Visual Testing
Catch what functional tests miss: a button moved 2px, a colour change, a broken image. Take a baseline screenshot of the card, plant a bug, and see your test fail — with Playwright's toHaveScreenshot or Cypress. Every recipe is run against this page in our test suite.
The card under test
Take a screenshot of this card as your baseline, then plant one of the bugs below and see if your test catches it. The viewer count and countdown change on their own — mask them or freeze time.
BestsellerNoise-cancelling Headphones
★★★★★ 4.7 (2,318)
₹7,999 ₹9,999
1. Take a baseline, then catch a bug
The first run saves a baseline image. Every later run compares against it. Plant a bug with ?bug=shift and run again: the test fails and saves a diff image showing exactly what moved.
import { test, expect } from '@playwright/test';
test('product card looks right', async ({ page }) => {
await page.goto('/misc/visual-testing'); // then try '/misc/visual-testing?bug=shift'
const card = page.getByTestId('vt-card');
await expect(card).toHaveScreenshot('product-card.png', {
mask: [card.getByTestId('vt-viewers'), card.getByTestId('vt-clock')], // parts that change every second
animations: 'disabled', // stop the pulsing badge
});
});
// First run: npx playwright test --update-snapshots (saves the baseline)
// Later runs: npx playwright test (compares; diff images go to test-results/)2. Freeze time instead of masking
The viewer count and the countdown are worked out from the current time. Freeze the page's clock and they become the same on every run — nothing needs hiding.
import { test, expect } from '@playwright/test';
test('card with frozen time', async ({ page }) => {
await page.clock.setFixedTime(new Date('2026-01-15T10:00:00'));
await page.goto('/misc/visual-testing');
await expect(page.getByTestId('vt-clock')).toHaveText('Offer ends in 14:00:00');
await expect(page.getByTestId('vt-card')).toHaveScreenshot('card-frozen.png', { animations: 'disabled' });
});3. Thresholds: how different is “different”?
Surprise: with the default settings, ?bug=colour (purple → indigo) passes recipe 1! A pixel only counts as changed if its colour moves more than the threshold (0.2 by default). Set threshold to 0 to catch both colour bugs — and allow a few changed pixels for anti-aliasing noise instead.
import { test, expect } from '@playwright/test';
test('strict colour check', async ({ page }) => {
await page.clock.setFixedTime(new Date('2026-01-15T10:00:00'));
await page.goto('/misc/visual-testing'); // then try '/misc/visual-testing?bug=colour'
await expect(page.getByTestId('vt-card')).toHaveScreenshot('card-strict.png', {
animations: 'disabled',
threshold: 0, // any colour change counts…
maxDiffPixels: 10, // …but tolerate a handful of noisy pixels
});
});4. Check it on a phone-sized screen
Layout bugs often only appear at small widths. Take a separate baseline per screen size.
import { test, expect } from '@playwright/test';
test.use({ viewport: { width: 375, height: 800 } });
test('card on a phone', async ({ page }) => {
await page.clock.setFixedTime(new Date('2026-01-15T10:00:00'));
await page.goto('/misc/visual-testing');
await expect(page.getByTestId('vt-card')).toHaveScreenshot('card-phone.png', { animations: 'disabled' });
});Good to know
Visual tests are powerful but picky.
- Baselines are per machine. Fonts and anti-aliasing differ between macOS, Windows and Linux, so create and compare baselines on the same OS — in CI, generate them in the same Docker image the tests run in.
- Screenshot the component, not the whole page, so an unrelated change elsewhere doesn't fail every test.
- Review diffs before updating baselines. Running with
--update-snapshotsaccepts whatever is on screen — including real bugs. - Hosted services (Percy, Applitools, Chromatic) add review workflows and cross-browser rendering on top of the same idea.