Functional Cypress tests verify behaviour: the button works, the form submits, the URL changes. They don't verify appearance. A button that's moved twenty pixels left, a font that's regressed from 16px to 14px, a heading that's lost its margin — every one of those bugs ships through a green Cypress suite. Visual regression testing fills the gap. This lesson covers the concept, the three tools you'll see in real codebases, and the workflow for keeping baseline screenshots honest as the design evolves.
What visual regression testing is
The core idea is mechanical:
- Baseline. First run takes a screenshot of every page (or component) under test and stores it as the "approved" version.
- Comparison. Every subsequent run takes a fresh screenshot of the same page and diffs it against the baseline.
- Verdict. If the pixels differ beyond a tolerance, the build fails and a human reviews the diff. The reviewer either approves the new image (replacing the baseline) or files a bug.
Functional tests catch what the page does. Visual tests catch what the page looks like. Both are necessary — you wouldn't ship a product with only one of them.
Three tools, one concept
The mechanism is the same; the implementation differs by vendor:
Three approaches to visual regression in Cypress
Percy (BrowserStack)
Cloud-hosted; uploads screenshots to Percy's diffing engine
Per-page snapshots: cy.percySnapshot('Homepage')
Reviews via the Percy web dashboard with side-by-side diffs
Best for teams who want zero infrastructure
Applitools Eyes
AI-perceptual diffing — ignores anti-aliasing, font-rendering noise
Cross-browser, cross-viewport baselines maintained automatically
More expensive; sharpest false-positive filtering
Best when subpixel noise was killing other tools
cypress-image-snapshot (OSS)
Free, runs locally; baselines stored in the repo
cy.matchImageSnapshot('product-grid')
Pixel diff via pixelmatch; tolerance configurable per test
Best for small-to-medium teams without a SaaS budget
The cloud tools (Percy, Applitools) shine on parallelism and review workflow. The OSS tool wins on cost and on-prem requirements. All three integrate with Cypress as a one-line command per snapshot.
Percy — the simplest cloud setup
// cypress/support/e2e.ts
import "@percy/cypress";it("renders the homepage", () => {
cy.visit("/");
cy.percySnapshot("Homepage");
});
it("renders the product page", () => {
cy.visit("/products/headphones-1");
cy.percySnapshot("Product page — headphones");
});The first run uploads the screenshots and accepts them as baseline. Every subsequent run uploads new screenshots, compares, and reports diffs in the Percy dashboard. Reviewers click Approve or Reject; approving updates the baseline; rejecting fails the build.
Percy is the lowest-friction starting point — you set up the dashboard, paste a token into a CI secret, and visual coverage exists.
cypress-image-snapshot — the OSS path
For teams without a SaaS budget or with on-prem requirements:
npm install cypress-image-snapshot --save-dev// cypress/support/e2e.ts
import { addMatchImageSnapshotCommand } from "cypress-image-snapshot/command";
addMatchImageSnapshotCommand({
failureThreshold: 0.02, // 2% pixel diff allowed
failureThresholdType: "percent",
});it("matches the product grid baseline", () => {
cy.visit("/products");
cy.get("[data-testid='product-grid']").matchImageSnapshot("product-grid");
});Baselines are stored in cypress/snapshots/. Commit them to git so CI has the same baseline every machine compares against. When the design legitimately changes, run with --env updateSnapshots=true to regenerate the baselines.
The trade-off: pixel diffing is sensitive. Anti-aliasing differences between dev (macOS) and CI (Linux Docker) routinely produce false positives. Run visual tests in a single, pinned Docker image — and configure failureThreshold to swallow tiny rendering differences.
What to test visually
Don't try to visual-test everything. The right targets:
- Marketing-critical pages — homepage, pricing, signup landing.
- Layout-heavy components — navigation bar, footer, product card, cart sidebar.
- Responsive breakpoints — capture each page at desktop, tablet, mobile widths.
- Theme variations — light and dark mode of the same component.
Skip:
- Pages full of dynamic data — timestamps, randomised content, live counters. Either mask them (covered next) or skip the visual test.
- Internal admin pages that change every sprint — the noise-to-signal is too high.
- Long forms with lots of state — if the visual test fails because the user typed in step 3, you're catching the wrong bug.
The discipline that pays off: 10 well-chosen visual tests beat 100 noisy ones that everyone ignores.
Handling dynamic content
Every visual tool has a way to ignore regions of the page that change every run:
// Percy — CSS injected only during the snapshot
cy.percySnapshot("Dashboard", {
percyCSS: `
[data-testid="last-updated"] { visibility: hidden; }
[data-testid="random-banner"] { display: none; }
`,
});
// cypress-image-snapshot — element-bounded snapshot skips dynamic regions entirely
cy.get("[data-testid='product-grid']").matchImageSnapshot("product-grid");
// Applitools — region exclusion via the Eyes API
cy.eyesCheckWindow({
ignore: [{ selector: "[data-testid='timestamp']" }],
});Hide, exclude, or scope the snapshot to a stable subtree. The pattern that fails is "snapshot the whole page and accept random failures" — your team starts ignoring red builds and the value of the visual suite goes to zero.
The visual-test workflow
A real team's day-to-day with visual tests:
- Author — write a Cypress test that sets up a known state (probably with stubs and fixtures so the data is deterministic), then calls
cy.percySnapshot(...)orcy.matchImageSnapshot(...). - First run on the feature branch — produces a baseline. The CI job uploads it (Percy/Applitools) or commits it to the branch (OSS).
- Subsequent runs — diff against the baseline. Pixel-perfect match → green build. Diff exceeds threshold → red build with a side-by-side comparison.
- Review — designer or front-end lead approves the change ("yes, the new card design is intentional") or rejects it ("the heading lost its margin — fix the CSS").
- Merge — approved baselines become the new reference for
main.
Most teams gate the merge on visual approval the same way they gate it on functional tests — green Cypress + green visual = mergeable.
A typed wrapper for stable visual tests
A small abstraction that pays off across tens of visual tests:
declare global {
namespace Cypress {
interface Chainable {
visualSnapshot(name: string, options?: { hideDynamic?: boolean }): Chainable<void>;
}
}
}
Cypress.Commands.add("visualSnapshot", (name, options = {}) => {
if (options.hideDynamic) {
cy.percySnapshot(name, {
percyCSS: `
[data-testid$="-timestamp"] { visibility: hidden; }
[data-testid$="-counter"] { visibility: hidden; }
.live-region { visibility: hidden; }
`,
});
} else {
cy.percySnapshot(name);
}
});
export {};Tests stay focused:
it("matches the dashboard baseline", () => {
cy.sessionLogin("alice@test.com", "Sup3rS3cret!");
cy.visit("/dashboard");
cy.get("[data-testid='dashboard-loaded']").should("be.visible");
cy.visualSnapshot("Dashboard", { hideDynamic: true });
});Swap Percy for Applitools or cypress-image-snapshot later by changing the wrapper — every test that uses cy.visualSnapshot migrates automatically.
⚠️ Common mistakes
- Running visual tests on every push without a budget. Cloud tools charge per snapshot. A few hundred snapshots × every push × a busy team = a surprising bill at the end of the month. Visual tests typically run on PRs to main and on a nightly schedule, not on every commit.
- Snapshotting pages with un-stubbed network data. The first user fixture you don't control changes their avatar or display name and your baseline fails for an unrelated reason. Stub network responses and use fixtures to make the visual snapshot deterministic.
- Treating a red visual diff as automatic "fail the build, page the engineer." Most diffs are intentional design changes that need approval, not bugs. Wire visual reviews into the same workflow as PR review — a designer or front-end lead approves the change, baseline updates, build goes green.
🎯 Practice task
Wire visual regression into a real spec. 25-30 minutes.
- Pick a tool. If your team has Percy or Applitools, use it. Otherwise install
cypress-image-snapshotfor an OSS local setup. - Configure the import in
cypress/support/e2e.tsand the matcher options (failure threshold ~2% for OSS, default for cloud tools). - Create
cypress/e2e/visual-homepage.cy.tswith oneitthat visits/(or whatever public page you have), waits for any post-load assertions to settle, and calls the visual snapshot command. - Run the spec twice. The first run produces the baseline; the second confirms it's reused. Inspect the baseline file or dashboard.
- Force a difference — visit a page in your dev tools, manually edit the CSS to bump a heading's margin or change a colour, then visit a styled mirror page in your spec (use
cy.get('h1').invoke('css', 'color', 'red')to force a colour change in-test if you can't change source). Re-run. The visual diff should fail. Inspect the diff. - Mask dynamic content — find any element on the page that changes every run (current time, "X products in stock"). Add a CSS hide rule to the snapshot. Re-run; confirm the masked region is no longer the cause of diffs.
- Stretch: wire two breakpoints —
cy.viewport(375, 667)for mobile andcy.viewport(1280, 720)for desktop. Take a separate snapshot at each. Confirm both baselines are stored independently and a mobile-only regression doesn't affect the desktop baseline.
The next lesson is about a different kind of automated quality check — accessibility, where the diff isn't between baselines but between the page and the WCAG specification.