DEV Community

Cover image for Visual Regression Testing with Playwright: Detecting UI Changes Automatically
Junior Diaz
Junior Diaz

Posted on • Originally published at juniordiazbriceno.hashnode.dev

Visual Regression Testing with Playwright: Detecting UI Changes Automatically

Series: Quality and Accessibility in Web Applications — Article 1 of 3


Introduction

When testing web applications, we tend to focus on functionality — but how do we ensure the user interface looks correct after every deployment?

That's where visual testing comes in.

Visual testing automatically compares UI screenshots between test runs, helping us detect unexpected layout changes, missing elements, or style regressions. A padding change, a button that shrank, a component that shifted two pixels — the kind of thing that slips through code review but that your UX and QA team needs to catch before it reaches production.

Although dedicated tools like Percy by BrowserStack offer advanced visual comparison capabilities, they come with a screenshot limit. Playwright's built-in functionality has no such direct cost — snapshots live in your repository. The "cost" is disk space and Git history size, both of which you can manage with a proper storage strategy. For teams and clients that prioritize open source tools, this is a compelling argument: you get visual comparison, historical evidence, multi-browser and multi-breakpoint support, all within the same tool you already use for functional testing.

In this article I'll show you how to set up visual regression testing with Playwright using real examples from AutoCatalog, a car catalog I built in Next.js + TypeScript to experiment with these patterns.

By the end, you'll be able to:

  • Capture baseline screenshots of key UI components
  • Automatically compare them in future test runs
  • Detect unwanted visual changes early in your CI pipeline

⚙️ Prerequisites

Before we start, make sure you have:

  • Node.js (LTS version recommended)
  • A Playwright project already set up:
pnpm create playwright
Enter fullscreen mode Exit fullscreen mode
  • Basic knowledge of Playwright tests and the Page Object Model (POM) pattern

Setting Up Visual Tests

1. Basic Screenshot Assertions

Playwright includes built-in support for visual comparisons via expect(page).toHaveScreenshot() or expect(element).toHaveScreenshot().

The first time you run a test, Playwright saves a baseline image. In future runs, it compares the new screenshot against the baseline and flags any differences.

To illustrate the real value of this: imagine someone modifies the size or padding of the "Add Product" button — a small change that easily goes unnoticed in code review. Visual regression testing catches it immediately.

test('Product Management - Default layout page', async ({ page }) => {
    await test.step('Capture full-page baseline', async () => {
        await expect(page).toHaveScreenshot('manage_default_layout.png')
    })
})
Enter fullscreen mode Exit fullscreen mode

If the layout changes, Playwright generates a diff image and the report includes an interactive slider to compare before and after:

Playwright visual regression report showing slider comparison — juniordiazbriceno.dev

📸 The Playwright report slider lets you visually compare the baseline against the current state — ideal for catching subtle spacing or size changes.


2. Component-Level Visual Testing

Instead of capturing full pages, you can focus on specific components or modals. This makes tests more stable: a header change won't break the modal test.

test('Product Management - Add Product modal default layout matches baseline', async ({ page }) => {
    await test.step('Open Add Product modal', async () => {
        await managePage.openAddProductModal()
        await managePage.expectAddProductModalVisible()
    })

    await test.step('Capture modal component screenshot', async () => {
        const productModal = managePage.modalTitle.locator('..')
        await expect(productModal).toHaveScreenshot(
            'add_product_modal_default.png'
        )
    })
})
Enter fullscreen mode Exit fullscreen mode

The screenshot scope is the modal itself — not the full page. This way, the test verifies that the modal's layout, spacing, and elements stay consistent without depending on the rest of the UI.

Add Product modal snapshot baseline — AutoCatalog demo site

📸 Snapshot of the Add Product modal — the test verifies that the layout, spacing, and modal elements remain consistent.


3. Capturing Snapshots at Key UI States

Visual tests are most effective when you capture relevant interface states, not every interaction.

The home carousel: the dynamic content problem

We verify that the home page layout looks correct without noise generated by animations. The carousel displays images in random positions on each run — without mask, the test fails constantly even when the layout is perfectly fine.

test('Home - Full page screenshot with carousel masked — stable test', async ({ page }) => {
    await test.step('Wait for carousels to be visible', async () => {
        await homePage.expectCarouselsVisible()
    })

    await test.step('Capture full page with all carousels masked', async () => {
        const allCarousels = await homePage.getAllCarousels()
        await expect(page).toHaveScreenshot('home_with_mask.png', {
            mask: allCarousels,
        })
    })
})
Enter fullscreen mode Exit fullscreen mode

👉 The mask: Locator[] option ignores areas that change dynamically, like the carousel.

Let's look at both scenarios:

Sin mask: diff report with red highlights showing carousel differences — juniordiazbriceno.dev

