Percy Visual Testing: Catch UI Regressions with Automated Screenshot Comparisons

Percy Visual Testing: Catch UI Regressions with Automated Screenshot Comparisons

Percy is a visual testing platform (now part of BrowserStack) that catches UI regressions through automated screenshot comparisons. Where functional tests check that code does the right thing, Percy checks that the UI looks right.

This guide covers what Percy does, how to integrate it with Playwright, Cypress, and Selenium, and how to build a practical visual regression workflow.

The Problem Percy Solves

Functional tests can miss visual bugs. A test that verifies a checkout button is clickable won't notice if:

  • The button color changed from brand blue to gray
  • The button is hidden behind another element (z-index issue)
  • The layout broke on a specific viewport size
  • A CSS change introduced text overflow clipping
  • A font changed across the entire app

Percy captures screenshots of your UI and compares them against a baseline. Any pixel-level change triggers a review. You approve or reject the diff, and the baseline updates accordingly.

How Percy Works

  1. Your test suite runs and makes Percy snapshot calls at key points
  2. Percy uploads the snapshots to its cloud service
  3. Percy renders each snapshot across configured browsers and viewports
  4. Percy diffs the current run against the approved baseline
  5. If differences are found, the PR/CI build is blocked pending review
  6. Your team reviews diffs, approves changes or reports bugs
  7. Approved diffs become the new baseline

The key insight is that Percy handles the rendering and diffing — you just instrument your existing tests to take snapshots at the right places.

Setting Up Percy

Install the Percy CLI:

npm install -D @percy/cli

Install the framework integration:

# Playwright
npm install -D @percy/playwright

# Cypress
npm install -D @percy/cypress

# Selenium (WebDriver.io)
npm install -D @percy/webdriverio

# Selenium (Python)
pip install percy-selenium

# Selenium (Java)
# Add io.percy:percy-java-selenium to pom.xml

Get your Percy token from the project settings on percy.io and set it as an environment variable:

export PERCY_TOKEN=your_percy_token

Integrating with Playwright

import { test, expect } from '@playwright/test';
import percySnapshot from '@percy/playwright';

test('homepage visual test', async ({ page }) => {
  await page.goto('https://your-app.com');
  
  // Take a Percy snapshot
  await percySnapshot(page, 'Homepage');
});

test('checkout flow', async ({ page }) => {
  await page.goto('https://your-app.com/products');
  await percySnapshot(page, 'Product Listing');
  
  await page.click('[data-testid="add-to-cart"]');
  await page.goto('https://your-app.com/cart');
  await percySnapshot(page, 'Cart with Item');
  
  await page.click('[data-testid="checkout-button"]');
  await percySnapshot(page, 'Checkout Form');
});

Run with Percy:

npx percy exec -- npx playwright test

Integrating with Cypress

// cypress/support/commands.js
import '@percy/cypress';
// cypress/e2e/visual.cy.js
describe('Visual Tests', () => {
  it('homepage looks correct', () => {
    cy.visit('/');
    cy.percySnapshot('Homepage');
  });

  it('navigation menu looks correct', () => {
    cy.visit('/');
    cy.get('.nav-menu').should('be.visible');
    cy.percySnapshot('Nav Menu Open', { widths: [375, 768, 1280] });
  });

  it('product card grid', () => {
    cy.visit('/products');
    cy.get('.product-grid').should('have.length.greaterThan', 0);
    cy.percySnapshot('Product Grid');
  });
});

Run:

npx percy exec -- npx cypress run

Integrating with Selenium (Python)

from percy import percy_snapshot

def test_homepage(driver):
    driver.get("https://your-app.com")
    percy_snapshot(driver, "Homepage")

def test_checkout(driver):
    driver.get("https://your-app.com/checkout")
    
    # Fill form
    driver.find_element(By.ID, "email").send_keys("test@example.com")
    percy_snapshot(driver, "Checkout - Email Filled")
    
    driver.find_element(By.ID, "card-number").send_keys("4111111111111111")
    percy_snapshot(driver, "Checkout - Payment Details")

Run:

npx percy exec -- pytest tests/visual/

Understanding Diffs

When Percy detects changes, the diff view shows:

  • Green pixels: Unchanged areas
  • Red pixels: Pixels that changed (the "diff")
  • Yellow overlay: The bounding box of changed regions

