Drop-in test matchers for hotwired/turbo-rails — replace every hand-rolled Turbo helper in your test suite with a single gem.
- Request/controller specs —
have_turbo_stream,have_turbo_frame,have_turbo_streams,have_stimulus_controller,have_stimulus_action,have_stimulus_target - Broadcast specs —
have_broadcasted_turbo_stream_towith count qualifiers - System/feature specs — Capybara matchers:
have_turbo_frame,have_turbo_stream_tag,within_turbo_frame,have_stimulus_controller,have_stimulus_action,have_stimulus_target - Minitest —
assert_turbo_stream,refute_turbo_stream,assert_turbo_frame,refute_turbo_frame,assert_broadcasted_turbo_stream_to,refute_broadcasted_turbo_stream_to - Factory helpers —
turbo_stream_html,turbo_frame_html - Shared examples —
it_behaves_like "a turbo stream response" - Auto-included — zero setup required when
turbo-railsis in your bundle
Docs: API Reference · Migration Guide · Cookbook
Add to your application's Gemfile:
group :test do
gem "turbo_rspec"
endRun the install generator to scaffold a spec/support/turbo_rspec.rb configuration file:
rails generate turbo_rspec:installNo setup needed. When turbo-rails is in your bundle:
TurboRspec::Matchersis automatically included in alltype: :requestexample groupsTurboRspec::Capybara::Matchersis automatically included in alltype: :systemandtype: :featureexample groups whencapybarais also present
For non-Rails projects or custom contexts, include the matchers explicitly:
# spec/spec_helper.rb
RSpec.configure do |config|
config.include TurboRspec::Matchers # request specs
config.include TurboRspec::Capybara::Matchers # system/feature specs
end# spec/support/turbo_rspec.rb
TurboRspec.configure do |config|
config.auto_include = false # disable automatic inclusion
endAssert that a response body contains a <turbo-stream> element.
# Basic — any turbo stream present
expect(response).to have_turbo_stream
# With action
expect(response).to have_turbo_stream.with_action(:append)
expect(response).to have_turbo_stream.with_action(:replace)
expect(response).to have_turbo_stream.with_action(:remove)
# With target (single DOM id)
expect(response).to have_turbo_stream.targeting("messages")
# With targets (CSS selector)
expect(response).to have_turbo_stream.targeting_all(".message-item")
# With content
expect(response).to have_turbo_stream.with_content("Hello, world!")
# With partial
expect(response).to have_turbo_stream.rendering("messages/_message")
# Chained — all constraints must match the same stream
expect(response).to have_turbo_stream
.with_action(:append)
.targeting("messages")
.with_content("Hello")
# With arbitrary attributes
expect(response).to have_turbo_stream.with_attributes("data-controller" => "messages")
# Turbo 8 morph with children-only
expect(response).to have_turbo_stream.with_action(:morph).children_only
# Count qualifiers — assert how many matching streams appear
expect(response).to have_turbo_stream.with_action(:append).once
expect(response).to have_turbo_stream.with_action(:append).twice
expect(response).to have_turbo_stream.with_action(:append).exactly(3).times
expect(response).to have_turbo_stream.with_action(:append).at_least(2).times
expect(response).to have_turbo_stream.with_action(:append).at_most(1).times
# Negation
expect(response).not_to have_turbo_stream.with_action(:replace)Turbo's built-in stream actions: append, prepend, replace, update, remove, before, after, refresh, morph.
with_action raises ArgumentError for unrecognised names. Register custom actions in your test setup:
# spec/support/turbo_rspec.rb
TurboRspec.register_action(:sparkle, :highlight)Assert that a response contains all of the specified streams in one expectation.
expect(response).to have_turbo_streams(
have_turbo_stream.with_action(:append).targeting("messages"),
have_turbo_stream.with_action(:replace).targeting("header")
)When a stream is missing the failure message lists each unmatched matcher so you can see at a glance which ones failed.
Alias of have_turbo_stream for teams that mix RSpec and minitest terminology.
expect(response).not_to assert_no_turbo_streamAssert that a response body contains a <turbo-frame> element.
# Basic — any turbo frame present
expect(response).to have_turbo_frame
# With id
expect(response).to have_turbo_frame.with_id("messages")
# With content
expect(response).to have_turbo_frame.with_id("messages").with_content("Hello")
# With partial
expect(response).to have_turbo_frame.with_id("post").rendering("posts/_post")
# Negation
expect(response).not_to have_turbo_frame.with_id("notifications")Assert that a block broadcasts a <turbo-stream> over ActionCable. Requires ActionCable's test adapter.
# Basic — any broadcast to the stream
expect { MyJob.perform_now }.to have_broadcasted_turbo_stream_to("notifications")
# With constraints (same chain as have_turbo_stream)
expect { MyJob.perform_now }.to have_broadcasted_turbo_stream_to("notifications")
.with_action(:append)
.targeting("messages")
.with_content("Hello")
# With arbitrary attributes
expect { MyJob.perform_now }.to have_broadcasted_turbo_stream_to("notifications")
.with_attributes("data-controller" => "messages")
# Turbo 8 morph with children-only
expect { MyJob.perform_now }.to have_broadcasted_turbo_stream_to("body")
.with_action(:morph).children_only
# Count qualifiers
expect { MyJob.perform_now }.to have_broadcasted_turbo_stream_to("notifications").once
expect { MyJob.perform_now }.to have_broadcasted_turbo_stream_to("notifications").exactly(3).times
expect { MyJob.perform_now }.to have_broadcasted_turbo_stream_to("notifications").at_least(2).times
# Alias
expect { MyJob.perform_now }.to broadcast_turbo_stream_to("notifications")
# Negation
expect { MyJob.perform_now }.not_to have_broadcasted_turbo_stream_to("notifications")Assert that a <turbo-frame> element is present on the page (Capybara).
# Basic
expect(page).to have_turbo_frame("messages")
# With content
expect(page).to have_turbo_frame("messages").with_content("Hello")
# Loaded (frame finished loading)
expect(page).to have_turbo_frame("messages").loaded
# Lazy frame — assert loading="lazy" attribute
expect(page).to have_turbo_frame("messages").lazy
# Eager frame — assert loading="eager" attribute (Turbo 8)
expect(page).to have_turbo_frame("messages").eager
# Strict frame — assert [strict] attribute (Turbo 8)
expect(page).to have_turbo_frame("messages").strict
# With src — assert the src attribute on a lazy-loaded frame
expect(page).to have_turbo_frame("messages").with_src("/messages/new")
# With arbitrary attributes (request specs only — use Capybara selectors in system specs)
expect(response).to have_turbo_frame.with_attributes("data-controller" => "chat")
# Chained
expect(page).to have_turbo_frame("messages").lazy.with_src("/messages/new")
# Negation
expect(page).not_to have_turbo_frame("notifications")Scope Capybara assertions to a specific frame's DOM.
within_turbo_frame("messages") do
expect(page).to have_content("Hello")
click_button "Reply"
endhave_stimulus_controller, have_stimulus_action, and have_stimulus_target work in both request specs (parsing response HTML) and system/feature specs (Capybara).
# Request specs — asserts Stimulus attributes in rendered HTML response
expect(response).to have_stimulus_controller("hello")
expect(response).to have_stimulus_action("click->hello#greet")
expect(response).to have_stimulus_action("hello#greet") # shorthand — matches any event
expect(response).to have_stimulus_target("hello", "name")
# System/feature specs — asserts on the live Capybara page
expect(page).to have_stimulus_controller("hello")
expect(page).to have_stimulus_action("click->hello#greet")
expect(page).to have_stimulus_target("hello", "name")
# Negation
expect(response).not_to have_stimulus_controller("missing")
expect(page).not_to have_stimulus_controller("missing")All three matchers use space-separated token matching (~=), so they work correctly when multiple controllers, actions, or targets are declared on a single element.
Assert that a <turbo-stream-source> subscription element is on the page.
# Any stream source
expect(page).to have_turbo_stream_tag
# With signed stream name
expect(page).to have_turbo_stream_tag("signed_stream_name")
# Negation
expect(page).not_to have_turbo_stream_tagRecord a turbo stream response on the first run and diff against it on subsequent runs. Good for complex multi-stream responses where specifying every constraint inline is noisy.
# First run — writes spec/snapshots/turbo/messages/new.turbo
expect(response).to match_turbo_stream_snapshot("messages/new")
# Subsequent runs — diffs against stored snapshot
expect(response).to match_turbo_stream_snapshot("messages/new")Set UPDATE_TURBO_SNAPSHOTS=1 to overwrite an existing snapshot. Configure the storage directory:
# spec/support/turbo_rspec.rb
TurboRspec.configure do |config|
config.snapshot_dir = "spec/fixtures/turbo_snapshots"
endLoad the TurboRspec/UseHaveTurboStream cop to catch raw response.body assertions:
# .rubocop.yml
require:
- turbo_rspec/rubocop# Flagged
expect(response.body).to include("<turbo-stream")
expect(response.body).to match(/turbo-stream/)
# Preferred
expect(response).to have_turbo_stream.with_action(:append)TurboRspec::Helpers provides factory methods for building Turbo HTML inline in tests. Auto-included in type: :request and type: :controller example groups.
# Build a <turbo-stream> element
turbo_stream_html(action: :append, target: "messages", content: "Hello")
turbo_stream_html(action: :remove, targets: ".item")
# Build a <turbo-frame> element
turbo_frame_html(id: "messages", content: "Hello")RSpec.describe "Messages", type: :request do
describe "POST /messages" do
before { post messages_path, params: { body: "Hello" }, as: :turbo_stream }
# Assert any turbo stream is present
it_behaves_like "a turbo stream response"
# Assert a specific stream
it_behaves_like "a turbo stream response", action: :append, target: "messages", content: "Hello"
# Assert a turbo frame
it_behaves_like "a turbo frame response", id: "messages"
end
endRSpec.describe "Messages", type: :request do
describe "POST /messages" do
it "appends the new message to the list" do
post messages_path, 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
describe "DELETE /messages/:id" do
it "removes the message row" do
message = create(:message)
delete message_path(message),
headers: { "Accept" => "text/vnd.turbo-stream.html" }
expect(response).to have_turbo_stream
.with_action(:remove)
.targeting("message_#{message.id}")
end
end
endRSpec.describe "Messages", type: :system do
it "appends a new message via Turbo Frame" do
visit messages_path
fill_in "Body", with: "Hello"
click_button "Send"
expect(page).to have_turbo_frame("messages").with_content("Hello")
end
it "shows the subscription stream tag" do
visit messages_path
expect(page).to have_turbo_stream_tag
end
endTurboRspec::Assertions is an opt-in companion module with no RSpec dependency. Include it in any Minitest test class:
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
include TurboRspec::Assertions
end# Stream assertions
assert_turbo_stream(response, action: :append, target: "messages")
assert_turbo_stream(response, action: :append, target: "messages", content: "Hello")
assert_turbo_stream(response, targets: ".items")
assert_turbo_stream(response, partial: "messages/_message")
refute_turbo_stream(response, action: :replace)
# Frame assertions
assert_turbo_frame(response, id: "messages")
assert_turbo_frame(response, id: "messages", content: "Hello")
refute_turbo_frame(response, id: "notifications")
# Custom failure message
assert_turbo_stream(response, action: :append, message: "expected append stream")
# Broadcast assertions (requires ActionCable test adapter)
assert_broadcasted_turbo_stream_to("notifications") { MyJob.perform_now }
assert_broadcasted_turbo_stream_to("notifications", action: :append, target: "messages") { MyJob.perform_now }
refute_broadcasted_turbo_stream_to("notifications") { MyJob.perform_now }Bug reports and pull requests are welcome on GitHub. See CONTRIBUTING.md for setup instructions, branch conventions, CHANGELOG requirements, and the PR checklist.
The gem is available as open source under the MIT License.