Testing Hotwire Apps with RSpec

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
end

Including 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 %>
  

<%= comment.body %>

<%= link_to "Edit", edit_comment_path(comment), class: "edit-link" %>
<% end %> # app/views/comments/edit.html.erb <%= turbo_frame_tag dom_id(@comment) do %> <%= form_with model: @comment, class: "comment-form" do |f| %> <%= f.text_area :body %> <%= f.submit "Update" %> <% end %> <% end %>

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
end

The 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
end

Testing 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
Options

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
end

Testing 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_id consistently 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.

13 claps
← Back to Blog