Handling Dynamic Content in Visual Tests — automated screenshot comparison for catching visual regressions.
Handling Dynamic Content in Visual Tests enables catching unintended visual changes automatically. Instead of manually reviewing UI after every change, screenshot comparison detects pixel-level differences and reports them in CI. This prevents visual regressions from shipping to users undetected.
1npm install --save-dev @playwright/test
2npx playwright install --with-deps chromium
3
4# Generate initial baseline screenshots:
5npx playwright test tests/visual/ --update-snapshots
6
7# Run visual comparison tests:
8npx playwright test tests/visual/
9
10# On failure, screenshots are in test-results/
11# The diff image shows exactly what changed1// playwright.config.ts
2export default defineConfig({
3 expect: {
4 toHaveScreenshot: {
5 maxDiffPixelRatio: 0.02, // 2% pixel difference allowed
6 threshold: 0.2, // per-pixel color tolerance
7 animations: 'disabled', // freeze CSS animations
8 },
9 },
10 snapshotDir: './tests/visual/__screenshots__',
11});1await expect(page).toHaveScreenshot('page.png', {
2 // Mask elements that change between test runs
3 mask: [
4 page.locator('[data-testid="timestamp"]'),
5 page.locator('[data-testid="user-avatar"]'),
6 page.locator('.advertisement'),
7 ],
8 animations: 'disabled',
9});
10
11// Freeze dynamic content before screenshot:
12await page.evaluate(() => {
13 document.querySelectorAll('[data-testid="live-count"]')
14 .forEach(el => el.textContent = '1,234');
15 const style = document.createElement('style');
16 style.textContent = '*, *::before, *::after { transition-duration: 0s !important; }';
17 document.head.appendChild(style);
18});1- name: Run visual tests
2 run: npx playwright test tests/visual/
3 continue-on-error: true
4
5- name: Upload visual test artifacts
6 uses: actions/upload-artifact@v4
7 if: always()
8 with:
9 name: visual-results
10 path: |
11 playwright-report/
12 test-results/
13 retention-days: 30Workflow for baseline updates:
npx playwright test --update-snapshots locally1test('dark mode visual consistency', async ({ page }) => {
2 // Light mode baseline
3 await page.goto('/courses');
4 await expect(page).toHaveScreenshot('courses-light.png');
5
6 // Dark mode — emulate prefers-color-scheme: dark
7 await page.emulateMedia({ colorScheme: 'dark' });
8 await page.reload();
9 await expect(page).toHaveScreenshot('courses-dark.png');
10
11 // Verify dark mode is actually applied
12 const bodyBg = await page.evaluate(() =>
13 getComputedStyle(document.body).backgroundColor
14 );
15 // Dark backgrounds have low luminance
16 const [r, g, b] = bodyBg.match(/\d+/g)?.map(Number) || [];
17 const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
18 expect(luminance).toBeLessThan(128); // dark background
19});1const BREAKPOINTS = [
2 { name: 'mobile', width: 375, height: 812 },
3 { name: 'tablet', width: 768, height: 1024 },
4 { name: 'desktop', width: 1280, height: 800 },
5];
6
7for (const { name, width, height } of BREAKPOINTS) {
8 test(`homepage at ${name}`, async ({ page }) => {
9 await page.setViewportSize({ width, height });
10 await page.goto('/');
11 await expect(page).toHaveScreenshot(`homepage-${name}.png`, {
12 fullPage: true,
13 animations: 'disabled',
14 });
15 });
16}
17
18// Also check for horizontal overflow:
19test('no horizontal scroll on mobile', async ({ page }) => {
20 await page.setViewportSize({ width: 375, height: 812 });
21 await page.goto('/');
22 const hasScroll = await page.evaluate(() =>
23 document.documentElement.scrollWidth > document.documentElement.clientWidth
24 );
25 expect(hasScroll).toBe(false);
26});Production visual regression tests with Playwright covering pages, components, dark mode, and responsive breakpoints.
1// Masking dynamic content in visual tests
2import { test, expect } from '@playwright/test';
3
4test('course listing page visual', async ({ page }) => {
5 await page.goto('/courses');
6 await page.waitForLoadState('networkidle');
7
8 // Freeze dynamic content before screenshot
9 await page.evaluate(() => {
10 // Replace timestamps with static text
11 document.querySelectorAll('[data-testid="created-at"]').forEach(el => {
12 el.textContent = 'January 1, 2024';
13 });
14 // Replace animated elements with static state
15 document.querySelectorAll('.animate-pulse').forEach(el => {
16 el.classList.remove('animate-pulse');
17 });
18 // Freeze CSS animations
19 const style = document.createElement('style');
20 style.textContent = '*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }';
21 document.head.appendChild(style);
22 });
23
24 // Mask elements that change per test run
25 await expect(page).toHaveScreenshot('courses.png', {
26 mask: [
27 page.locator('[data-testid="user-avatar"]'), // user-specific
28 page.locator('.advertisement'), // third-party
29 page.locator('[data-testid="live-count"]'), // real-time data
30 ],
31 animations: 'disabled',
32 });
33});This visual test implementation covers handling dynamic content in visual tests with Playwright's built-in screenshot comparison. Baseline screenshots are stored in __screenshots__ and compared on every test run. The 2% maxDiffPixelRatio allows for minor rendering differences across environments. CI uploads failure artifacts for review, and developers update baselines by running npx playwright test --update-snapshots when changes are intentional.
Continue with the next visual regression topic to build comprehensive screenshot coverage across your entire UI.
Your progress is saved in this browser, on this device only.