Screenshots and visual regression testing
Learn how to take some pictures and implement visual regression tests.
Playwright's debugging tools (traces, UI mode) usually answer "what happened?", but a plain screenshot is sometimes the fastest way to peek inside a headless run.
Page screenshots
import { test, expect } from "@playwright/test";
test("get started link", async ({ page }) => {
await page.goto("/");
// take a page screenshot
await page.screenshot({ path: "./home.png" });
});
Use page.screenshot() to capture the state of the page. Pass fullPage: true to capture the entire scrollable page instead of just the viewport:
await page.screenshot({ path: "./home.png", fullPage: true });
Locator screenshots
If you're only interested in a particular DOM element, locators have a screenshot() method, too.
import { test, expect } from "@playwright/test";
test("get started link", async ({ page }) => {
await page.goto("/");
// take a screenshot of a particular element
await page
.getByRole("link", { name: "Products" })
.screenshot({ path: "./products.png" });
});
Capture a single product card
Adjust one of your existing tests, navigate to /search, and take a
screenshot of the first product card only.
▸ Show how
test("product card screenshot", async ({ page }) => {
await page.goto("/search");
await page
.getByTestId("search-grid")
.getByRole("link")
.first()
.screenshot({ path: "./screenshots/product.png" });
});
Screenshots as test attachments
Honestly, I rarely reach for screenshots myself — trace files already capture every action, network call, and DOM snapshot, so the trace almost always has whatever I need to debug a failure. The one place screenshots earn their keep is the HTML report: a thumbnail next to a failing test is much faster to scan than opening a trace, especially when triaging CI runs. Calling page.screenshot() without a path returns the image as a Buffer, which you can hand straight to testInfo.attach() to surface it in the test report.
test("basic test", async ({ page }, testInfo) => {
await page.goto("/");
const screenshot = await page.screenshot();
await testInfo.attach("screenshot", {
body: screenshot,
contentType: "image/png",
});
});
Test attachments can be very valuable when your tests are dealing with file uploads and downloads.
Include the browser name in the path
If you're running parallel tests with multiple browsers, including the browserName in the screenshot path keeps each browser's screenshots from clobbering the others'.
test("get started link", async ({ page, browserName }) => {
await page.goto("https://playwright.dev/");
await page
.getByRole("link", { name: "Docs" })
.screenshot({ path: `./docs-${browserName}.png` });
});
The page, browserName and other test variables are called test fixtures.
Playwright provides many
fixtures for different use
cases and we'll look at them later.
You only need this manual interpolation for page.screenshot(). toHaveScreenshot() includes the project name in the baseline filename automatically, so multi-browser projects don't collide.
Visual regression snapshots
Once you're comfortable taking manual screenshots, you can promote them to assertions with toHaveScreenshot() — Playwright's built-in visual regression check.
test("get started link", async ({ page, browserName }) => {
await page.goto("https://playwright.dev/");
// visual regression works on a page level...
await expect(page).toHaveScreenshot("home.png");
// but also on a locator level
await expect(page.getByRole("link", { name: "Docs" })).toHaveScreenshot(
"docs.png",
);
});
toHaveScreenshot() stores baseline screenshots next to your test files in a <testfile>-snapshots/ folder and compares against them on future runs. The filename includes the project name and platform (e.g. home-chromium-darwin.png), so multi-browser projects produce one baseline per browser. Unlike page.screenshot(), toHaveScreenshot() auto-retries until two consecutive captures match before comparing — that's why it's the right tool for visual regression.
Playwright by default disables animations and transitions when taking screenshots to avoid flakiness because of moving elements.
Creating and updating baselines
The first run of a toHaveScreenshot() assertion always fails because there's no baseline to compare against yet. It will be so kind to generate one for you, though.
Later on, generate new snapshots with the --update-snapshots flag (short: -u):
npx playwright test --update-snapshots
This writes any missing baselines and overwrites existing ones. Commit the resulting *-snapshots/ directories to version control — they're the source of truth your tests compare against.
When a visual regression fails, the HTML report shows a side-by-side diff with an overlay slider:
npx playwright show-report
Screenshots differ across operating systems, browser versions, and font
rendering. Baselines generated on macOS will almost always fail on a Linux CI
runner. Generate baselines in the same environment they'll run in. Either by
running --update-snapshots on CI and commiting the snapshots, or by running
tests locally inside the official Playwright Docker
image.
Cross-platform rendering is a very annoying problem and that's why many people don't get into spending much time on implementing visual regression.
Screenshot configuration
screenshot() and toHaveScreenshot() share most options. The ones you'll reach for most often:
fullPage— capture the entire scrollable page (page-level only)mask— pass an array of locators to overlay with a solid color, hiding dynamic content like timestamps or avatarsmaxDiffPixels/maxDiffPixelRatio— how many pixels are allowed to differ before the assertion fails (absolute count vs. ratio)threshold— per-pixel color tolerance, default0.2stylePath— inject a CSS file at capture time to hide flaky elements site-wideanimations—'disabled'(default) or'allow'clip— capture a rectangular region
Set project-wide defaults in playwright.config.ts under expect.toHaveScreenshot so individual tests stay clean:
// playwright.config.ts
export default defineConfig({
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: 0.01,
stylePath: "./tests/screenshot.css",
},
},
});
Per-test overrides still work — pass options as the second argument to toHaveScreenshot():
test("product listing", async ({ page }) => {
await page.goto("/search");
await expect(page).toHaveScreenshot("products.png", {
mask: [page.getByTestId("product")],
});
});
Practice visual regression
Mask the product catalog
- Navigate to the catalog at
/search. - Add visual regression testing and mask every product so the captured image hides the product tiles behind the default mask color