A functional test checks that a button works but does not notice that it has shifted, become invisible on the dark theme or lost its padding after a CSS edit. Visual regression is caught by screenshot comparison: Playwright captures an element or the page and compares it with a saved reference - a golden file. In essence this is a rendering contract: "this is how it should look", fixed in an image. The layout breaks - the screenshot diverges from the reference, and the test goes red.
This works through the toHaveScreenshot matcher. await expect(page).toHaveScreenshot(...) compares the capture with the reference; on the first run the reference is created, on later ones it is compared. You can capture either the whole page or a single element via a locator - expect(locator).toHaveScreenshot(...) fixes one component, which is more robust and meaningful than a full-page capture depending on everything at once.
The matcher has options without which visual tests are unstable. animations: 'disabled' stops CSS animations so the capture does not land on an intermediate frame. fullPage: true captures the whole scrollable page, not just the viewport. mask takes locators of areas to hide before comparison - where the content by definition changes from run to run. These options turn a capricious capture into a reproducible contract.
It helps to see both modes - the page and a component - together with the stabilization options once. Below is a full-page capture of order placement with animations disabled and a mask over the dynamic balance, and a capture of a single price card. You return to this form when adding a visual check: first you decide what exactly to capture - the whole page or a specific component - and what on it is knowingly dynamic.
The main difficulty of visual tests is the reference's dependence on the environment. A golden file is tied to the operating system, the browser, the set of fonts and rendering specifics: a capture made on the developer's macOS will not match the same capture on Linux in CI due to different font anti-aliasing. So references are generated and compared in an identical environment - as a rule, in the same Docker container as CI, not on a local machine.
Dynamics are masked pointwise, not by a threshold. A date, a balance, a random id, an ad banner - everything that changes on its own - is covered with a mask so it does not fail the comparison. The temptation to instead raise the allowed pixel-diff threshold is dangerous: a large threshold muffles not only noise but real regressions - a block shifted by a few pixels will pass unnoticed. Mask the concrete dynamic, and keep the threshold strict.
Updating references is a conscious action, not an automatism. When the design has deliberately changed, references are regenerated with the --update-snapshots flag, but precisely after making sure the new rendering is correct. Bulk-updating references on every red capture means turning a visual test into a fiction that agrees with any look, including a broken one. A red visual test poses a question: did I change the design, or is this a regression?
The typical visual-test failures are predictable. References captured locally and compared in CI - eternal discrepancies due to different fonts and rendering. A huge pixel-diff threshold instead of a mask - hidden regressions under the guise of "acceptable noise". And a reflexive update of all references on every red capture - a visual test that checks nothing. Generate references in the CI environment, mask the concrete dynamic and update captures consciously.
// The whole page: animations off, dynamics masked
await expect(page).toHaveScreenshot('checkout.png', {
fullPage: true,
animations: 'disabled',
mask: [page.getByTestId('dynamic-balance')],
})
// A single component - more robust than a full-page capture
await expect(page.getByTestId('price-card'))
.toHaveScreenshot('price-card.png')