TurboRspec
RSpec matchers for Turbo — assert Turbo Stream responses, Turbo Frame content, and ActionCable broadcasts without hand-rolling helpers in every project.
Installation
Add to your application's Gemfile:
group :test do
gem "turbo_rspec"
end
Setup
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")
# Negation
expect(response).not_to have_turbo_stream.with_action(:replace)
Actions
Turbo supports the following stream actions: append, prepend, replace, update, remove, before, after, refresh.
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")
# 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
# 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
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
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
Contributing
Bug reports and pull requests are welcome on GitHub.
License
The gem is available as open source under the MIT License.