Percy uses perceptual diffing — it's smarter than a pure pixel comparison. It accounts for:

  • Anti-aliasing differences: Slight rendering variations between browsers don't trigger false positives
  • Layout shifts: Percy can detect when elements moved, not just when pixels changed

Reviewing Diffs

The review workflow:

  1. Open the Percy build from your PR check
  2. Review each snapshot diff
  3. For each diff:
    • Approve: This change is intentional (a new feature, a design update)
    • Request changes: This is a bug that needs to be fixed

Approved snapshots become the new baseline. Rejected snapshots keep the CI check failing until the underlying code change is reverted or the test is re-run after a fix.

Who Should Review?

Designers and product managers can review Percy diffs — you don't need to be an engineer to look at screenshots and say "that text is cut off" or "that button moved." This shifts visual verification from a developer task to a cross-functional one.

Responsive Testing

Test multiple viewport sizes in a single snapshot:

// Playwright
await percySnapshot(page, 'Product Page', {
  widths: [375, 768, 1024, 1440]
});
// Cypress
cy.percySnapshot('Product Page', { widths: [375, 768, 1440] });

Percy renders the snapshot at each width and shows diffs for each viewport separately. A CSS change that only breaks mobile layout will show a diff only at 375px, not at 1440px.

Ignoring Regions

Dynamic content (timestamps, ads, animations) creates false positives. Ignore specific regions:

// Playwright — ignore dynamic content
await percySnapshot(page, 'Dashboard', {
  percyCSS: `
    .timestamp { visibility: hidden !important; }
    .advertisement { visibility: hidden !important; }
    .loading-animation { visibility: hidden !important; }
  `
});
// Cypress
cy.percySnapshot('Dashboard', {
  percyCSS: '.timestamp, .ad-container { visibility: hidden !important; }'
});

percyCSS injects CSS only for the Percy snapshot capture, not for the actual test. The elements are hidden in the screenshot but still present in the DOM for functional testing.

Freezing Dynamic Content

Some dynamic content can be frozen at test time:

// Freeze animations
await page.evaluate(() => {
  document.getAnimations().forEach(a => a.pause());
});
await percySnapshot(page, 'Animated Component');

// Set a fixed date for timestamp-based content
await page.evaluate(() => {
  const fakeDate = new Date('2025-01-01T12:00:00Z');
  Date.now = () => fakeDate.getTime();
});

CI/CD Integration

GitHub Actions:

name: Visual Tests

on:
  pull_request:

jobs:
  visual-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install chromium

      - name: Run Percy visual tests
        run: npx percy exec -- npx playwright test tests/visual/
        env:
          PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}

Percy automatically detects the GitHub commit SHA and branch, linking the build to the PR. The PR shows a Percy check with a link to the visual diff review.

What to Snapshot

Strategic snapshot placement matters. Too many snapshots slow down review; too few miss real issues.

Good snapshot candidates:

  • Key page states: empty, loading, filled, error
  • Interactive component states: dropdown open, modal visible, form validation errors
  • Responsive breakpoints for layout-heavy pages
  • Data-dense displays: tables, dashboards, lists

Poor snapshot candidates:

  • Pages with mostly dynamic content that requires extensive percyCSS exclusions
  • Intermediate states during animations
  • Error pages that rarely change

A practical rule: snapshot the pages/states that designers would care about if they changed unexpectedly.

Percy vs. DIY Screenshot Comparisons

Teams sometimes build their own visual regression using Playwright's built-in toHaveScreenshot(). The difference:

Percy Playwright toHaveScreenshot
Cross-browser rendering Renders in multiple browsers in cloud Uses local browser
Review workflow Visual diff review UI, approve/reject Update snapshot files in git
CI integration Native GitHub/GitLab PR checks Pass/fail in CI
False positive handling Perceptual diff, ignore regions Threshold settings
Cost Paid service Free (infra cost only)

For teams with a few hundred snapshots and a clear review process, Playwright's built-in screenshots are a reasonable free alternative. For larger projects with cross-browser visual requirements and a need for a non-developer review workflow, Percy is worth the cost.

Summary

Percy makes visual regression testing practical. The key is treating visual changes like code changes: every PR that touches UI runs Percy, differences require review, and approvals update the baseline. This makes visual regressions visible before they reach users, without requiring manual visual QA on every PR.

The integration is lightweight — instrument your existing Playwright or Cypress tests with snapshot calls, run them through the Percy CLI, and you have a visual testing pipeline. The review UI is intuitive enough for designers and product managers to participate without engineering involvement.

Read more

Start now free