TurboRspec
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
Installation
Add to your application's Gemfile:
group :test do
gem "turbo_rspec"
end
Setup
Generator
Run the install generator to scaffold a spec/support/turbo_rspec.rb configuration file:
rails generate turbo_rspec:install
Rails + turbo-rails (automatic)
No 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
Manual include
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
Configuration
# spec/support/turbo_rspec.rb
TurboRspec.configure do |config|
config.auto_include = false # disable automatic inclusion
end
Matchers
have_turbo_stream
Assert 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)
Actions
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)
have_turbo_streams
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.
assert_no_turbo_stream
Alias of have_turbo_stream for teams that mix RSpec and minitest terminology.
expect(response).not_to assert_no_turbo_stream
have_turbo_frame
Assert 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")
have_broadcasted_turbo_stream_to
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")
have_turbo_frame (system/feature specs)
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")
within_turbo_frame
Scope Capybara assertions to a specific frame's DOM.
within_turbo_frame("messages") do
expect(page).to have_content("Hello")
"Reply"
end
Stimulus matchers
have_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.
have_turbo_stream_tag
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_tag
match_turbo_stream_snapshot
Record 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"
end
RuboCop cop
Load 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)
Test helpers
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")
Shared examples
RSpec.describe "Messages", type: :request do
describe "POST /messages" do
before { post , 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
end
Example: request spec
RSpec.describe "Messages", type: :request do
describe "POST /messages" do
it "appends the new message to the list" 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
describe "DELETE /messages/:id" do
it "removes the message row" do
= create(:message)
delete (),
headers: { "Accept" => "text/vnd.turbo-stream.html" }
expect(response).to have_turbo_stream
.with_action(:remove)
.targeting("message_#{.id}")
end
end
end
Example: system spec
RSpec.describe "Messages", type: :system do
it "appends a new message via Turbo Frame" do
visit
fill_in "Body", with: "Hello"
"Send"
expect(page).to have_turbo_frame("messages").with_content("Hello")
end
it "shows the subscription stream tag" do
visit
expect(page).to have_turbo_stream_tag
end
end
Minitest support
TurboRspec::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
Available assertions
# 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 }
Contributing
Bug reports and pull requests are welcome on GitHub. See CONTRIBUTING.md for setup instructions, branch conventions, CHANGELOG requirements, and the PR checklist.
License
The gem is available as open source under the MIT License.