DEV Community

Pavel Myslik
Pavel Myslik

Posted on

Testing Turbo Frames in Rails Without a Browser

Turbo Frames are a great way to build interactive pages without writing custom JavaScript. But how do you test them properly?

You can reach for system tests with Capybara and a real browser, but for many Turbo Frame interactions, this can be unnecessary overkill.

Rails integration tests are faster and more stable, and the turbo-rails gem provides dedicated Minitest assertions for working with Turbo Frames that many Rails developers don't know about.


How a Turbo Frame Request Works

When the browser loads content into a <turbo-frame> element, it adds an HTTP header to the request:

Turbo-Frame: "frame_name"
Enter fullscreen mode Exit fullscreen mode

turbo-rails detects this header and renders the response using a minimal layout optimized for Turbo Frame requests instead of the full application layout.

This is a rendering optimization, since Turbo extracts the matching <turbo-frame> element from the response in the browser and swaps it into the page. The rest of the page is already present and does not need to be rendered again.


Simulating a Turbo Frame Request in Tests

To simulate a Turbo Frame request in a controller test, just add the header:

get edit_article_path(article), headers: { "Turbo-Frame" => "article_1" }
Enter fullscreen mode Exit fullscreen mode

Rails will treat the request as a Turbo Frame request and render the response accordingly, as it would when triggered from a <turbo-frame> in the browser.


Turbo Frame Test Helpers

The turbo-rails gem provides two main assertion helpers for testing <turbo-frame> elements in Rails integration tests.

assert_turbo_frame

Asserts that the response contains a <turbo-frame> element with the given attributes.

assert_turbo_frame "article_1"
Enter fullscreen mode Exit fullscreen mode

Arguments:

  • ids — the frame's [id] attribute. Also accepts an ActiveRecord object and the helper will derive the correct DOM id automatically.

Supported options:

  • src — matches the [src] attribute (useful for lazy-loaded frames)
  • target — matches the [target] attribute
  • loading — matches the [loading] attribute
  • count — expected number of matching frames, defaults to 1

You can also pass a block to assert the frame's contents:

assert_turbo_frame "article_1" do
  assert_select "h1", text: "Edit"
  assert_select "form"
end
Enter fullscreen mode Exit fullscreen mode

Nested frames can be asserted the same way:

assert_turbo_frame "article_1" do
  assert_turbo_frame "article_1_comments", loading: "lazy", src: article_comments_path(article)
end
Enter fullscreen mode Exit fullscreen mode

assert_no_turbo_frame

Asserts that the response does not contain a matching <turbo-frame> element. Accepts the same options as assert_turbo_frame.


Practical Examples

Let's look at a common pattern: a lazily-loaded "related articles" section on an article page.

The page initially renders a lazy-loaded frame and Turbo automatically fetches its content when it enters the viewport.

The controller:

# app/controllers/articles_controller.rb

class ArticlesController < ApplicationController
  before_action :set_article, only: %i[show related]

  def show
  end

  def related
    @related_articles = Article.related_to(@article).limit(5)
  end

  private

  def set_article
    @article = Article.find(params[:id])
  end
end
Enter fullscreen mode Exit fullscreen mode

Article.related_to(@article) returns articles in the same category excluding the article itself.

The article show page renders a lazy-loaded frame:

<!--app/views/articles/show.html.erb-->

<%= turbo_frame_tag "related_articles",
      src: related_article_path(@article),
      loading: :lazy,
      target: :_top do %>
  <p>Loading related articles...</p>
<% end %>
Enter fullscreen mode Exit fullscreen mode

target: :_top ensures that links inside the frame (to articles) trigger full-page navigation.

When the frame is requested, the related action returns the content:

<!--app/views/articles/related.html.erb-->

<%= turbo_frame_tag "related_articles" do %>
  <h2>Related articles</h2>
  <ul>
    <% @related_articles.each do |article| %>
      <li><%= link_to article.name, article %></li>
    <% end %>
  </ul>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Testing the Frame Placeholder

The initial page response should contain the frame placeholder with the expected attributes:

# test/controllers/articles_controller_test.rb

test "show renders the lazy related articles frame" do
    article = articles(:ruby_one)

    get article_url(article)

    assert_response :success

    assert_turbo_frame "related_articles", src: related_article_path(article), target: "_top", loading: "lazy" do
      assert_select "p", text: "Loading related articles…"
    end
  end
Enter fullscreen mode Exit fullscreen mode

No Turbo-Frame header is needed here because this is a normal page request. We're testing that the page includes the lazy-loaded frame, not the request that populates it.

 Testing the Loaded Frame Content

To test the frame contents, simulate the request Turbo would make when loading the frame:

# test/controllers/articles_controller_test.rb

test "related renders articles from the same category" do
  article = articles(:ruby_one)

  get related_article_url(article), headers: { "Turbo-Frame" => "related_articles" }

  assert_response :success

  assert_turbo_frame "related_articles" do
    assert_select "h2", text: "Related articles"

    assert_select "a", text: articles(:ruby_two).name
    assert_select "a", text: articles(:rails_one).name, count: 0
  end
end
Enter fullscreen mode Exit fullscreen mode

What Integration Tests Won't Catch

Integration tests are excellent for verifying server-side behavior, but they cannot test everything about Turbo Frames.

In particular, they won't verify:

  • Whether Turbo successfully swaps the frame content in the browser
  • Focus management after a frame update
  • Scroll position preservation
  • JavaScript interactions triggered by frame loads
  • Timing-related issues and race conditions

For scenarios where browser behavior is part of the functionality, system tests with Capybara are still the right tool.


Summary

Rails integration tests can simulate Turbo Frame requests by sending the Turbo-Frame header, while assert_turbo_frame and assert_no_turbo_frame make it easy to verify the response.

The basic workflow is:

  1. Send a request with the Turbo-Frame header when testing a frame endpoint
  2. Assert the response status
  3. Use assert_turbo_frame or assert_no_turbo_frame
  4. Optionally assert the frame contents with a block

For server-side behavior, this approach is fast, deterministic, and significantly faster and less fragile than system tests.

Do you use integration tests for Turbo Frames, or do you reach for system tests? Let me know in the comments.

Top comments (0)