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_tosetup 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.