📸 Without mask: the test fails because the carousel shows different images between runs — Playwright detects the differences and marks them in red.

Con mask aplicado: test passing with magenta masked areas — juniordiazbriceno.dev

Playwright test report showing passing result for the carousel mask test — 1 passed, 0 failed — juniordiazbriceno.dev

📸 With mask applied: the dynamic areas are ignored and the test passes consistently.


4. Managing Baseline Images

The first time you run the tests, Playwright saves the baseline screenshots under:

/tests/screenshots/
Enter fullscreen mode Exit fullscreen mode

If a visual change is intentional, update the snapshots with:

pnpm exec playwright test --update-snapshots
Enter fullscreen mode Exit fullscreen mode

⚠️ Note for CI: Avoid running --update-snapshots automatically in your pipeline. Reserve this command for manual, reviewed updates. Consider creating a separate workflow or gating it behind an explicit confirmation.

To review differences:

  1. Open the test-results folder
  2. Compare the baseline, actual, and diff images
  3. Approve or reject changes during code review

Best Practices

1. Organize Tests by Feature

Group visual tests logically using test.describe():

test.describe('@ProductManagement @Visual', () => {
    test.describe('@LayoutBaseline', () => {
        // Full layout test
    })

    test.describe('@ModalBaseline', () => {
        // Modal component tests
    })
})
Enter fullscreen mode Exit fullscreen mode

2. Use Descriptive Screenshot Names

Name screenshots clearly to indicate what they're testing:

  • manage_default_layout.png
  • add_product_modal_masked_dropdowns.png
  • screenshot1.png

3. Prepare Test State Properly

Make sure the application is in the correct state before capturing a screenshot:

test.beforeEach(async ({ page }) => {
    poManager = new POManager(page)
    managePage = poManager.getManagePage()
    await managePage.navigateToManage()
    await managePage.expectManagePageLoaded()
})
Enter fullscreen mode Exit fullscreen mode

4. Use test.step with Snapshots

You can take snapshots inside test.step, which lets you capture multiple states within a single test. The upside: the test stays cohesive and the Playwright report shows exactly which step failed. The tradeoff: if one step fails, the following ones don't run — in some cases it's better to split them into separate tests.

test('Add Product modal — multiple states', async ({ page }) => {
    await test.step('Open modal', async () => {
        await managePage.openAddProductModal()
        await managePage.expectAddProductModalVisible()
    })

    await test.step('Capture default state', async () => {
        await expect(productModal).toHaveScreenshot('modal_default.png')
    })

    await test.step('Capture focus on submit button', async () => {
        await managePage.activateTabKeyboard()
        await managePage.focusModalSubmitButton()
        await expect(productModal).toHaveScreenshot('modal_submit_focus.png')
    })
})
Enter fullscreen mode Exit fullscreen mode

5. Test Responsive Layouts

Verify that responsive layouts work correctly across different screen sizes:

test('Home - mobile layout', async ({ page }) => {
    await page.setViewportSize({ width: 375, height: 667 })
    await expect(page).toHaveScreenshot('home-mobile.png')
})
Enter fullscreen mode Exit fullscreen mode

🧠 Troubleshooting Common Issues

Problem Solution
Minor pixel differences between runs Identify the root cause: use mask for dynamic content, addStyleTag for scrollbars, or explicit waits for elements still rendering. Avoid maxDiffPixelRatio — it accepts incorrect pixels as valid and can hide real regressions.
Dynamic content (carousels, timestamps, animations) Use the mask option to exclude it from the snapshot
False positives in CI Ensure consistent screen resolution and browser version across environments
Scrollbars generating diffs Hide them with addStyleTag before the screenshot

⚠️ On maxDiffPixelRatio: While Playwright offers this option to tolerate minor pixel differences, use it with care. A value of 0.01 may seem small, but on a 1280×720 screen that's over 9,000 pixels — enough to hide a misaligned button or a color change. In most cases, the right fix is to eliminate the source of instability, not tolerate it.


🎯 Conclusion

By combining Playwright's functional tests with visual assertions, you can confidently detect and prevent unwanted UI changes.

This approach gives your QA and development team an extra safety net: ensuring that both how your app behaves and how it looks remain consistent over time.

Key takeaways:

  • Use toHaveScreenshot() to capture and compare UI states
  • Mask dynamic elements to get reliable diffs
  • Version your baselines and review changes through the code review process
  • Avoid updating snapshots automatically in CI without an explicit review
  • Use test.step to organize tests with multiple captures and get more granular reports

This is the first in a series on QA automation — coming soon:

  • Automating Web Accessibility Testing: Combining axe-core and Playwright
  • WCAG 2.4.7 Focus Visible: Visual Regression Testing with Playwright

Does your team have visual regression coverage? If you want to explore how to implement this kind of testing in your project, tell me about your team here.


📚 Further Reading

Top comments (0)