Playwright’s visual testing capability is more about detecting drift than outright regressions, and it’s surprisingly effective at catching things you’d never write an assertion for.
Let’s see it in action. Imagine you have a simple React component:
// src/components/Button.jsx
import React from 'react';
import './Button.css';
function Button({ children, onClick, primary }) {
const className = primary ? 'button-primary' : 'button-secondary';
return (
<button className={className} onClick={onClick}>
{children}
</button>
);
}
export default Button;
And its CSS:
/* src/components/Button.css */
.button-primary {
background-color: #007bff;
color: white;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
}
.button-secondary {
background-color: #e0e0e0;
color: black;
padding: 10px 20px;
border: 1px solid #ccc;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
}
Now, in your Playwright test file, you can visually test this button. First, make sure you have Playwright installed:
npm install --save-dev @playwright/test
# or
yarn add --dev @playwright/test
And configure your playwright.config.ts to include the visual testing reporter:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/* Maximum time expect() should wait for the condition to be met. */
timeout: 5000,
},
reporter: [
['list'],
['@playwright/test/reporter/html', { open: 'always' }],
['@visual-testing/playwright', { outputDir: './visual-tests' }] // Add this line
],
use: {
browserName: 'chromium',
headless: true,
viewport: { width: 1280, height: 720 },
screenshot: 'only-on-failure',
video: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
Your test file might look like this:
// tests/button.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Button Component Visual Tests', () => {
test('Primary button default state', async ({ page }) => {
await page.goto('http://localhost:3000/button-story'); // Assuming a storybook or local dev server
const primaryButton = page.locator('button', { hasText: 'Click Me' }); // Select the primary button
await expect(primaryButton).toHaveScreenshot('primary-button-default.png');
});
test('Secondary button default state', async ({ page }) => {
await page.goto('http://localhost:3000/button-story');
const secondaryButton = page.locator('button', { hasText: 'Cancel' }); // Select the secondary button
await expect(secondaryButton).toHaveScreenshot('secondary-button-default.png');
});
test('Primary button on hover', async ({ page }) => {
await page.goto('http://localhost:3000/button-story');
const primaryButton = page.locator('button', { hasText: 'Click Me' });
await primaryButton.hover();
await expect(primaryButton).toHaveScreenshot('primary-button-hover.png');
});
});
When you run npx playwright test, Playwright will navigate to your page, take screenshots of the specified elements, and compare them against a baseline. The first time you run it, it will generate these baseline screenshots in the ./visual-tests directory (as configured). On subsequent runs, it compares against these stored images. If there’s any pixel difference, the test fails, and you get a diff report.
The core problem Playwright visual testing solves is the brittleness of traditional DOM assertions for UI appearance. You could write assertions for backgroundColor, padding, borderRadius, color, border, etc., but that’s tedious, hard to maintain, and prone to breaking due to minor, acceptable design tweaks. Visual testing captures the entire visual state of an element or page, treating it as a single, cohesive unit. It’s about detecting drift – subtle, unintended changes in layout, spacing, colors, typography, or even the presence/absence of minor visual artifacts that would be missed by targeted assertions.
Internally, Playwright uses a pixel-matching engine. When toHaveScreenshot is called, it renders the specified element to an image. This image is then compared pixel-by-pixel against the stored baseline image for that test. The comparison isn’t always a strict "all or nothing" match; it can be configured with thresholds for acceptable differences, though the default is usually quite sensitive. Playwright also leverages its own browser automation capabilities to ensure consistent rendering environments across test runs.
The exact levers you control are:
- The Element/Page to Capture: You can capture an entire page (
await expect(page).toHaveScreenshot(...)) or specific elements using Playwright’s locators (await expect(locator).toHaveScreenshot(...)). - Screenshot Naming: The string argument to
toHaveScreenshot('primary-button-default.png') is crucial. It defines the baseline image file name and links the visual state to a specific test scenario. - Configuration: In
playwright.config.ts, you define theoutputDirfor storing baselines and the reporter. You can also set globalscreenshotoptions likefullPage: trueoromitBackground: true. - Comparison Options: While not directly in
toHaveScreenshotitself, the underlying visual testing reporter might offer options for diff thresholds, masking areas, or specifying pixel comparison algorithms. These are usually configured within the reporter’s options inplaywright.config.ts.
What most people don’t realize is how powerful Playwright’s mask option is for visual testing. You can tell Playwright to ignore specific parts of a screenshot, which is invaluable when dealing with dynamic content like timestamps, user avatars, or ephemeral notification banners. For example:
await expect(page.locator('#user-profile')).toHaveScreenshot('user-profile-no-avatar.png', {
mask: [
page.locator('.user-avatar'),
page.locator('.last-login-time')
]
});
This tells Playwright to capture the #user-profile element but to treat any pixels within the .user-avatar and .last-login-time elements as if they were transparent or matched perfectly, regardless of their actual content. This allows you to visually test the layout and surrounding elements of a component without failing due to unpredictable dynamic data.
The next step in mastering visual testing is often integrating it with CI/CD pipelines for automated baseline updates and managing visual diffs across different browsers and devices.