Running the Storybook test-runner in GitHub Actions

This page is part of the test-runner automation section of the Storybook isolation workflows guide, which covers running stories unattended.

The specific problem: test-storybook works on your machine and does nothing useful in GitHub Actions. The job either reports zero tests, hangs until it is cancelled, or leaves the server process running after the tests finish.

Problem statement

The runner does not start Storybook — it connects to one. Locally that instance is already running in another terminal, which is why the local command looks so simple. In CI nothing is running, so the job has to build Storybook, serve it, wait for it to answer, run the tests, and then shut the server down.

Each of those five responsibilities has a characteristic failure. Skip the build and there is nothing to serve. Skip the wait and the runner connects to a closed port and reports zero tests found. Skip the shutdown and the job hangs after a green run, because a background server keeps the step alive.

Root cause

test-storybook --url performs an HTTP request for index.json and enumerates the stories it finds. A connection refused is not treated as a fatal error — the runner reports that it found no stories, which looks like a configuration problem rather than a timing one. That single behaviour accounts for most first attempts failing.

The second cause is process lifetime. A shell that starts a server with & has no way to know when the tests are done, so either the server outlives the step or the step ends before the tests do.

The job, step by step Five steps left to right: checkout; npm ci restoring a cache; build-storybook producing a static bundle; the highlighted serve step that starts http-server and waits for port 6006 to answer; and test-storybook generating one test per story. GitHub Actions job order checkout npm ci cached build-storybook static serve + wait-on 127.0.0.1:6006 test-storybook one test per story

Minimal reproduction

# nothing is serving Storybook, so the runner finds nothing
      - run: npm ci
      - run: npx test-storybook --url http://127.0.0.1:6006
# → "No stories found" and a green job that tested nothing

The job is worse than a failure: it passes, so it looks like coverage while asserting nothing at all.

Step-by-step fix

1. Build Storybook as its own step

      - name: Build Storybook
        run: npm run build-storybook -- --quiet

What this does: produces storybook-static, the frozen bundle the tests will run against. Building separately also means the log distinguishes a build failure from a test failure.

2. Serve and wait in one command

{
  "scripts": {
    "test-storybook:ci": "concurrently -k -s first -n SB,TEST \"http-server storybook-static -p 6006 --silent\" \"wait-on tcp:6006 && test-storybook --url http://127.0.0.1:6006 --maxWorkers=2\""
  }
}

What this does: wait-on blocks until the port answers, removing the race. -k kills the server when the tests finish and -s first makes the exit code the tests’ own, so a failing suite fails the job.

3. Run inside a browser-capable image

    runs-on: ubuntu-latest
    container:
      image: mcr.microsoft.com/playwright:v1.47.0-jammy    # pin the version
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run build-storybook -- --quiet
      - run: npm run test-storybook:ci

What this does: gives the job browsers and the font stack without a download step, and pins them so a runner-image update cannot change rendering underneath you.

4. Publish the report whether the run passed or failed

      - name: Upload test report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: storybook-test-report
          path: reports/
          retention-days: 7

What this does: always() is the important part — without it the artifact is skipped on exactly the runs where someone needs it.

5. Make the job a required check

Add the job’s name to the protected branch’s required status checks. A story suite that reports but does not block is a report, not a gate, and will be ignored within a sprint.

Verification

A gated story run in CI A CI log for run 1043. The static Storybook is served on port 6006 and wait-on reports it ready in 0.6 seconds. Button and form stories pass. The IconButton story fails on the button-name accessibility rule. Forty-seven of forty-eight tests pass in one minute twelve seconds, so the job is red. Storybook tests — run #1,043 Serving storybook-static on :6006 wait-on tcp:6006 ... ready in 0.6 s PASS Button/Primary PASS Form/SubmitsAndShowsSaved FAIL IconButton/Default (button-name) Tests: 47 passed, 1 failed, 48 total 1m 12s

The two things to confirm on the first green run are that the reported test count matches your story count, and that the job finishes rather than hanging. A count of zero means the wait step is missing; a hang means the server is not being killed.

Edge cases and caveats

Six settings a CI story run needs Six rows. A pinned Playwright container image supplies browsers and fonts. maxWorkers of two matches a two-core runner. wait-on for TCP port 6006 removes the start-up race. Running the server and test under concurrently with kill-on-first-exit leaves no orphaned process. Uploading artifacts with an always condition keeps the report from a red run. Caching node_modules and the static build means a retry costs only the test time. Runner setting Value Why on a CI runner container image playwright:v1.47.0-jammy browsers and fonts baked in --maxWorkers 2 matches a 2-core runner wait-on tcp:6006 removes the start-up race concurrently -s first kill on first exit no orphaned server upload-artifact if always() report survives a red run cache node_modules + storybook-static retry costs test time only
  • Storybook on a non-loopback host. Inside a container, localhost may not resolve to the served interface. Use 127.0.0.1 explicitly in both the serve and the --url argument.

  • Memory on small runners. Each worker is a browser context. The default worker count is the CPU count, which on a two-core runner with four gigabytes will thrash. Set --maxWorkers=2 and raise it only with evidence.

  • Monorepos with several Storybooks. Each needs its own port and its own job. Sharing a port across parallel jobs on the same runner produces cross-talk that looks like flakiness.

FAQ

Why does the job report “No stories found”?

Because the runner reached the URL and did not get a story index back — nearly always because nothing is listening yet. Add wait-on tcp:6006 before the test command. If the wait succeeds and the count is still zero, fetch http://127.0.0.1:6006/index.json in a debug step: an HTML response means the server is serving the wrong directory.

Should the build and the tests be one job or two?

Two, once the suite is sharded. A build job that publishes storybook-static as an artifact lets every shard download the same bundle instead of rebuilding it, which removes both the duplicated build time and any chance the shards test different bundles. For an unsharded suite one job is simpler and costs one build either way.

Do I need Playwright browsers installed separately?

Not when the job runs in the Playwright container image, which ships them. On a bare ubuntu-latest runner you need npx playwright install --with-deps chromium, and it is worth caching — see caching Playwright browsers in GitHub Actions.

How do I keep the job under the time a pull request can tolerate?

Cache dependencies, reuse the build across shards, and shard once the suite passes roughly five minutes. Story runs shard cleanly because the unit of work is a story rather than a file, so four shards genuinely quarter the wall-clock rather than leaving one straggler.

Can I run the story suite on a schedule instead of on every pull request?

You can, and it is a reasonable interim step while the suite is still slow or noisy — but a nightly run finds a regression a day after it merged, when the author has moved on. Treat it as a staging post: shard the run, fix the flaky stories it surfaces, and promote it to a required pull-request check as soon as it is fast and stable enough to hold that position.