Testing Hotwire applications presents unique challenges. Turbo Frames load content asynchronously, Turbo Streams update multiple DOM elements simultaneously, and Stimulus controllers add JavaScript behavior that traditional controller tests cannot verify. This guide covers practical patterns for testing all three Hotwire components with RSpec and Capybara.
Setting Up the Test Environment
Rails 8 system tests work out of the box with Hotwire, but a few configuration tweaks improve reliability. The default Selenium driver handles JavaScript execution, which is essential for testing Stimulus and Turbo behavior.
# spec/support/capybara.rb
RSpec.configure do |config|
config.before(:each, type: :system) do
driven_by :selenium, using: :headless_chrome, screen_size: [1400, 900]
end
end
# spec/rails_helper.rb
require 'capybara/rspec'
require_relative 'support/capybara'
RSpec.configure do |config|
config.include ActionView::RecordIdentifier, type: :system
endIncluding ActionView::RecordIdentifier provides access to the dom_id helper, which matches the IDs that Turbo Streams use for targeting elements.
Testing Turbo Frames
Turbo Frames replace content within a specific region of the page. Tests must wait for the frame to load before making assertions. Capybara's built-in waiting behavior handles most cases, but explicit frame targeting ensures tests remain stable.
Consider a comment system where clicking "Edit" loads a form inside a Turbo Frame:
# app/views/comments/_comment.html.erb
<%= turbo_frame_tag dom_id(comment) do %>
The system test verifies the frame loads and the form submits correctly:
# spec/system/comments_spec.rb
require 'rails_helper'
RSpec.describe "Comment editing", type: :system do
let!(:comment) { Comment.create!(body: "Original text", post: post) }
let(:post) { Post.create!(title: "Test Post") }
it "edits a comment inline via Turbo Frame" do
visit post_path(post)
within "##{dom_id(comment)}" do
click_link "Edit"
# Capybara waits for the form to appear
expect(page).to have_css("form.comment-form")
fill_in "comment_body", with: "Updated text"
click_button "Update"
end
# Frame replaces with updated content
within "##{dom_id(comment)}" do
expect(page).to have_text("Updated text")
expect(page).not_to have_css("form")
end
end
endThe within block scopes assertions to the specific Turbo Frame. This prevents false positives when multiple frames exist on the page.
Testing Turbo Streams
Turbo Streams update multiple page elements in response to a single action. A typical pattern broadcasts changes via WebSocket or returns stream responses from form submissions. Tests must verify that all targeted elements update correctly.
This example tests a task list where creating a task appends it to the list and updates a counter:
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
def create
@task = current_project.tasks.build(task_params)
if @task.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to project_path(current_project) }
end
else
render :new, status: :unprocessable_entity
end
end
end
# app/views/tasks/create.turbo_stream.erb
<%= turbo_stream.append "tasks" do %>
<%= render @task %>
<% end %>
<%= turbo_stream.update "task_count" do %>
<%= current_project.tasks.count %> tasks
<% end %>The test verifies both stream actions execute:
# spec/system/tasks_spec.rb
require 'rails_helper'
RSpec.describe "Task creation", type: :system do
let(:project) { Project.create!(name: "Test Project") }
before do
project.tasks.create!(name: "Existing task")
end
it "appends new task and updates counter via Turbo Stream" do
visit project_path(project)
expect(page).to have_css("#task_count", text: "1 tasks")
fill_in "task_name", with: "New task"
click_button "Add Task"
# Verify append action
within "#tasks" do
expect(page).to have_text("Existing task")
expect(page).to have_text("New task")
end
# Verify update action
expect(page).to have_css("#task_count", text: "2 tasks")
# Confirm no full page reload occurred
expect(page).not_to have_current_path(project_tasks_path(project))
end
endTesting that the URL did not change confirms Turbo handled the response without a redirect.
Testing Stimulus Controllers
Stimulus controllers add JavaScript behavior to HTML elements. Tests should verify user-facing behavior rather than internal controller state. This approach keeps tests resilient to refactoring.
Consider a dropdown controller that toggles visibility:
# app/javascript/controllers/dropdown_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["menu"]
static classes = ["hidden"]
toggle() {
this.menuTarget.classList.toggle(this.hiddenClass)
}
close(event) {
if (!this.element.contains(event.target)) {
this.menuTarget.classList.add(this.hiddenClass)
}
}
}# app/views/shared/_dropdown.html.erb
The test interacts with the dropdown as a user would:
# spec/system/dropdown_spec.rb
require 'rails_helper'
RSpec.describe "Dropdown behavior", type: :system do
it "toggles menu visibility on click" do
visit page_with_dropdown_path
menu = find("[data-dropdown-target='menu']")
expect(menu[:class]).to include("hidden")
click_button "Options"
expect(menu[:class]).not_to include("hidden")
click_button "Options"
expect(menu[:class]).to include("hidden")
end
it "closes menu when clicking outside" do
visit page_with_dropdown_path
click_button "Options"
menu = find("[data-dropdown-target='menu']")
expect(menu[:class]).not_to include("hidden")
find("body").click
expect(menu[:class]).to include("hidden")
end
endTesting the actual CSS class changes verifies the Stimulus controller connected and responds to events correctly.
Handling Asynchronous Behavior
Hotwire operations complete asynchronously. While Capybara's default wait time handles most situations, some patterns require explicit waiting. Avoid sleep statements, which slow tests and introduce flakiness.
Use have_css or have_text matchers with Capybara's built-in waiting:
# Bad: Arbitrary sleep
sleep 2
expect(page).to have_text("Updated")
# Good: Capybara waits up to default_max_wait_time
expect(page).to have_text("Updated")
# Good: Custom wait for slow operations
expect(page).to have_text("Updated", wait: 5)For WebSocket-based Turbo Streams, ensure Action Cable connects before triggering broadcasts. Adding a connection indicator element provides a reliable wait target.
Common Pitfalls
Several issues frequently trip up Hotwire testing:
- Stale element references: After Turbo replaces content, previously found elements become stale. Always re-query elements after expecting DOM changes.
- Frame ID mismatches: Turbo Frame replacement requires matching IDs. Use
dom_idconsistently to prevent subtle bugs. - Missing JavaScript driver: Request specs and controller tests skip JavaScript. Any Hotwire behavior requires system tests with a JS-capable driver.
- Turbo cache interference: Turbo caches pages for back/forward navigation. Add
data-turbo-cache="false"to test pages if cache causes unexpected state.
Summary
Testing Hotwire applications follows a consistent pattern: interact with elements as users do, wait for asynchronous updates using Capybara matchers, and verify DOM changes rather than internal state. System tests with a JavaScript driver cover Turbo Frames, Turbo Streams, and Stimulus controllers in a single, integrated approach. This strategy catches integration bugs that unit tests miss while remaining fast enough for continuous integration pipelines.
<%= comment.body %>
<%= link_to "Edit", edit_comment_path(comment), class: "edit-link" %>