Testing Turbo with Rails: Request Specs, Broadcasts, and System Tests

Testing Turbo applications isn't complicated, but it's different from testing traditional Rails apps. You're not just checking HTML responses anymore—you're verifying stream actions, broadcast payloads, and WebSocket behavior.
turbo-rails ships with a complete set of test helpers. Here's how to use them across request specs, model tests, and system tests.

Request Specs: Testing Turbo Stream Responses

When a controller responds with format.turbo_stream, you need to verify the response contains the right stream actions. turbo-rails adds assert_turbo_stream to your integration tests.
Basic stream response test:
class MessagesControllerTest < ActionDispatch::IntegrationTest
  test "create appends message via turbo stream" do
    room = rooms(:general)
    
    post room_messages_path(room),
      params: { message: { content: "Hello world" } },
      as: :turbo_stream
    
    assert_response :success
    assert_turbo_stream action: "append", target: "messages"
  end
end
The as: :turbo_stream sets the request's Accept header to text/vnd.turbo-stream.html. Without it, your controller will respond with HTML instead of streams.
Testing with model objects:
test "destroy removes message" do
  message = messages(:greeting)
  
  delete room_message_path(message.room, message), as: :turbo_stream
  
  # Pass the model directly - it converts to dom_id automatically
  assert_turbo_stream action: "remove", target: message
end
Testing stream content:
test "update replaces message with new content" do
  message = messages(:greeting)
  
  patch room_message_path(message.room, message),
    params: { message: { content: "Updated content" } },
    as: :turbo_stream
  
  assert_turbo_stream action: "replace", target: message do
    assert_select "template p", text: "Updated content"
  end
end
Multiple stream actions:
test "create appends message and updates counter" do
  room = rooms(:general)
  
  post room_messages_path(room),
    params: { message: { content: "New message" } },
    as: :turbo_stream
  
  # Test each action separately
  assert_turbo_stream action: "append", target: "messages"
  assert_turbo_stream action: "update", target: "message_count"
end
Testing multiple targets with CSS selectors:
test "bulk update targets all prices" do
  post bulk_update_prices_path, as: :turbo_stream
  
  # For streams that use targets (CSS selector) instead of target (ID)
  assert_turbo_stream action: "update", targets: ".price"
end
Asserting absence of streams:
test "invalid message doesn't append" do
  room = rooms(:general)
  
  post room_messages_path(room),
    params: { message: { content: "" } },  # Invalid - blank content
    as: :turbo_stream
  
  assert_turbo_stream action: "replace", target: "new_message"  # Re-renders form
  assert_no_turbo_stream action: "append", target: "messages"   # No append
end
Testing with specific HTTP status:
test "validation errors return unprocessable entity" do
  room = rooms(:general)
  
  post room_messages_path(room),
    params: { message: { content: "" } },
    as: :turbo_stream
  
  assert_turbo_stream action: "replace", target: "new_message", status: :unprocessable_entity
end

RSpec Request Specs

If you're using RSpec, the same assertions work—just include the helper:
RSpec.describe "Messages", type: :request do
  include Turbo::TestAssertions::IntegrationTestAssertions
  
  describe "POST /rooms/:room_id/messages" do
    it "appends message via turbo stream" do
      room = create(:room)
      
      post room_messages_path(room),
        params: { message: { content: "Hello" } },
        as: :turbo_stream
      
      assert_turbo_stream action: "append", target: "messages"
    end
  end
end
Or create a shared context:
# spec/support/turbo_stream_helpers.rb
RSpec.configure do |config|
  config.include Turbo::TestAssertions::IntegrationTestAssertions, type: :request
end

Model Tests: Testing Broadcasts

When your models use broadcasts_to or call broadcast_append_to directly, you need to verify those broadcasts happen. Include Turbo::Broadcastable::TestHelper:
class MessageTest < ActiveSupport::TestCase
  include Turbo::Broadcastable::TestHelper
  
  test "broadcasts append on create" do
    room = rooms(:general)
    
    assert_turbo_stream_broadcasts room do
      Message.create!(room: room, content: "Hello")
    end
  end
