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"
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" }
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"
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 to1
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
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
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
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 %>
target: :_topensures 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 %>
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
No
Turbo-Frameheader 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
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:
- Send a request with the
Turbo-Frameheader when testing a frame endpoint - Assert the response status
- Use
assert_turbo_frameorassert_no_turbo_frame - 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)