Skip to content
Development
Skill

/e2e-write-visual-test

Use when writing a Playwright visual regression (screenshot comparison) test, tagging a test `@visual`, generating or updating baseline screenshots, running visual tests locally, or debugging a failing screenshot comparison in CI.

From plugin
payload
45k8 skills3 commands2 MCP
Install
$ npx -y skills add payloadcms/payload --skill e2e-write-visual-test --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/e2e-write-visual-test

Context preview

The summary Claude sees to decide when to auto-load this skill.

Use when writing a Playwright visual regression (screenshot comparison) test, tagging a test `@visual`, generating or updating baseline screenshots, running visual tests locally, or debugging a failing screenshot comparison in CI.

SKILL.md

e2e-write-visual-test.SKILL.md
name: e2e-write-visual-test
description: Use when writing a Playwright visual regression (screenshot comparison) test, tagging a test `@visual`, generating or updating baseline screenshots, running visual tests locally, or debugging a failing screenshot comparison in CI.

Writing and Running Visual Regression Tests

Overview

Visual regression tests are normal Playwright e2e tests tagged `@visual` that compare a screenshot against a committed baseline PNG instead of (or in addition to) asserting on the DOM. They live alongside normal e2e tests — there is no separate test type or directory to register a test in.

Key pieces:

  • `test/__helpers/e2e/visual.ts` — the `visual()` helper. Declares a test tagged `@visual` without the tag needing to be typed (and possibly forgotten) at each call site. Prefer this over `test()` with a manual tag for any normal visual regression test.
  • `test/__helpers/e2e/expectScreenshot.ts` — the helper that takes the screenshot and diffs it against the baseline.
  • `test/playwright.config.ts` — `toHaveScreenshot.maxDiffPixelRatio` (anti-aliasing tolerance) and `snapshotPathTemplate` (where baselines are stored).
  • `.github/scripts/visual/find-visual-suites.mjs` — discovers every suite that has an `@visual`-tagged test by scanning `test/**/e2e.spec.ts` for either the string `@visual` or a `visual()` helper import. Nothing needs to be registered anywhere else — add a `visual()` test and it's picked up automatically next time visual tests run.
  • `.github/scripts/visual/run-visual-suites.sh` — loops `pnpm test:e2e:prod:server:run:noturbo <suite> --grep @visual` over either an explicit suite or every discovered suite. Shared by CI and the local Docker script.

Writing a new visual test

Use the `visual()` helper instead of `test()` and call `expectScreenshot` instead of (or alongside) normal assertions:

import { expectScreenshot } from '../__helpers/e2e/expectScreenshot.js'
import { visual } from '../__helpers/e2e/visual.js'

visual('renders the posts list view', async () => {
  await page.goto(url.list)

  // Assert the page actually loaded before screenshotting — a screenshot of an error
  // page or a spinner will "pass" the pixel diff and hide a real bug.
  const textCell = page.locator('.row-1 .cell-title')
  await expect(textCell).toBeVisible()

  await expectScreenshot({ name: 'posts-list-view.png', page })
})

`visual()` applies the `@visual` tag for you, so there's nothing to remember. A test written with plain `test()` that forgets the tag is intentionally excluded from the visual-regression flow rather than caught after the fact — use `visual()` from the start instead of tagging manually.

  • `name` — the baseline filename. Baselines are stored at `test/<suite>/__snapshots__/e2e.spec.ts/<name>`.
  • `target` (optional) — a `Locator` to screenshot instead of the full page. Prefer this for testing one component in isolation; it's less prone to unrelated diffs elsewhere on the page.
  • `mask` (optional) — an array of `Locator`s to blank out before comparing (timestamps, avatars, anything non-deterministic that isn't the thing under test).

Put it in whichever suite's existing `e2e.spec.ts` the feature belongs to — same convention as any other e2e test. Nothing else needs to be wired up.

Manually tagging instead

`visual()` only covers the plain `test()` case. For a variant it doesn't wrap — `test.skip`, `test.only`, `test.fixme`, a `test.describe` block, or a deliberately different tag (e.g. `@visual-canary`, see `test/admin/e2e/visual/e2e.spec.ts`) — tag it directly instead:

test('renders the posts list view', { tag: '@visual' }, async () => {
  await page.goto(url.list)
  await expectScreenshot({ name: 'posts-list-view.png', page })
})

This is picked up by `find-visual-suites.mjs` the same way, since it also matches a literal `@visual` string in the file.

Generating / updating the baseline

**Baselines must be generated inside the pinned Playwright Docker image — never on a bare host.** Font hinting/anti-aliasing differs enough between operating systems that a baseline captured on macOS or Windows will fail the comparison on CI even when nothing visually changed.

pnpm docker:start                              # MongoDB, if not already running
pnpm test:visual:update

This runs every suite with an `@visual` test inside `mcr.microsoft.com/playwright:vX-noble` (the same image CI uses) and writes/overwrites the baseline PNGs. Commit the resulting PNGs.

To scope it to one suite:

pnpm test:visual <suite> -- --update-snapshots

**Never produce the baseline PNG any other way** — not a manual screenshot, not a screenshot/browser tool, not an agent's own screenshot capability, not copying an image from somewhere else — even if it looks pixel-identical when you look at it. The comparison in CI runs against the exact renderer/font stack inside that Docker image; a PNG from anywhere else can look correct to a human and still fail every single CI run, deterministically, because the mismatch is systemic (font hinting), not random. If you did not run one of the two commands above to produce the file, don't commit it.

Running visual tests locally (without updating baselines)

pnpm docker:start
pnpm test:visual                 # every suite with an @visual test
pnpm test:visual <suite>         # just one suite

Extra Playwright flags can be forwarded after `--`, e.g. `pnpm test:visual _community -- --headed`.

**`@visual` tests are not picked up by plain `pnpm test:e2e` / `pnpm test`.** This is intentional, not a gap to work around:

  • `runE2E.ts` excludes `@visual` by default (`--grep-invert=@visual`) unless you explicitly pass `--grep @visual`.
  • `expectScreenshot` refuses to run unless `PAYLOAD_TEST_PROD === 'true'` (set by `--prod-server`), because a dev-server render isn't representative of what CI compares against — dev mode injects extra overlays/markup and skips minification, which
Read more
Ships withpayload

Payload is the open-source, fullstack Next.js framework, giving you instant backend superpowers. Get a full TypeScript backend and admin panel instantly. Use Payload as a headless CMS or for building powerful applications.

Get the whole plugin

Other skills on payload.