end
Testing specific broadcast count:
test "broadcasts exactly one stream on create" do
  room = rooms(:general)
  
  assert_turbo_stream_broadcasts room, count: 1 do
    Message.create!(room: room, content: "Hello")
  end
end
Testing with string stream names:
test "broadcasts to notifications channel" do
  user = users(:alice)
  
  assert_turbo_stream_broadcasts "user_#{user.id}:notifications" do
    Notification.create!(user: user, message: "You have mail")
  end
end
Asserting no broadcasts:
test "invalid records don't broadcast" do
  room = rooms(:general)
  
  assert_no_turbo_stream_broadcasts room do
    Message.create(room: room, content: "")  # Invalid, won't save
  end
end
Capturing and inspecting broadcasts:
test "broadcasts with correct action" do
  room = rooms(:general)
  
  streams = capture_turbo_stream_broadcasts room do
    message = Message.create!(room: room, content: "Hello")
    message.update!(content: "Updated")
    message.destroy!
  end
  
  assert_equal 3, streams.size
  assert_equal "append", streams[0]["action"]
  assert_equal "replace", streams[1]["action"]
  assert_equal "remove", streams[2]["action"]
end
Testing broadcast content:
test "broadcast includes message content" do
  room = rooms(:general)
  
  streams = capture_turbo_stream_broadcasts room do
    Message.create!(room: room, content: "Hello world")
  end
  
  # streams are Nokogiri elements - query them like HTML
  template = streams.first.at("template")
  assert_match /Hello world/, template.inner_html
end
Suppressing broadcasts in tests:
test "can suppress broadcasts for setup" do
  room = rooms(:general)
  
  # Create test data without triggering broadcasts
  Message.suppressing_turbo_broadcasts do
    10.times { Message.create!(room: room, content: "Setup message") }
  end
  
  # Now test the broadcast you care about
  assert_turbo_stream_broadcasts room, count: 1 do
    Message.create!(room: room, content: "The one I'm testing")
  end
end

System Tests: Testing Real-Time Updates

System tests verify the full flow—including JavaScript and WebSocket connections. The tricky part is timing: you need to wait for WebSocket connections before triggering broadcasts.
Basic real-time test:
class MessagesSystemTest < ApplicationSystemTestCase
  test "new messages appear in real-time" do
    room = rooms(:general)
    
    visit room_path(room)
    
    # Wait for WebSocket connection
    connect_turbo_cable_stream_sources
    
    # Trigger a broadcast (simulating another user)
    Message.create!(room: room, content: "Hello from another user")
    
    # Assert the message appears
    assert_text "Hello from another user"
  end
end
The connect_turbo_cable_stream_sources helper waits until all <turbo-cable-stream-source> elements on the page have established their WebSocket connections.
Testing after navigation:
test "receives broadcasts after clicking through" do
  room = rooms(:general)
  
  visit rooms_path
  click_link room.name
  
  # Need to connect again after navigation
  connect_turbo_cable_stream_sources
  
  Message.create!(room: room, content: "New message")
  
  assert_text "New message"
end
Auto-connect configuration:
By default, turbo-rails auto-connects after visit. You can extend this:
# config/environments/test.rb
config.turbo.test_connect_after_actions << :click_link
config.turbo.test_connect_after_actions << :click_button
Or disable auto-connect entirely:
# config/environments/test.rb
config.turbo.test_connect_after_actions = []
Asserting stream source presence:
test "page subscribes to room updates" do
  room = rooms(:general)
  
  visit room_path(room)
  
  # Verify the subscription element exists and is connected
  assert_turbo_cable_stream_source connected: true
end

test "leaving room removes subscription" do
  room = rooms(:general)
  
  visit room_path(room)
  click_link "Leave Room"
  
  assert_no_turbo_cable_stream_source
end
Testing Turbo Frame updates:
test "edit form loads in frame" do
  message = messages(:greeting)
  
  visit room_path(message.room)
  
  within "#message_#{message.id}" do
    click_link "Edit"
  end
  
  # Form should appear inside the same frame
  within "#message_#{message.id}" do
    assert_selector "form"
    fill_in "Content", with: "Updated"
    click_button "Save"
  end
  
  # Updated content should appear
  within "#message_#{message.id}" do
    assert_text "Updated"
    assert_no_selector "form"
  end
