Stabilize integrations tests with flakyness introduced by Turbo / Stimulus / Hotwire

Posted . Visible to the public.

If you run a Rails app that is using Turbo Show archive.org snapshot , you might observe that your integration tests are unstable depending on the load of your machine. We have a card "Fixing flaky E2E tests" that explains various reasons for that in detail.

Turbo currently ships with three modules:

  • Turbo Drive accelerates links and form submissions by negating the need for full page reloads.
  • Turbo Frames decompose pages into independent contexts, which scope navigation and can be lazily loaded.
  • Turbo Streams deliver page changes over WebSocket, SSE or in response to form submissions using just HTML and a set of CRUD-like actions.

..which are all examples of CapybaraLockstep's limitations Show archive.org snapshot .

To stabilize asynchronous work of Turbo, we have to find good events Show archive.org snapshot to wrap calls to startWork and stopWork around them. The framework currently does not define symmetric events for each type, so my controller needed one hack or two.

Stimulus controller

import { Controller, Application } from "@hotwired/stimulus"

// See https://turbo.hotwired.dev/reference/events

class CapybaraLockstepController extends Controller {
  connect() {
    this.lockCounter = 0

    this.boundLockTurboStreamRendering = this.lockTurboStreamRendering.bind(this)
    this.boundStartLock = this.startLock.bind(this)
    this.boundStopLock = this.stopLock.bind(this)

    // Stream rendering
    document.addEventListener("turbo:before-stream-render", this.boundLockTurboStreamRendering)

    // Form submission
    document.addEventListener("turbo:submit-start", this.boundStartLock)
    document.addEventListener("turbo:submit-end", this.boundStopLock)

    // Network activity
    document.addEventListener("turbo:before-fetch-request", this.boundStartLock)
    document.addEventListener("turbo:before-fetch-response", this.boundStopLock)
    document.addEventListener("turbo:fetch-request-error", this.boundStopLock)

    // Frame rendering
    document.addEventListener("turbo:before-frame-render", this.boundStartLock)
    document.addEventListener("turbo:frame-render", this.boundStopLock)
  }

  disconnect() {
    // Stream rendering
    document.removeEventListener("turbo:before-stream-render", this.boundLockTurboStreamRendering)

    // Form submission
    document.removeEventListener("turbo:submit-start", this.boundStartLock)
    document.removeEventListener("turbo:submit-end", this.boundStopLock)

    // Network activity
    document.removeEventListener("turbo:before-fetch-request", this.boundStartLock)
    document.removeEventListener("turbo:before-fetch-response", this.boundStopLock)
    document.removeEventListener("turbo:fetch-request-error", this.boundStopLock)

    // Frame rendering
    document.removeEventListener("turbo:before-frame-render", this.boundStartLock)
    document.removeEventListener("turbo:frame-render", this.boundStopLock)
  }

  lockTurboStreamRendering(event) {
    const originalRender = event.detail.render
    this.startLock(event)

    event.detail.render = (streamElement) => {
      originalRender(streamElement)
      this.stopLock({type: "turbo:after-stream-render"})
    }
  }

  startLock(event) {
    this.lockCounter++;
    window.CapybaraLockstep?.startWork(event.type);
  }

  stopLock(event) {
    if (this.lockCounter > 0) {
      // Turbo does not currently offer perfectly symmetrical event pairs.
      // We must ensure that stopWork is not called more often than startWork
      this.lockCounter--
      window.CapybaraLockstep?.stopWork(event.type)
    }
  }
}

const application = Application.start()
application.register("capybara-lockstep", CapybaraLockstepController)

DOM

Activate the controller on the body tag of every layout.

<body data-controller="capybara-lockstep">
Michael Leimstädtner
Last edit
Paul Demel
License
Source code in this card is licensed under the MIT License.
Posted by Michael Leimstädtner to makandra dev (2025-09-12 11:32)