Percy Visual Testing with Cypress and Playwright: Complete Tutorial
Percy is a visual testing platform from BrowserStack that integrates directly with Cypress and Playwright. You add a single command to your tests; Percy captures screenshots, compares them against approved baselines, and flags visual differences in a pull request review workflow.
This tutorial covers setup for both Cypress and Playwright, baseline management, the Percy review workflow, and GitHub Actions integration.
How Percy Works
Percy operates as a cloud service. Your test runner captures DOM snapshots (not raw screenshots) and sends them to Percy's servers. Percy renders them server-side across multiple browsers and screen sizes, then compares the renders against the approved baseline.
The key insight: Percy doesn't just diff pixel images — it renders the DOM fresh each time. This makes it more reliable across browsers and less sensitive to anti-aliasing differences.
When diffs are found, Percy creates a visual review dashboard where your team approves or rejects changes. Approved changes become the new baseline.
Setup with Cypress
Install
npm install --save-dev @percy/cli @percy/cypressConfigure Percy token
Get your project token from the Percy dashboard and add it to your environment:
export PERCY_TOKEN=your_percy_token_hereFor CI, add it as a secret. Never commit the token.
Add Percy to your Cypress support file
// cypress/support/e2e.js
import '@percy/cypress';Capture snapshots in tests
// cypress/e2e/homepage.cy.js
describe('Homepage Visual Tests', () => {
beforeEach(() => {
cy.visit('/');
});
it('renders the hero section correctly', () => {
cy.get('.hero').should('be.visible');
cy.percySnapshot('Homepage - Hero Section');
});
it('renders the product grid', () => {
cy.get('.product-grid').should('be.visible');
cy.percySnapshot('Homepage - Product Grid', {
widths: [375, 768, 1280] // capture at multiple widths
});
});
it('renders the navigation menu open state', () => {
cy.get('.hamburger-btn').click();
cy.get('.nav-menu').should('be.visible');
cy.percySnapshot('Navigation - Mobile Menu Open');
});
});Run with Percy
# Run Cypress with Percy
npx percy <span class="hljs-built_in">exec -- cypress run
<span class="hljs-comment"># Run specific spec
npx percy <span class="hljs-built_in">exec -- cypress run --spec <span class="hljs-string">"cypress/e2e/homepage.cy.js"Percy only captures snapshots when you run through percy exec. Running cypress run directly bypasses Percy entirely.
Setup with Playwright
Install
npm install --save-dev @percy/cli @percy/playwrightConfigure in your test
// tests/visual/homepage.spec.js
import { test, expect } from '@playwright/test';
import percySnapshot from '@percy/playwright';
test.describe('Homepage Visual Tests', () => {
test('hero section renders correctly', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.hero');
await percySnapshot(page, 'Homepage - Hero Section');
});
test('product grid renders at multiple viewports', async ({ page }) => {
await page.goto('/products');
await page.waitForSelector('.product-grid');
await percySnapshot(page, 'Products - Desktop', {
widths: [1280]
});
await page.setViewportSize({ width: 375, height: 812 });
await percySnapshot(page, 'Products - Mobile');
});
test('checkout form renders correctly', async ({ page }) => {
await page.goto('/checkout');
await page.waitForLoadState('networkidle');
await percySnapshot(page, 'Checkout Form');
});
});Run with Percy
npx percy exec -- playwright <span class="hljs-built_in">test tests/visual/Snapshot Naming Strategy
Snapshot names become your visual changelog. Name them precisely:
// Bad — hard to trace in the review dashboard
cy.percySnapshot('Test 1');
cy.percySnapshot('homepage');
// Good — describes what's being captured and why
cy.percySnapshot('Checkout - Step 2: Shipping Address (empty state)');
cy.percySnapshot('Checkout - Step 2: Shipping Address (validation errors)');
cy.percySnapshot('Dashboard - Pro plan: weekly chart');
cy.percySnapshot('Dashboard - Free plan: upgrade prompt');Include the component, state, and any relevant variant in the name. You'll thank yourself when reviewing 50 snapshots in a PR.
Baseline Management
The first time you run Percy on a new snapshot name, it creates a baseline without requiring approval. All subsequent runs are compared to that baseline.
Approving changes
When a visual diff appears:
- Percy creates a review build on its dashboard
- Team members review each diff and click "Approve" or "Request changes"
- Once all snapshots are approved, the Percy CI check passes
- The approved screenshots become the new baseline
Auto-approve non-visual changes
Configure Percy to ignore specific CSS properties that change legitimately:
# .percy.yml
version: 2
snapshot:
percyCSS: |
* { animation-duration: 0s !important; transition-duration: 0s !important; }
.dynamic-timestamp { visibility: hidden; }
.user-avatar { filter: blur(5px); }Branch baselines
Percy uses your git branch to determine which baseline to compare against. Feature branches compare against the main branch baseline. This means visual changes in a PR are clearly scoped to that PR's changes.
Handling Dynamic Content
Dynamic content (dates, user names, ads, animations) causes false positives. Handle it before capturing:
// Freeze animations
await page.addStyleTag({
content: `
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
}
`
});
// Replace dynamic content
await page.evaluate(() => {
// Mask timestamps
document.querySelectorAll('.timestamp').forEach(el => {
el.textContent = '2026-01-01 00:00:00';
});
// Mask user-specific data
document.querySelectorAll('.user-email').forEach(el => {
el.textContent = 'user@example.com';
});
});
await percySnapshot(page, 'Dashboard - Stable State');Percy also provides a percyCSS option to inject CSS that hides or stabilises elements before capture.
GitHub Actions Integration
name: Visual Tests
on: [push, pull_request]
jobs:
visual-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install chromium
- name: Run Percy visual tests
run: npx percy exec -- playwright test tests/visual/
env:
PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
# Percy automatically posts status to GitHub PRs
# when PERCY_TOKEN is configuredPercy integrates with GitHub's commit status API — a PR that has unapproved visual diffs will show a failing Percy check, blocking merge until the changes are reviewed.
Configuring Percy
# .percy.yml
version: 2
# Browsers to render in
browsers:
- chrome
- firefox
# Default snapshot widths
snapshot:
widths: [375, 1280]
minHeight: 600
# Ignore specific elements
snapshot:
percyCSS: |
iframe { display: none !important; }
.ad-container { visibility: hidden; }Percy Limits and Pricing
Percy's free tier includes 5,000 screenshots per month. Each snapshot at each width counts as one screenshot.
To optimise usage:
- Only run Percy snapshots on your visual test suite, not in every test
- Use
--includepatterns to run visual tests separately from functional tests - Capture only the components that matter, not full-page screenshots of every page
What Percy Catches (and Doesn't)
Percy catches:
- Layout shifts (elements moving unexpectedly)
- Color changes
- Font changes or missing web fonts
- Image failures
- CSS regression from global style changes
- Responsive layout breakage
Percy doesn't catch:
- Functional bugs (wrong data, broken interactions)
- Performance regressions
- Accessibility issues
- SEO changes
Percy is a visual layer on top of your functional test suite, not a replacement for it.
Next Steps
With Percy running:
- Add visual tests to your PR template checklist: "Percy review approved"
- Configure percy CLI for storybook integration:
percy storybook ./storybook-static - Use Percy's API to programmatically approve snapshots in your CD pipeline for specific low-risk changes