TurboRspec

CI Gem Version Gem Downloads Ruby codecov

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::Matchers is automatically included in all type: :request example groups
  • TurboRspec::Capybara::Matchers is automatically included in all type: :system and type: :feature example groups when capybara is 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_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")
  click_button "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 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
end

Example: system spec

RSpec.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
end

Contributing

Bug reports and pull requests are welcome on GitHub.

License

The gem is available as open source under the MIT License.