Fun with Turbo Streams and Capybara

Using and testing Turbo Streams in a Rails app is quite fun, but comes with its share of quirks, which are not always well documented.

Let’s explain some of these with a very simple app, pompously titled “The Rodents Encyclopedia”. It consists of a dashboard displaying the number of rodents in the database, and a standard CRUD for these rodents. When a rodent is added, the dashboard counter is automagically updated.

The Rodents Encyclopedia app

The system spec

The feature is described in a system spec with Capybara and RSpec:

# spec/system/rodents_counter_spec.rb

feature 'Rodents Counter', puma: true, action_cable: :inline, active_job: :inline do
  background do
    driven_by :selenium, using: :headless_chrome
  end

  given!(:existing_rodent) { create(:rodent, name: 'Chipmunk') }

  scenario 'Creating a rodent updates the dashboard rodents counter' do
    visit '/'

    expect(page).to have_text 'There are 1 rodents in the encyclopedia!'

    new_window = open_new_window
    within_window new_window do
      visit '/rodents/new'
      fill_in 'Name', with: 'Squirrel'
      click_on 'Create Rodent'
    end

    expect(page).to have_text 'There are 2 rodents in the encyclopedia!'
  end
end

First peculiarity, the feature declaration:

feature 'Rodents Counter', puma: true, action_cable: :inline, active_job: :inline do

puma: true is a tag defined in a support file to use Puma as the Capybara server. It should help running concurrent requests.

action_cable: :inline is a tag defined by RSpec. I’m not sure yet if that one is required over the standard :async or :test adapters. Feel free to experiment on your project.

active_job: :inline is a tag defined in another support file. Broadcast methods ending in later will start an ActiveJob. If the job is not run immediately during the spec, Capybara won’t see its result. So this tag is essential to not lose your sanity in debug sessions.

Next, we select the Selenium driver, because the default rack_test one won’t run Javascript code:

driven_by :selenium, using: :headless_chrome

The actual spec code is now ready to run. It will first check that the dashboard counter is in its initial state, then proceed to create a rodent in a separate browser window, and finally check that the counter has been updated. Note that Capybara does not reload the dashboard page during the whole process, which is exactly the use case we want to test.

The broadcast

Triggering the counter update is done in the Rodent model whenever the record changes:

# app/models/rodent.rb

class Rodent < ApplicationRecord
  after_commit :update_rodents_counter

  private

  def update_rodents_counter
    broadcast_replace_later_to :dashboard, target: 'rodents-counter', partial: 'dashboard/rodents_counter', locals: {rodent: nil}
  end
end

Let’s decompose the broadcast_replace_later_to call:

The stream

The client subscribes to the channel in the dashboard view:

/ app/views/dashboard/show.html.slim

= turbo_stream_from :dashboard

h1 Dashboard

== render 'rodents_counter'

The :dashboard part is the channel scope used earlier in the broadcast.

In the rodents counter partial, the rodents-counter id is the target used in the broadcast:

/ app/views/dashboard/_rodents_counter.html.slim

p#rodents-counter There are #{Rodent.count} rodents in the encyclopedia!

That whole partial is rendered server-side and sent over the wire to the subscribed clients. Hotwire’s hidden Javascript code will take care of the replacement in the DOM.

That’s it, really. A few lines of code to update a page element. No need to write a dedicated Channel class or a Stimulus controller. Less code, and hopefully less bugs.