Skip to content
End To End Tester

Screenshots

Capturing the screen at the moment of failure, and using screenshot comparison as visual regression testing — configuration, masking and the flakiness to design out.

2 min read · updated 19 September 2026

Two different activities share one mechanism.

Diagnostic screenshots capture the screen when a test fails, so you can see what was actually there. Nearly free, and the single highest-value CI artefact after the trace.

Visual regression testing compares a screenshot against an approved baseline and fails on a difference. Genuinely useful, and the most configuration-sensitive testing technique covered here.

#Diagnostic capture

typescript
// playwright.config.ts — costs nothing on a green run.
use: {
  screenshot: 'only-on-failure',
  trace: 'on-first-retry',
  video: 'retain-on-failure'
}
typescript
// Cypress: on by default in run mode.
screenshotOnRunFailure: true
csharp
// Selenium: no built-in hook, so do it in teardown.
public void Dispose()
{
    if (TestContext.Current.Result == TestOutcome.Failed)
    {
        var shot = ((ITakesScreenshot)_driver).GetScreenshot();
        shot.SaveAsFile($"artifacts/{TestContext.Current.TestName}.png");
    }
    _driver.Quit();
}

Capture the page HTML at the same time. A screenshot shows what it looked like; the HTML shows why — the disabled attribute, the hidden error message, the element that never rendered.

typescript
test.afterEach(async ({ page }, testInfo) => {
  if (testInfo.status !== testInfo.expectedStatus) {
    await testInfo.attach('dom', { body: await page.content(), contentType: 'text/html' });
  }
});

#Visual regression

typescript
// Playwright — built in, no service required.
test('the checkout page looks right', async ({ page }) => {
  await page.goto('/checkout');

  await expect(page).toHaveScreenshot('checkout.png', {
    fullPage: true,
    maxDiffPixelRatio: 0.01,
    // Everything that legitimately changes between runs.
    mask: [
      page.getByTestId('order-reference'),
      page.getByTestId('placed-at'),
      page.locator('[data-ad-slot]')
    ],
    animations: 'disabled'
  });
});
bash
npx playwright test --update-snapshots

The three settings that decide whether this works:

animations: 'disabled'. A screenshot taken mid-transition differs from one taken after it. This one setting removes most visual flakiness.

mask. Timestamps, order references, avatars, ads, anything randomised. Masking is the difference between a useful signal and a test that is red every day.

maxDiffPixelRatio. A small tolerance absorbs antialiasing noise. Too large and it absorbs real regressions; 0.01 is a reasonable starting point.

#The font problem

The most common reason visual tests fail in CI and pass locally.

A macOS machine and a Linux container have different fonts, different hinting and different subpixel antialiasing. The same CSS produces measurably different pixels.

The fix is to generate baselines in the same environment that compares them:

yaml
# .github/workflows/visual.yml — update baselines in the CI image itself
- name: Update baselines
  if: github.event_name == 'workflow_dispatch'
  run: |
    docker run --rm -v "$PWD":/work -w /work \
      mcr.microsoft.com/playwright:v1.49.0-noble \
      npx playwright test --update-snapshots
typescript
// And name baselines per project, because engines render differently too.
await expect(page).toHaveScreenshot(`checkout-${test.info().project.name}.png`);

See cross-browser testing.

#Component-level visual tests

Cheaper and more precise than full-page ones: less surface to change, so fewer false failures.

typescript
// Playwright component testing — one component, real browser rendering.
test('the price tag shows a strikethrough when discounted', async ({ mount }) => {
  const component = await mount(<PriceTag cents={1_200} wasCents={1_500} />);

  await expect(component).toHaveScreenshot('price-tag-discounted.png');
});

A full-page baseline changes whenever anything on the page changes. A component baseline changes when that component changes, which is the signal you wanted.

#Hosted services

Percy, Applitools, Chromatic and similar take the baselines off your repository and add review workflows and smarter diffing — Applitools in particular uses visual AI to ignore antialiasing and layout noise that a pixel diff flags.

What you get: approval flows, cross-browser rendering farms, no baseline images in git, and much lower false-positive rates.

What you pay: money, and a dependency in your pipeline.

For a small suite, Playwright's built-in comparison in a pinned container is usually enough. For a design system consumed by many teams, the review workflow of a hosted service is the thing worth paying for.

#Storage

Screenshots are large. Be deliberate:

yaml
- uses: actions/upload-artifact@v4
  if: failure()                    # not always(): only when there is something to look at
  with:
    name: screenshots-${{ matrix.shard }}
    path: test-results/**/*.png
    retention-days: 7              # nobody opens a two-week-old screenshot

And do not commit failure screenshots. test-results/ and playwright-report/ belong in .gitignore; baselines are the only images that belong in the repository.

Common questions

Should I take a screenshot on every test or only on failure?
Only on failure, for diagnosis. A screenshot per test is storage and noise. Visual regression testing is a different activity that happens to use the same mechanism, and there you capture deliberately on specific pages.
Why do my visual regression tests fail on CI but pass locally?
Font rendering. A CI container has a different font set and different antialiasing from macOS or Windows, so the same page renders differently. Generate baselines in the same container that compares them — usually by running the update inside the CI image.
How do I handle dynamic content in visual tests?
Mask it. Every visual tool supports excluding regions — timestamps, avatars, ad slots, anything randomised. Masking is what turns visual regression from permanently red into a useful signal.

Runnable samples for this page

last test results ↗

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?