end

Testing Turbo Drive Navigation

test "navigation doesn't full-page reload" do
  visit rooms_path
  
  # Check that Turbo Drive is active
  assert_selector "html[data-turbo-preview]", visible: false, wait: 0
  
  original_body = page.find("body")["data-turbo-body"]
  
  click_link "About"
  
  # Body should be swapped, not reloaded
  # (The data attribute persists across Turbo navigations)
end

Common Testing Patterns

Testing flash messages via streams:
test "shows flash after create" do
  room = rooms(:general)
  
  post room_messages_path(room),
    params: { message: { content: "Hello" } },
    as: :turbo_stream
  
  assert_turbo_stream action: "update", target: "flash" do
    assert_select "template .flash-notice"
  end
end
Testing empty state handling:
test "shows empty state when last item removed" do
  message = messages(:only_message)  # Last message in room
  
  delete room_message_path(message.room, message), as: :turbo_stream
  
  assert_turbo_stream action: "remove", target: message
  assert_turbo_stream action: "update", target: "messages" do
    assert_select "template .empty-state"
  end
end
Testing morph updates:
test "counter updates without losing form state" do
  room = rooms(:general)
  
  visit room_path(room)
  
  # Start typing in a form
  fill_in "message_content", with: "Draft message"
  
  # Trigger a morph update
  Message.create!(room: room, content: "From another user")
  
  # Form state should be preserved
  assert_field "message_content", with: "Draft message"
  
  # But counter should be updated
  assert_selector "#message_count", text: room.messages.count.to_s
end

Test Setup Tips

1. Use the async adapter in tests:
# config/environments/test.rb
config.active_job.queue_adapter = :test

# For broadcasts to work synchronously in tests:
config.active_job.queue_adapter = :inline
2. Configure Action Cable for testing:
# config/environments/test.rb
config.action_cable.disable_request_forgery_protection = true
3. Clear broadcasts between tests (if needed):
class ActiveSupport::TestCase
  teardown do
    ActionCable.server.pubsub.clear if ActionCable.server.pubsub.respond_to?(:clear)
  end
end
4. Increase Capybara timeout for WebSocket tests:
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  # WebSocket connections need a bit more time
  Capybara.default_max_wait_time = 5
end

Debugging Turbo Tests

Print the response body:
test "debugging stream response" do
  post room_messages_path(room), params: { message: { content: "Test" } }, as: :turbo_stream
  
  puts response.body  # See the actual turbo stream HTML
  
  assert_turbo_stream action: "append", target: "messages"
end
Inspect captured broadcasts:
test "debugging broadcasts" do
  streams = capture_turbo_stream_broadcasts room do
    Message.create!(room: room, content: "Test")
  end
  
  streams.each do |stream|
    puts "Action: #{stream['action']}"
    puts "Target: #{stream['target']}"
    puts "HTML: #{stream.at('template')&.inner_html}"
  end
end
Check WebSocket connection in system tests:
test "debugging websocket" do
  visit room_path(room)
  
  # Check for stream source element
  sources = all("turbo-cable-stream-source", visible: false)
  puts "Found #{sources.count} stream sources"
  
  sources.each do |source|
    puts "Connected: #{source['connected']}"
    puts "Channel: #{source['channel']}"
  end
  
  connect_turbo_cable_stream_sources
end

The Testing Strategy

For Turbo apps, I use this testing split:
  • Request specs: Test that controllers return correct stream actions. Fast, comprehensive, catches most bugs.
  • Model specs: Test that broadcasts fire at the right times with correct payloads. Verify your broadcasts_to setup works.
  • System specs: Test critical real-time flows end-to-end. Slower, so use sparingly for the happy paths that matter most.
Request specs are your workhorse. They're fast and catch 90% of Turbo-related bugs. System tests are your safety net for the WebSocket pieces that request specs can't cover.

References