This recipe is the smallest end-to-end Playwright test that pins the QA Shop PDP layout against accidental visual drift.
It uses Playwright's built-in toHaveScreenshot assertion with a deterministic mask for the dynamic cart-count badge, and commits a canonical Linux baseline to the repo. Run it on your laptop in about eight seconds.
It is the canonical first visual-regression test to write against any e-commerce site, and it is the foundation that broader visual-coverage suites (gallery scrolls, hover states, dark-mode variants) build on top of.
§ 01 · PREREQUISITES
Prerequisites
You need a project with @playwright/test installed, a Playwright config that points at QA Shop, and a Linux environment to generate the canonical baseline.
Bootstrap with npm init playwright@latest if you don't already have a project — it scaffolds the runner, the config, and an example spec.
Set to http://localhost:3002 for a local checkout, or https://automationtestingplatform.com for the hosted demo.
Snapshot baselines are platform-specific. The committed baseline in this repo is Linux because CI runs Linux. Generate yours on the same OS family — either in CI itself or in a mcr.microsoft.com/playwright:v1.58.0-jammy container locally.
The recipe assumes the server is already up. The test does not start it for you.
§ 02 · THE SPEC
The full spec
Save this file as tests/recipes/playwright/visual-regression-screenshot.spec.ts and run it with npx playwright test --grep visual-regression-screenshot.
// tests/recipes/playwright/visual-regression-screenshot.spec.ts
import { test, expect } from "@playwright/test";
const PRODUCT_SLUG = "wooden-train-set-100-piece";
test("PDP layout is pixel-stable except for the dynamic cart-count badge", async ({
page,
}) => {
// 1. Land on the PDP and wait for the wrapper — the readiness contract.
await page.goto(`/products/${PRODUCT_SLUG}`);
await expect(page.getByTestId("page-pdp")).toBeVisible();
// 2. Compare the full page against the committed baseline. The cart-count
// badge is masked because its value depends on cart state across runs;
// masking blacks out the locator's bounding box during comparison.
// maxDiffPixels=200 absorbs anti-aliasing jitter from font and SVG
// rendering without permitting visible drift.
await expect(page).toHaveScreenshot("pdp.png", {
mask: [page.getByTestId("cart-count")],
maxDiffPixels: 200,
});
});The first run on a fresh checkout will fail with A snapshot doesn't exist at …pdp-recipes-linux.png, writing actual. That is the expected first-run behavior — Playwright writes the actual render as the new baseline, and the next run will compare against it. The first-run write is exactly what --update-snapshots triggers explicitly; without --update-snapshots, Playwright still writes the missing baseline but reports the run as failed so that nobody accidentally promotes a broken render to baseline in CI.
§ 03 · WALKTHROUGH
Walkthrough
The first navigation to /products/wooden-train-set-100-piece is plain page.goto — no readiness flag yet.
The first assertion is on data-testid="page-pdp", the wrapper at the top of the product detail page. Asserting on the wrapper rather than the title or the gallery image is deliberate: when the wrapper is visible, the buy box has hydrated, the gallery has laid out, and the page is stable enough to screenshot. Asserting on a half-hydrated DOM produces a baseline that captures the loading skeleton — every subsequent run that loads slightly faster then fails the comparison.
The toHaveScreenshot call is the load-bearing line1. The first argument is the snapshot identifier — Playwright resolves it to tests/recipes/playwright/visual-regression-screenshot.spec.ts-snapshots/pdp-<projectName>-<platform>.png via the default snapshotPathTemplate. On the recipes project running in Linux CI, that becomes pdp-recipes-linux.png, and that is the file committed to the repo.
The mask option accepts an array of locators; each matched element is overlaid with a solid color (default #FF00FF magenta on screenshot, black on comparison) before the comparison runs. This recipe masks only data-testid="cart-count" because it is the one element on the PDP whose rendered value depends on persistent state. Other dynamic surfaces — the cart drawer, time-of-day greetings, A/B-test slots — would each get their own entry in the array. Masking a region preserves the layout assertion (the box still has to exist in that position) while removing the inner-pixel assertion.
The maxDiffPixels: 200 budget is the deliberate-jitter allowance. Sub-pixel rasterization differs run-to-run even on identical environments because of font hinting, GPU rounding, and SVG curve tessellation. Two hundred pixels on a 1280x720 viewport is well below human-perceptible drift but still tight enough to catch real layout regressions, which typically move thousands of pixels. The alternative knob is maxDiffPixelRatio (a percentage), which scales better across viewport sizes; for a single fixed-viewport recipe like this one, the absolute pixel count is more predictable.
When a comparison fails, Playwright writes three files into the test-results/ directory: pdp-actual.png (what the live render produced), pdp-expected.png (the committed baseline), and pdp-diff.png (a per-pixel diff with changed regions highlighted). All three are attached to the HTML report by the playwright-report reporter — open the report and scroll to the failed test to see them inline. The diff PNG is the most useful artifact for triage; a glance tells you whether the regression is a real layout change or anti-aliasing noise from a font upgrade.
§ 04 · EXTEND IT
Things to extend
Three obvious variants build on this skeleton.
Replace expect(page).toHaveScreenshot(...) with expect(page.getByTestId('pdp-buy-box')).toHaveScreenshot('buy-box.png', ...). The element-scoped variant ignores everything outside the buy box, which keeps the test stable when unrelated parts of the page (footer copy, related-products rail) change. Pair element-scoped baselines with one full-page baseline for the best signal-to-noise ratio.
Add a test.describe.parallel around the body and parameterize viewport: { width, height } over { 393, 852 } (mobile), { 768, 1024 } (tablet), and { 1280, 720 } (desktop). Each viewport gets its own baseline file (pdp-recipes-linux.png becomes pdp-mobile-recipes-linux.png and so on via the snapshot identifier), and a single --update-snapshots run regenerates all three.
Pass animations: 'disabled' to toHaveScreenshot to pause CSS animations and transitions before capture. The default is 'allow', which can produce a baseline mid-animation if the page has a hover-revealed component or a loading shimmer. 'disabled' is the right default for any visual regression on a page that uses Framer Motion or CSS keyframes.
A fourth variant worth scripting eventually is the cross-environment baseline — generate one baseline per RECIPE_BASE_URL (local dev, Vercel preview, hosted demo) and commit them all. The current recipe relies on the hosted demo and CI rendering identically; once the suite outgrows that assumption, multi-environment baselines are the next promotion.
§ 05 · MAINTAINING THE BASELINE
Maintaining the baseline
The committed baseline is canonical Linux. CI generates it, CI compares against it, and any developer change that legitimately moves the layout has to regenerate it.
The full update cycle:
- Make the markup or styling change that intentionally moves the layout.
- Run the spec locally to confirm it fails (this proves the baseline is actually catching the change you made).
- Open a Linux container:
docker run --rm -v $(pwd):/work -w /work mcr.microsoft.com/playwright:v1.58.0-jammy bash -c "npm ci && npx playwright test --update-snapshots tests/recipes/playwright/visual-regression-screenshot.spec.ts". - The regenerated
tests/recipes/playwright/visual-regression-screenshot.spec.ts-snapshots/pdp-recipes-linux.pngis now in your working tree. git addthe new baseline and commit it in the same PR as the design change.
Reviewers see the new baseline next to the design change. A baseline change without a paired markup change is a red flag — it usually means the previous baseline was wrong or generated on the wrong environment.
If you skip the Linux container and regenerate the baseline on macOS, the resulting pdp-recipes-darwin.png does not satisfy the CI comparison, which is keyed on -linux.png. Do not commit a -darwin.png; if one slips into a PR, the CI run will simply fail with the original "snapshot not found" error against the -linux.png filename it expects.
Related
For the full testid catalog and the layout primitives the PDP is built from, see the QA Shop testing guide. For the broader cart-and-checkout flow the PDP wrapper anchors, see Add to cart and reach checkout.
The Selenium, Cypress, and WebdriverIO visual-regression counterparts are coming soon — placeholder slugs are /recipes/selenium/visual-regression-screenshot, /recipes/cypress/visual-regression-screenshot, and /recipes/webdriverio/visual-regression-screenshot. The /recipes index lists them as they ship.
Footnotes
-
Playwright Visual comparisons — https://playwright.dev/docs/test-snapshots (verified 2026-05-03, Playwright 1.58) ↩
Frequently asked questions
Last verified: