DEV Community

Gary Passero
Gary Passero

Posted on

Fixing a stubborn class of flaky Turbo Stream system tests

If you've written system tests against a Rails app that uses Turbo Streams, you've probably hit this: the test clicks a button, a background job runs, and a progress bar (or some other element) should update — but the assertion intermittently fails because the DOM hasn't caught up yet.

The usual advice is to use Turbo::SystemTestHelper#connect_turbo_cable_stream_sources, which waits for the WebSocket subscription to be established before proceeding. That fixes the most common race condition: a broadcast firing before the browser has connected to the cable stream.

But there's a second, less-discussed race condition that connect_turbo_cable_stream_sources doesn't address.

The real problem

Consider a job that broadcasts twice in sequence — once at 0% and once at 100% when it finishes:

  # in your job
  def broadcast_update(message = nil)
    ProgressBar.new(percent_complete:, result_message:).update(stream_id)
  end
Enter fullscreen mode Exit fullscreen mode

And the test:

  click_on('Update')
  expect(page).to have_css(data_test('progress-bar'))
  perform_enqueued_jobs
  expect(page).to have_content('100%')  # flaky
Enter fullscreen mode Exit fullscreen mode

The WebSocket connection is fine. The job completes. But expect(page).to have_content('100%') still fails intermittently. Why?

Down the ActionCable rabbit hole

broadcast_replace_to eventually calls Turbo::StreamsChannel.broadcast_replace_to, which publishes a message through the ActionCable pubsub adapter. When a subscribed client receives that message, it goes through
ActionCable::Channel::Streams#worker_pool_stream_handler:

  def worker_pool_stream_handler(broadcasting, user_handler, coder: nil)
    handler = stream_handler(broadcasting, user_handler, coder: coder)

    -> message do
      connection.worker_pool.async_invoke handler, :call, message, connection: connection
    end
  end
Enter fullscreen mode Exit fullscreen mode

async_invoke posts the message handler onto a Concurrent::ThreadPoolExecutor. That thread pool is genuinely asynchronous — it doesn't block the calling thread, and it doesn't flush before perform_enqueued_jobs returns.

So the sequence looks like this:

  1. perform_enqueued_jobs is called
  2. Job runs, calls broadcast_replace_to (0%) → queued on thread pool
  3. Job runs, calls broadcast_replace_to (100%) → queued on thread pool
  4. perform_enqueued_jobs returns
  5. Thread pool processes the 100% update.
  6. Thread pool processes the 0% update.
  7. Capybara asserts 100% but the UI shows 0%.

The fix

A targeted patch in a spec support file makes ActionCable stream delivery synchronous in the test environment. It redefines worker_pool_stream_handler to call the synchronous invoke instead of async_invoke:

  ActionCable::Channel::Streams.module_eval do
    private

    def worker_pool_stream_handler(broadcasting, user_handler, coder: nil)
      handler = stream_handler(broadcasting, user_handler, coder: coder)

      lambda do |message|
        connection.worker_pool.invoke handler, :call, message, connection: connection
      end
    end
  end
Enter fullscreen mode Exit fullscreen mode

The drop-in file (place it in spec/support/) is here: [https://gist.github.com/gap777/6cc10654606e8ed27c7d1105b9d9eca5]

Because spec/rails_helper.rb auto-requires everything under spec/support/, no other changes are needed.

Why this works

With the patch in place, perform_enqueued_jobs now fully drains the call chain:

job runs →
broadcast_replace_to →
pubsub delivers →
worker_pool_stream_handler fires →
invoke runs the handler synchronously on the same thread.

By the time perform_enqueued_jobs returns, the DOM update has already been dispatched, and Capybara's assertion sees the final state every time.

Top comments (0)