Running Storybook visual tests in GitLab CI
This page is part of the GitLab CI Pipelines section, which covers how to gate merge requests on component and visual tests inside GitLab’s pipeline model. Here we solve one specific, common failure: getting @storybook/test-runner to execute reliably against a static Storybook build inside a GitLab job, instead of racing a dev server or hanging on an unbound port.
Problem statement
You have a Storybook with play functions and visual assertions that pass on your laptop. You add a GitLab CI job that runs test-storybook, and it fails in one of two ways. Either the test-runner tries to launch its own dev server and the job times out during compilation, or the runner starts before Storybook is serving and every test fails with net::ERR_CONNECTION_REFUSED against http://127.0.0.1:6006. Locally the dev server is already warm, so the race never surfaces; in a cold CI container it fails on almost every run.
The goal is a deterministic sequence: build Storybook once, serve the static output, wait until the HTTP endpoint actually responds, then run the test-runner against that URL — with the JUnit report and any diff images captured as artifacts you can open from the merge request.
Root cause
@storybook/test-runner needs a running Storybook to connect to, but by default test-storybook assumes one is already served and simply connects to http://127.0.0.1:6006. In CI nothing is listening on that port yet, so the Playwright-driven runner connects to a dead socket. The fix is to decouple the build from the serve step — compile a static bundle with storybook build, serve storybook-static with http-server, and use wait-on as an explicit readiness gate — the same required-status-check discipline you apply to any pipeline that blocks a merge.
Minimal reproduction
The smallest failing pipeline is a single job that calls test-storybook with nothing serving Storybook:
# .gitlab-ci.yml — fails: nothing is listening on :6006
storybook-test:
image: node:20
script:
- npm ci
- npx test-storybook # connects to http://127.0.0.1:6006 → refused
The job output ends with a connection error before any story runs:
Error: page.goto: net::ERR_CONNECTION_REFUSED at http://127.0.0.1:6006/
There is no browser in the node:20 image either, so even if a server were running, Playwright’s Chromium would be missing. Both problems have to be fixed together.
Step-by-step fix
1. Add the build, serve, and wait scripts to package.json
Install the three tools the pipeline needs and wire them into npm scripts so the CI file stays readable.
npm install --save-dev @storybook/test-runner http-server wait-on concurrently
// package.json (scripts excerpt)
{
"scripts": {
"build-storybook": "storybook build --output-dir storybook-static",
"serve-storybook": "http-server storybook-static --port 6006 --silent",
"test-storybook:ci": "test-storybook --url http://127.0.0.1:6006 --junit"
}
}
What this does: build-storybook emits a static bundle, serve-storybook serves that bundle as plain files, and test-storybook:ci points the runner at the served URL (--url stops it from trying to start its own server) and writes a JUnit report.
2. Build Storybook in a dedicated stage and cache the output
Compile the static bundle once in a build stage and pass storybook-static forward as an artifact. Later jobs consume it instead of recompiling.
# .gitlab-ci.yml
stages:
- build
- test
# Reuse the npm cache across pipelines keyed on the lockfile
.node-cache: &node-cache
key:
files:
- package-lock.json
paths:
- .npm/
build-storybook:
stage: build
image: node:20
cache: *node-cache
script:
- npm ci --cache .npm --prefer-offline
- npm run build-storybook
artifacts:
paths:
- storybook-static/
expire_in: 1 hour
What this does: the build stage produces storybook-static/ and hands it to the test stage via artifacts:paths. Keying the cache on package-lock.json means dependencies are only re-downloaded when the lockfile changes.
3. Serve the static build, wait for it, then run the runner
Use a Playwright image so Chromium is already present, pull in the built artifact, start http-server in the background, block on wait-on, and only then run the tests.
storybook-visual-tests:
stage: test
# Ships Chromium + system libs at the version the runner expects
image: mcr.microsoft.com/playwright:v1.48.0-jammy
needs:
- build-storybook # pulls storybook-static/ artifact, skips rebuild
cache: *node-cache
script:
- npm ci --cache .npm --prefer-offline
- npm run serve-storybook & # background server, note the trailing &
- npx wait-on tcp:127.0.0.1:6006 --timeout 60000
- npm run test-storybook:ci
artifacts:
when: always # keep reports even when the job fails
paths:
- test-results/
reports:
junit: junit.xml
expire_in: 1 week
What this does: needs: [build-storybook] fetches the cached static bundle so this job never recompiles Storybook. The trailing & backgrounds http-server; wait-on tcp:127.0.0.1:6006 blocks until the port accepts connections (failing after 60 s rather than hanging forever); then the runner executes every story. when: always keeps the JUnit report and diff images even on failure so you can open them from the merge request.
4. Configure the test-runner to capture visual snapshots
Add a .storybook/test-runner.ts hook so each story is snapshotted and diffs land in a predictable directory that the artifact glob picks up.
// .storybook/test-runner.ts
import type { TestRunnerConfig } from '@storybook/test-runner';
const config: TestRunnerConfig = {
async postVisit(page, context) {
// Wait for fonts and async images so the snapshot is stable
await page.evaluate(() => document.fonts.ready);
const image = await page.screenshot();
expect(image).toMatchImageSnapshot({
customSnapshotsDir: 'test-results/snapshots',
customDiffDir: 'test-results/diffs',
failureThreshold: 0.01,
failureThresholdType: 'percent',
});
},
};
export default config;
What this does: postVisit runs after each story mounts, waits for web fonts to settle (a common source of one-off diffs), and compares a screenshot against the committed baseline. Diffs write to test-results/diffs, which the job’s artifacts:paths uploads. The 1 % failureThreshold mirrors the tolerance thresholds you would tune for any pixel comparison.
Verification
Trigger the pipeline and confirm the test stage output shows the readiness gate clearing before the runner starts:
$ npm run serve-storybook &
$ npx wait-on tcp:127.0.0.1:6006 --timeout 60000
$ npm run test-storybook:ci
PASS browser: chromium src/components/Button.stories.tsx
Button
✓ Primary (412 ms)
✓ Disabled (298 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
In the merge request, open the job and confirm the Tests tab is populated from junit.xml and that test-results/ appears under Browse artifacts. If a story regresses, the diff image is downloadable from that same artifact tree, so review happens without re-running the pipeline locally.
Edge cases and caveats
- Pin the Playwright image to the runner’s Playwright version.
@storybook/test-runnerdepends on a specific Playwright release; if themcr.microsoft.com/playwrighttag is newer or older than that dependency, Chromium may fail to launch with a protocol-mismatch error. Match the image tag (for examplev1.48.0-jammy) to the Playwright version in your lockfile. - The background server must be reaped or the job can hang on shutdown. With
http-server &the process keeps running after tests pass; GitLab usually kills the container, but on shared runners a lingering process can delay cleanup. Usingconcurrently -k -s first "npm run serve-storybook" "npm run test-storybook:ci"starts both and kills the server the instant the tests finish. - First-run baselines will fail until committed.
toMatchImageSnapshotwrites a new baseline on first encounter and reports it as a failure in CI (where writing is treated as a miss). Generate baselines locally, commit them, and only then enable the gate — otherwise every new story reds the pipeline on introduction.
FAQ
Why build a static Storybook instead of running storybook dev in CI?
storybook dev starts a Webpack or Vite dev server that recompiles on demand, adding cold-start time and non-deterministic timing to every test run. A static build compiled once by storybook build is served as plain files by http-server, so the test-runner hits a stable endpoint with no compilation happening during the tests. You can also cache the storybook-static directory as an artifact and reuse it across parallel jobs.
How do I stop tests from starting before Storybook is ready in GitLab CI?
Start http-server in the background with a trailing ampersand, then run wait-on tcp:127.0.0.1:6006 (or http-get://…) before the test command. wait-on polls the endpoint and exits zero only once it responds, so the test-runner never races an unbound port. The concurrently package with a --success flag is an alternative that also tears the server down when tests finish.
Do I need a browser installed in the GitLab CI image to run the test-runner?
Yes. @storybook/test-runner drives stories through Playwright’s Chromium, so the runner image must contain the browser and its system libraries. Use the official mcr.microsoft.com/playwright image, which ships the browsers and dependencies pre-installed, or run npx playwright install --with-deps chromium in a before_script on a plain node image.
Related
- GitLab CI Pipelines — the parent section covering how to gate merge requests on component and visual tests in GitLab.
- GitLab CI Parallel Matrix for Cross-Browser Tests — fan this same job out across Chromium, Firefox, and WebKit with
parallel:matrix. - Writing Interaction Tests with the Storybook Play Function — the
playfunctions the test-runner executes inside each job. - Configuring Chromatic Threshold Settings for Pixel-Perfect Diffs — how to choose the
failureThresholdvalue used in the snapshot step. - Making Visual Review a Required Status Check — turn this job into a merge-blocking gate.