Percy Visual Testing: CI Integration and Cross-Browser Screenshots

Percy Visual Testing: CI Integration and Cross-Browser Screenshots

Percy by BrowserStack is a visual testing platform that renders your application in real browsers across multiple viewports and compares screenshots against a baseline. Unlike local snapshot testing, Percy handles cross-browser rendering differences, stores all snapshot history in the cloud, and integrates with pull request workflows through GitHub, GitLab, and Bitbucket status checks.

This guide covers Percy setup, SDK integration, navigating the diff review UI, and configuring CI pipelines.

How Percy Works

Percy sits between your test runner and your review workflow. When your tests run, the Percy SDK captures DOM snapshots (the full HTML and CSS at a point in time) and sends them to Percy's servers. Percy then renders those snapshots in real browsers — Chrome, Firefox, Edge, and Safari — and runs pixel-level diffs against the accepted baseline.

This architecture has a key advantage: the rendering happens in Percy's cloud, so your tests don't need to be run in a specific browser to get cross-browser visual coverage. You capture once, Percy renders everywhere.

Installing the Percy SDK

Percy has SDKs for most popular testing frameworks. For Playwright:

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

For Cypress:

npm install --save-dev @percy/cli @percy/cypress

Set your Percy token as an environment variable. Get the token from the Percy dashboard after creating a project:

export PERCY_TOKEN=your-token-here

Taking Percy Snapshots with Playwright

Import the Percy snapshot function and call it at the moment you want to capture:

// tests/visual/checkout.spec.ts
import { test, expect } from '@playwright/test';
import percySnapshot from '@percy/playwright';

test('checkout flow visual states', async ({ page }) => {
  await page.goto('/checkout');
  
  // Capture the empty cart state
  await percySnapshot(page, 'Checkout - Empty Cart');
  
  // Add items and capture the filled state
  await page.click('[data-testid="add-to-cart"]');
  await page.waitForSelector('.cart-item');
  await percySnapshot(page, 'Checkout - With Items');
  
  // Proceed to payment
  await page.click('[data-testid="proceed-to-payment"]');
  await page.waitForSelector('.payment-form');
  await percySnapshot(page, 'Checkout - Payment Form');
});

Percy snapshots are named strings — these names appear in the Percy UI and become the baseline identifier. Consistent naming is important: if you rename a snapshot, Percy treats it as a new snapshot with no baseline.

Configuring Viewports and Browsers

Percy renders each snapshot at multiple widths by default. Configure this in .percy.yml at your project root:

# .percy.yml
version: 2
snapshot:
  widths:
    - 375    # Mobile
    - 768    # Tablet
    - 1280   # Desktop
    - 1920   # Wide desktop
  min-height: 1024
  enable-javascript: true
  percy-css: |
    /* Disable animations globally */
    *, *::before, *::after {
      animation: none !important;
      transition: none !important;
    }
    /* Hide known flaky elements */
    .timestamp, .live-counter {
      visibility: hidden;
    }

discovery:
  allowed-hostnames:
    - localhost
    - staging.example.com

The percy-css block is injected into every snapshot before rendering — this is the cleanest way to suppress animations and hide dynamic content without modifying your application code.

For browser-specific configuration, you can specify which browsers to run in the Percy project settings (available on paid plans).

The Percy Diff Review UI

When Percy detects changes, it posts a status check to your pull request. The Percy UI shows you:

  • Snapshot count — how many snapshots changed vs unchanged
  • Diff view — side-by-side baseline vs new, with changed pixels highlighted
  • Width selector — review the same snapshot across all configured viewports
  • Browser selector — compare rendering differences between Chrome and Firefox

For each changed snapshot, you can:

  • Approve — mark as intentional. The snapshot becomes the new baseline.
  • Request changes — mark as a regression. The PR check stays red.

Percy tracks approval state per user — in team environments, you can see who approved what and when. You can also configure required approvals before a build is considered passing.

Bulk operations speed up large design system updates: select all changed snapshots and approve in one click after a global design token change.

Branch Comparisons and Baseline Management

Percy's baseline strategy is branch-aware. Each branch maintains its own baseline, inherited from the branch it was created from. When you merge to main, the baseline on main is updated with any approved changes from the PR.

This means a feature branch never pollutes the main baseline with unapproved changes. If a developer approves a visual change on their feature branch but the PR is never merged, main's baseline is unaffected.

For long-lived branches, Percy detects the "merge base" — the last common commit between your branch and the target branch — and uses that as the comparison point. This avoids false positives from changes that landed on main after your branch was created.

GitHub Actions CI Integration

# .github/workflows/percy.yml
name: Percy Visual Tests

on:
  pull_request:
  push:
    branches: [main]

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

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

      - name: Install dependencies
        run: npm ci

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

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

The npx percy exec -- wrapper handles Percy build lifecycle: it creates a Percy build before your tests run and finalizes it afterward. Without this wrapper, Percy won't know when to stop accepting snapshots and run the comparison.

GitLab CI Integration

# .gitlab-ci.yml
percy-visual-tests:
  image: mcr.microsoft.com/playwright:v1.44.0-jammy
  stage: test
  variables:
    PERCY_TOKEN: $PERCY_TOKEN
  script:
    - npm ci
    - npx percy exec -- npx playwright test tests/visual/
  artifacts:
    when: on_failure
    paths:
      - test-results/
    expire_in: 1 week
  only:
    - merge_requests
    - main

Percy automatically detects GitLab CI environment variables and links the Percy build to the merge request, posting a status check with a link to the diff review.

Handling Flaky Snapshots in CI

Network-dependent content — if your page loads third-party widgets or ads, they introduce rendering inconsistencies. Block them at the network level:

test.beforeEach(async ({ page }) => {
  // Block third-party scripts before navigation
  await page.route('**/analytics.js', route => route.abort());
  await page.route('**/ads/**', route => route.abort());
});

Fonts not loading — Percy renders in its cloud environment; custom fonts may not be available unless served from your application. Include fonts in your application bundle rather than loading them from Google Fonts or similar CDNs, or use @font-face with font-display: block to prevent invisible text during rendering.

State-dependent pages — pages that require authentication or specific data state need setup before snapshotting:

test('authenticated dashboard', async ({ page }) => {
  // Set auth cookie directly instead of going through login UI
  await page.context().addCookies([{
    name: 'session',
    value: process.env.TEST_SESSION_TOKEN!,
    domain: 'localhost',
    path: '/',
  }]);
  
  await page.goto('/dashboard');
  await page.waitForLoadState('networkidle');
  await percySnapshot(page, 'Dashboard - Authenticated');
});

Percy's cloud rendering model and branch-aware baseline management make it a strong choice for teams that need cross-browser visual coverage and a collaborative review workflow. The trade-off is that snapshots go to an external service rather than living in your repository — which simplifies the CI setup but adds a service dependency.

Read more

Start now free