Visual Regression Testing with Playwright + Percy
Visual regression testing is a technique that captures screenshots of components and pages during tests, comparing them against a baseline to detect unintended differences. With Playwright and Percy, you automate the capture and comparison of visual changes, ensuring that no CSS or layout modification breaks your user interface.
In this guide from the Advanced Playwright Component Testing series, you'll learn to configure Percy with Playwright for capturing component snapshots, creating baselines, managing intentional variations (e.g., themes, hover states), and integrating everything into a CI/CD pipeline. You'll discover how to reduce false positives, debug regression failures, and scale visual testing across hundreds of components without slowing down your test suite.
What You'll Learn
- How visual regression testing works and why it complements functional tests
- Setting up Percy with Playwright in an Angular project
- Writing tests that capture snapshots using Percy.snapshot()
- Creating and updating baseline comparison snapshots
- Managing intentional variations (breakpoints, themes, component state)
- CI/CD integration: automation on every PR and commit
- Strategies to reduce false positives (pixel tolerance, area masking)
- Debugging failed visual regressions and conscious rollbacks
- Performance best practices: batch tests, caching, parallel execution
- Scaling to 50+ components without build time degradation
1. Visual Regression Testing: Core Concepts
A functional test verifies behavior: "If I click this button, the modal must open." A visual test verifies appearance: "This button must always appear blue, with 8px rounded corners, and the text must stay centered even when the viewport is narrow."
Visual regression testing automates this check by comparing pixel-for-pixel. When you modify a button's CSS or a card's layout, the test captures a new screenshot, compares it to the saved baseline, and flags suspicious differences. The main advantage is speed: instead of manually testing every page after each change, a machine does it in seconds across dozens of components.
1.1 Visual Regression vs. Other Forms of Testing
Unit tests (Vitest) — verify logic and output: pure functions, pipes, directives. Functional tests (Playwright e2e) — verify user flows: clicks, form submission, navigation. Visual regression (Percy + Playwright) — verify visual appearance: CSS, layout, graphics.
These three are complementary. A functional test can pass (the button is clickable) while visual regression fails (the button is invisible because the text is white on a white background).
1.2 When to Use Visual Regression Testing
- Components with complex styling: cards, navbars, modals, tables with various states
- Responsive design: test layouts on mobile, tablet, desktop
- Multiple themes: light theme, dark theme, alternate brand
- Interactive states: hover, focus, disabled, loading
- Internationalization: layout with text in different languages (IT vs. EN vs. DE)
- CSS refactoring: renaming classes, migrating to utility-first (Tailwind)
2. Configuring Percy with Playwright
Percy is a cloud service (by Browserstack) that stores and compares screenshots. Integrating with Playwright requires: Percy SDK, API token, and configuration in your test file.
2.1 Installing Dependencies
Add Percy CLI and the Playwright SDK:
npm install --save-dev @percy/cli @percy/playwright
Create a Percy configuration file in your project root:
version: 2
static:
cleanUrls: true
include: "**/*.html"
exclude:
- node_modules/**/*
discovery:
network-idle-timeout: 750
percy:
version: 2
static-assets:
base-url: http://localhost:4200
browsers:
- name: chrome
widths: [1280, 768, 375] # desktop, tablet, mobile
- name: firefox
widths: [1280]
2.2 Authentication Token
Sign up on percy.io, create a new project, and copy your authentication token. Save it as an environment variable in your CI/CD:
PERCY_TOKEN=your_percy_token_here
Make sure .env.local is in .gitignore to avoid committing your token.
3. Writing Your First Visual Test
3.1 Base Test: Single Snapshot
Here's a Playwright Component test that captures a snapshot of a Button component:
import { test, expect } from '@playwright/experimental-ct-angular';
import { percySnapshot } from '@percy/playwright';
test.describe('Button Component - Visual Regression', () => {
test('primary button snapshot', async ({ mount, page }) => {
// Mount the component
await mount(`Click Me `, {
props: {}
});
// Take a Percy snapshot
await percySnapshot(page, 'button-primary');
// Verify functionally that it's clickable
const button = page.locator('button');
await expect(button).toBeVisible();
});
test('secondary button snapshot', async ({ mount, page }) => {
await mount(`Cancel `);
await percySnapshot(page, 'button-secondary');
});
test('disabled button snapshot', async ({ mount, page }) => {
await mount(`Disabled `);
await percySnapshot(page, 'button-disabled');
});
test('button with icon', async ({ mount, page }) => {
await mount(`
→
Send
`);
await percySnapshot(page, 'button-with-icon');
});
});
3.2 Running Tests and Creating Baseline
The first time you run tests, Percy automatically creates the baseline (initial screenshots).
# Run Playwright component tests with Percy
npx percy exec -- npm run test:ct
# Percy will upload screenshots to percy.io and
# mark them as "approved" (initial baseline)
Log in to percy.io in your browser to view the created snapshots. If everything looks correct, approve the baseline by clicking "Approve All".
4. Baseline and Conscious Updates
Once the baseline is approved, every future test run will compare new screenshots against that baseline. If they differ beyond a tolerance threshold, the test fails.
4.1 Intentionally Updating the Baseline
If you deliberately modify CSS (e.g., change primary color), visual tests will fail. You must manually approve the new screenshots on Percy:
1. Modify the component's CSS
src/app/components/button/button.component.css
Change: color: #0066FF -> color: #FF6600
2. Run component tests
npm run test:ct
3. Percy detects differences and creates a new "Build"
Log in to percy.io
4. View the diffs (split view old vs. new)
Verify the change is intentional
5. Click "Approve" to update the baseline
6. Next test run will use this as the new baseline
4.2 Pixel Tolerance and Dynamic Area Masking
Small rendering differences (anti-aliasing, font rendering) can cause false positives. Percy supports pixel tolerance and area masking.
import { percySnapshot } from '@percy/playwright';
test('button with pixel tolerance', async ({ mount, page }) => {
await mount(`Click Me `);
// Permit differences up to 5 pixels total
await percySnapshot(page, 'button-primary', {
widths: [1280],
minHeight: 100,
// tolerance: 0-255 grayscale scale (default 0 = exact)
// Note: use only if necessary to avoid masking real bugs
});
});
To mask areas with dynamic content (timestamps, random numbers):
test('card with dynamic timestamp', async ({ mount, page }) => {
await mount(`
Last updated:
`);
// Mask the timestamp element before snapshotting
await page.locator('.timestamp').evaluate(el => {
el.textContent = '[MASKED]';
});
await percySnapshot(page, 'card-with-timestamp');
});
5. Responsive Testing: Mobile, Tablet, Desktop
Percy automatically captures snapshots at multiple viewports (defined in .percy.yml). But you can also control the viewport manually in your test:
import { test, expect } from '@playwright/experimental-ct-angular';
import { percySnapshot } from '@percy/playwright';
test.describe('Navbar - Responsive Design', () => {
// Desktop (1280px)
test('navbar desktop layout', async ({ mount, page }) => {
await page.setViewportSize({ width: 1280, height: 720 });
await mount(` `);
await percySnapshot(page, 'navbar-desktop');
});
// Tablet (768px)
test('navbar tablet layout', async ({ mount, page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
await mount(` `);
await percySnapshot(page, 'navbar-tablet');
});
// Mobile (375px)
test('navbar mobile layout (hamburger menu)', async ({ mount, page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await mount(` `);
// On mobile, hamburger menu must be visible
const hamburgerMenu = page.locator('[aria-label="Toggle menu"]');
await expect(hamburgerMenu).toBeVisible();
await percySnapshot(page, 'navbar-mobile');
});
// Mobile with menu open
test('navbar mobile with menu open', async ({ mount, page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await mount(` `);
const hamburgerMenu = page.locator('[aria-label="Toggle menu"]');
await hamburgerMenu.click();
// Menu must become visible
const navMenu = page.locator('nav[role="navigation"]');
await expect(navMenu).toBeVisible();
await percySnapshot(page, 'navbar-mobile-menu-open');
});
});
6. Testing Interactive States (Hover, Focus, Disabled)
Components often have different visual states: hover, focus, disabled, loading. Percy lets you snapshot each state:
import { test } from '@playwright/experimental-ct-angular';
import { percySnapshot } from '@percy/playwright';
test.describe('Button Interactive States', () => {
test('button default state', async ({ mount, page }) => {
await mount(`Click Me `);
await percySnapshot(page, 'button-default');
});
test('button hover state', async ({ mount, page }) => {
await mount(`Click Me `);
const button = page.locator('button');
await button.hover();
await percySnapshot(page, 'button-hover');
});
test('button focus state', async ({ mount, page }) => {
await mount(`Click Me `);
const button = page.locator('button');
await button.focus();
await percySnapshot(page, 'button-focus');
});
test('button active/pressed state', async ({ mount, page }) => {
await mount(`Click Me `);
const button = page.locator('button');
// Simulate press with keyboard
await button.focus();
await page.keyboard.press('Space');
await percySnapshot(page, 'button-active');
});
test('button disabled state', async ({ mount, page }) => {
await mount(`Disabled `);
const button = page.locator('button');
await expect(button).toBeDisabled();
await percySnapshot(page, 'button-disabled');
});
test('button loading state', async ({ mount, page }) => {
await mount(`Please Wait... `);
await percySnapshot(page, 'button-loading');
});
});
7. CI/CD Integration (GitHub Actions, GitLab CI)
Visual regression testing is most powerful when automated in CI/CD. Every PR triggers the tests, and Percy generates an automatic comment with diffs.
7.1 GitHub Actions Workflow
name: Visual Regression Tests
on:
pull_request:
branches: [main, develop]
push:
branches: [main]
jobs:
visual-regression:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run component tests with Percy
run: npx percy exec -- npm run test:ct
env:
PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 30
Add your Percy token as a secret on GitHub (Settings → Secrets → New repository secret).
7.2 Blocking PRs if Regression Fails
Percy automatically creates a "check" on your PR that passes or fails based on tests. To block merging when there are unapproved differences:
1. Go to Settings → Branches → Add rule
2. Branch name pattern: main
3. Check "Require status checks to pass"
4. Add Percy's check as required
(percyio/percy-check or similar, depends on your setup)
8. Best Practices and Performance Optimizations
8.1 Organizing Tests by Category
Structure your tests in files organized by component type:
tests/
components/
button/
button.spec.ts # Base snapshots
button-states.spec.ts # Hover, focus, disabled
button-responsive.spec.ts # Mobile, tablet, desktop
card/
card.spec.ts
card-responsive.spec.ts
navbar/
navbar.spec.ts
navbar-states.spec.ts
navbar-responsive.spec.ts
visual/
visual-regression.config.ts # Shared configurations
8.2 Timeout and Parallelization Configuration
Visual tests can be slow if you have many viewports. Use parallelization:
import { defineConfig, devices } from '@playwright/experimental-ct-angular';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
workers: 4, // Run 4 tests in parallel
timeout: 30_000, // 30 seconds per test (snappy!)
webServer: {
command: 'npm run dev',
port: 4200,
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
9. Debugging and Troubleshooting
9.1 Legitimate Differences vs. Bugs
When a visual test fails, you must determine if it's a bug or intentional modification. Percy shows the diff side-by-side to help:
- Layout shift: an element moved 2px? Could be a broken media query.
- Color change: button was red, now blue? Someone modified the CSS.
- Element disappears: an icon doesn't show? Accidental display:none?
- Font rendering: text looks slightly different? Anti-aliasing — ignore if less than 1px.
9.2 False Positives: Masking Noise
If the test fails constantly for mundane reasons (timestamps, random numbers, generated IDs), mask that element before snapshotting.
10. Frequently Asked Questions (FAQ)
How many snapshots can I take before tests become slow?
With parallelization (4 workers), you can handle 50-100 snapshots per suite without significant degradation. Beyond that, split into multiple test files. Percy loads quickly; the bottleneck is browser rendering.
What if Percy is down? Will tests fail?
No. If Percy is unreachable, the test continues normally but without capturing the snapshot (usually with a warning in console). The test won't fail. In CI, you can configure automatic retries.
Is Percy free?
Percy offers a free plan for 5000 snapshots per month (more than enough for small teams). Beyond that limit, there are paid plans. Evaluate if Percy's cost is worth it for your team, or if open-source alternatives (BackstopJS, Pixelmatch) are sufficient.
How do I keep snapshots updated if I change the theme?
Approve all new screenshots on Percy when you make the theme change. Then run tests on a dedicated branch to capture the new snapshots, approve them, and merge to main. This creates an audit trail of all approved visual changes.
Can I use visual regression testing for e2e beyond component testing?
Yes. Use `percySnapshot()` in any Playwright test (component, integration, e2e). Capture full-page screenshots, sections, or specific components. It's particularly useful for verifying complete flows (checkout, onboarding) where visual appearance matters.

Commenti
Caricamento commenti...
Sign in to comment