Cookbook: Common Turbo Testing Patterns
Request specs
Asserting a single stream
RSpec.describe "Messages", type: :request do
it "appends the new message" do
post , params: { message: { body: "Hello" } },
headers: { "Accept" => "text/vnd.turbo-stream.html" }
expect(response).to have_turbo_stream
.with_action(:append)
.targeting("messages")
.with_content("Hello")
end
end
Asserting multiple streams in one expectation
it "updates the list and clears the form" do
post , params: { message: { body: "Hello" } }, as: :turbo_stream
expect(response).to have_turbo_streams(
have_turbo_stream.with_action(:append).targeting("messages"),
have_turbo_stream.with_action(:replace).targeting("message_form")
)
end
Using shared examples
RSpec.describe "Messages", type: :request do
describe "POST /messages" do
before { post , params: { body: "Hello" }, as: :turbo_stream }
it_behaves_like "a turbo stream response", action: :append, target: "messages"
end
end
Asserting a remove stream
it "removes the deleted message" do
delete (), as: :turbo_stream
expect(response).to have_turbo_stream
.with_action(:remove)
.targeting("message_#{.id}")
end
Asserting a Turbo Frame response
it "renders the edit form in the frame" do
get ()
expect(response).to have_turbo_frame.with_id("message_#{.id}")
end
Lazy-loaded Turbo Frames
it "lazy-loads the message list frame" do
get
# Assert the frame tag is rendered in the page
expect(response.body).to include('turbo-frame id="messages"')
end
it "responds to the frame src request" do
get , headers: { "Turbo-Frame" => "messages" }
expect(response).to have_turbo_frame.with_id("messages")
end
Broadcast matchers in job specs
Basic broadcast assertion
RSpec.describe NotifyUsersJob, type: :job do
it "broadcasts a stream to the user channel" do
expect { described_class.perform_now(user) }
.to have_broadcasted_turbo_stream_to("user_#{user.id}")
.with_action(:append)
.targeting("notifications")
end
end
Count qualifiers
it "broadcasts exactly once per recipient" do
expect { described_class.perform_now(users) }
.to have_broadcasted_turbo_stream_to("notifications")
.exactly(users.count).times
end
Broadcast to a model (requires turbo-rails)
it "broadcasts to the conversation channel" do
expect { described_class.perform_now }
.to have_broadcasted_turbo_stream_to(conversation)
.with_action(:append)
end
Multi-stream responses
A single Turbo Stream response can contain multiple <turbo-stream> tags. All matchers handle this correctly — have_turbo_stream checks if any stream matches, while have_turbo_streams requires all listed streams to be present.
it "broadcasts multiple updates" do
post bulk_update_path, as: :turbo_stream
# passes if any one stream is :append
expect(response).to have_turbo_stream.with_action(:append)
# passes only if both streams are present
expect(response).to have_turbo_streams(
have_turbo_stream.with_action(:append).targeting("list"),
have_turbo_stream.with_action(:replace).targeting("count")
)
end
Using factory helpers
RSpec.describe "Messages", type: :request do
# Build test HTML without hand-rolling strings
let(:stream_body) { turbo_stream_html(action: :append, target: "messages", content: "Hello") }
it "matches the expected stream" do
expect(stream_body).to have_turbo_stream.with_action(:append).with_content("Hello")
end
end
Minitest integration
class MessagesControllerTest < ActionDispatch::IntegrationTest
include TurboRspec::Assertions
test "appends the new message" do
post , params: { message: { body: "Hello" } }, as: :turbo_stream
assert_turbo_stream(response, action: :append, target: "messages", content: "Hello")
end
test "does not render a replace stream" do
post , params: { message: { body: "Hello" } }, as: :turbo_stream
refute_turbo_stream(response, action: :replace)
end
end
Controller specs
Matchers and helpers are also available in type: :controller specs:
RSpec.describe MessagesController, type: :controller do
it "responds with a turbo stream" do
post :create, params: { message: { body: "Hello" } },
format: :turbo_stream
expect(response).to have_turbo_stream.with_action(:append).targeting("messages")
end
end