---
title: The Playwright debugging tool Rails devs aren't using
teaser: " Playwright records every test in cinematic detail. Most Rails devs aren't
  watching."
tags: rails,testing,capybara,rspec,debugging,ci
author: Justin Toniazzo
published_on: 2026-06-19
---

Playwright ships with a [black-box recorder](https://en.wikipedia.org/wiki/Flight_recorder) that records every detail of every test, giving you a treasure trove of information for debugging flaky tests. Most Rails apps I've worked on don't use it.

It's called the [Trace Viewer](https://playwright.dev/docs/trace-viewer), and if you're running Capybara with Playwright via [`playwright-ruby-client`](https://playwright-ruby-client.vercel.app/), turning it on takes a single block in `rails_helper.rb`.

## What's in a Playwright trace?

When Playwright records a trace, you get back a `.zip` you can replay in the Trace Viewer. The viewer is essentially a DVR for your test run. For every action Playwright took, every `click`, `fill_in`, and navigation, you get:

- A **before-and-after screenshot**, so you can see what the page looked like at each moment
- The **DOM at that point in time**, fully inspectable like in your devtools
- The **network requests** in flight, with status codes, body, and timing
- The **JavaScript console** output
- A **timeline** of everything that happened

![plawright trace viewer](https://images.thoughtbot.com/h90wgy5l1gaujkor9ntod93qbpwu_Screenshot%202026-05-15%20at%2011.18.43%E2%80%AFAM.png)

Compare that to what you usually get when a test fails on CI: A screenshot, and maybe a stack trace pointing at `expected to find "Saved"`. With a trace, you can scrub through the test like a video, and pinpoint exactly where things went wrong.

## Enable Playwright traces in your Rails app

Drop this into your `rails_helper.rb`. It assumes you're using [`playwright-ruby-client`](https://playwright-ruby-client.vercel.app/docs/article/guides/rails_integration).

You may need to change `type: :system` to `type: :feature` if that's how you set up your browser-based tests.

```ruby
RSpec.configure do |config|
  config.before(:each, type: :system) do |example|
    driver = Capybara.current_session.driver
    next unless driver.respond_to?(:start_tracing)

    driver.start_tracing(
      screenshots: true,
      snapshots: true,
      sources: true,
      title: example.full_description
    )
    example.metadata[:playwright_tracing] = true
  end

  config.after(:each, type: :system) do |example|
    next unless example.metadata[:playwright_tracing]

    path = trace_path(example)
    Capybara.current_session.driver.stop_tracing(path:)
    if example.exception
      output = RSpec.configuration.output_stream
      output.puts("Playwright trace: #{path}")
    end
  rescue Playwright::Error => e
    raise unless e.message.start_with?("Must start tracing before stopping")
  end

  def trace_path(example)
    if example.exception
      Rails.root.join(
        "tmp",
        "playwright-traces",
        [
          example.full_description.parameterize[0, 150],
          "-#{Time.current.to_i}",
          ".zip"
        ].join
      )
    else
      File::NULL
    end
  end
end
```

This starts a trace before every feature spec and only saves it if the test failed. When that happens, you'll see a line like this printed:

```
Playwright trace: /Users/jutonz/code/thoughtbot/testing/tmp/playwright-traces/it-works-1775576941.zip
```

A few things in the snippet might catch your eye (the `before(:each)` instead of `around`, the `rescue`, the conditional path). They're like that for a reason. Copy as-is, or expand below for the rationale.

<details>
<summary>Why the snippet looks the way it does</summary>

<ul>
<li><strong><code>before(:each)</code> over <code>around</code>.</strong> Capybara only swaps from RackTest to Playwright after it sees the spec's <code>:js</code> (or <code>js: true</code>) metadata. That swap happens after the <code>around</code> block, so a hook running there would still see the RackTest driver and <code>respond_to?(:start_tracing)</code> would always be <code>false</code>.</li>
<li><strong><code>rescue Playwright::Error</code>.</strong> If <code>start_tracing</code> failed for any reason, e.g. if a test modifies some low level driver config, <code>stop_tracing</code> will throw a "Must start tracing before stopping" error. Catching it won't fail an otherwise passing test because of a tracing issue.</li>
<li><strong>Conditional <code>trace_path</code> / <code>File::NULL</code>.</strong> Playwright requires you to write a trace somewhere once you've started one. Since we don't care about traces for passing tests, we discard them by writing them to the <code>/dev/null</code> <a href="https://en.wikipedia.org/wiki/Null_device">null device</a>.</li>
</ul>

</details>

## Download traces from CI for debugging

You'll want to keep failed traces around for inspection. In GitHub Actions, add an upload step that runs even when the suite fails:

```yaml
- name: Upload Playwright traces
  if: failure()
  uses: actions/upload-artifact@v4
  with:
    name: playwright-traces
    path: tmp/playwright-traces
```

Now you can download traces after a failed test.

## Open a trace

Once you've downloaded a trace zip, open it with:

```bash
npx playwright show-trace path/to/trace.zip
```

That spins up the Trace Viewer in a local browser. The [Playwright docs](https://playwright.dev/docs/trace-viewer) cover the UI in detail.

## Gotchas

A couple of things worth knowing once traces are on.

**Traces only show Playwright actions, not Capybara or RSpec.**

A `click_on("Save")` call will appear as `Click` in the trace, since that's the underlying Playwright action that's performed. Usually it's easy enough to map the Playwright action to the Capybara version, but it won't be exactly the same.

The trace recorder is also capable of capturing [Playwright-based assertions](https://playwright-ruby-client.vercel.app/docs/article/guides/rspec_integration), but if you're using Capybara, these won't show up. Capybara does assertions itself, rather than delegating to the driver. This is because Selenium and other drivers don't support built-in assertions.

**There's a small I/O cost.**

Recording is mostly disk-bound. In my experience the cost is negligible, but if your CI is I/O-constrained, you might notice it as new flakiness. If that happens, you can store traces in a tmpfs or ramdisk by passing `tracesDir` to the driver:

```ruby
Capybara::Playwright::Driver.new(
  app,
  # ... existing options ...
  tracesDir: ENV["CI"].present? ? "/mnt/ramdisk/playwright-traces" : nil,
)
```

CircleCI exposes [`/mnt/ramdisk`](https://circleci.com/docs/guides/execution-managed/using-docker/#ram-disks) by default. On other providers you can [create a tmpfs directory manually](https://docs.oracle.com/cd/E18752_01/html/817-5093/fscreate-99040.html).

## Debug, don't guess

Before traces, my approach to flaky CI was guess, re-run, and hope. Now I open the artifact, scrub through the test, and see the actual problem. The difference is night and day, and the setup is one block of code.

If you're running Playwright through Capybara, you've already got everything you need. Turn it on.
