Skip to main content

Visual Regression Testing with Playwright + Percy

Automate visual testing of your components with Percy and Playwright. Learn to capture snapshots, manage baselines, and integrate visual regression into CI/CD.

What do you think about this article?

Visual Regression Testing with Playwright + Percy
Playwright ComponentsPhotos:Brett SaylesPexels

At a Glance

Automate visual testing of your components with Percy and Playwright. Learn to capture snapshots, manage baselines, and integrate visual regression into CI/CD.

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:

Shell
npm install --save-dev @percy/cli @percy/playwright

Create a Percy configuration file in your project root:

.percy.yml
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:

Bash (.env.local)
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:

button.spec.ts
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).

Bash
# 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:

Percy Workflow
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.

Pixel Tolerance
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):

Masking Dynamic Areas
test('card with dynamic timestamp', async ({ mount, page }) => {
  await mount(`
    

Last updated: 2026-07-19T10:30:00Z

`); // 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:

test-responsive.spec.ts
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:

interactive-states.spec.ts
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

.github/workflows/visual-regression.yml
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:

branch protection rule (GitHub)
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:

Recommended structure
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:

playwright.config.ts
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

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects