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
// playwright.config.ts — costs nothing on a green run.
use: {
screenshot: 'only-on-failure',
trace: 'on-first-retry',
video: 'retain-on-failure'
}// Cypress: on by default in run mode.
screenshotOnRunFailure: true// 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.
test.afterEach(async ({ page }, testInfo) => {
if (testInfo.status !== testInfo.expectedStatus) {
await testInfo.attach('dom', { body: await page.content(), contentType: 'text/html' });
}
});#Visual regression
// 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'
});
});npx playwright test --update-snapshotsThe 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:
# .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// And name baselines per project, because engines render differently too.
await expect(page).toHaveScreenshot(`checkout-${test.info().project.name}.png`);#Component-level visual tests
Cheaper and more precise than full-page ones: less surface to change, so fewer false failures.
// 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:
- 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 screenshotAnd 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 ↗- TypeScript (Playwright)
browser/tests/diagnostics/screenshots
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Screen RecordingsVideo of a test run — when it is worth the storage, how to configure it so it costs nothing on green runs, and why a trace is usually better.
- Traces and Debugging CI FailuresA trace records every action, request, console message and DOM snapshot of a run — the single artefact that turns an unreproducible CI failure into a five-minute diagnosis.
- PlaywrightThe default browser automation tool in 2026 — auto-waiting locators, tracing, sharding and fixtures — with the configuration that matters and the mistakes that still cause flakiness.
- Snapshot TestingRecording output and comparing it on every run — where snapshots earn their place, the approval reflex that destroys their value, and better alternatives.
- Cross-Browser TestingWhich browsers actually need testing in 2026, what still differs between engines, and a strategy that catches real defects without tripling your pipeline.
- Testing Avalonia ApplicationsAvalonia's headless test platform runs real UI tests in CI in milliseconds with no display server — the most testable desktop stack in .NET.
- Browser TestingWhat a browser test can prove that nothing below it can, the three engines that matter, headless versus headed, and the cost model that should shape your suite.
- iOS App TestingXCTest, Swift Testing, XCUITest and the accessibility identifiers that make UI automation possible — plus what to run on a simulator and what needs a device.