How to Do Visual Testing with Cypress
cypress-image-snapshot plugin adds pixel-level screenshot comparison with configurable thresholds, and cloud services like Percy and Applitools provide managed comparison with review dashboards. This guide covers both approaches, from plugin installation to CI integration, so you can add visual regression coverage to your Cypress test suite regardless of which comparison method fits your workflow.
Cypress excels at functional end-to-end testing, but functional assertions like "the button exists" and "the text says 'Success'" do not verify that the page looks correct. A CSS change that shifts every element 50 pixels down passes every functional assertion while delivering a broken user experience. Visual testing fills this gap by comparing screenshots against approved baselines, catching layout regressions, font changes, and styling bugs that no amount of cy.contains() or cy.should('be.visible') calls can detect.
Install cypress-image-snapshot
The cypress-image-snapshot plugin is the most popular open source option for adding visual comparison to Cypress. It wraps jest-image-snapshot under the hood and provides a matchImageSnapshot custom command.
Install the package:
npm install --save-dev @simonsmith/cypress-image-snapshot
Register the plugin in your Cypress config file (cypress.config.ts or cypress.config.js):
import { defineConfig } from 'cypress';
import { addMatchImageSnapshotPlugin } from '@simonsmith/cypress-image-snapshot/plugin';
export default defineConfig({
e2e: {
setupNodeEvents(on, config) {
addMatchImageSnapshotPlugin(on, config);
},
},
});
Add the command to your support file (cypress/support/e2e.ts):
import { addMatchImageSnapshotCommand } from '@simonsmith/cypress-image-snapshot/command';
addMatchImageSnapshotCommand();
The matchImageSnapshot command is now available in all your Cypress tests.
Write Your First Visual Test
Add visual assertions to new or existing Cypress tests by calling cy.matchImageSnapshot() at any point where you want to verify the visual state:
describe('Homepage', () => {
it('should match the visual baseline', () => {
cy.visit('/');
cy.matchImageSnapshot('homepage');
});
});
describe('Pricing Page', () => {
it('should display pricing cards correctly', () => {
cy.visit('/pricing');
cy.matchImageSnapshot('pricing-page');
});
});
The string argument names the baseline file. Choose descriptive names that make it clear which page or component the baseline represents.
You can also capture element-level screenshots by chaining on a Cypress selection:
it('should display the navigation correctly', () => {
cy.visit('/');
cy.get('nav.main-nav').matchImageSnapshot('main-navigation');
});
The first run creates baseline images in a cypress/snapshots directory (configurable). Subsequent runs compare against these baselines and fail if differences exceed the threshold.
Configure Comparison Thresholds
The default threshold is strict enough to catch real regressions but may also flag anti-aliasing differences as failures. Tune the threshold with the failureThreshold and failureThresholdType options:
// Allow up to 0.5% of pixels to differ
cy.matchImageSnapshot('homepage', {
failureThreshold: 0.005,
failureThresholdType: 'percent',
});
// Or allow up to 200 pixels to differ
cy.matchImageSnapshot('homepage', {
failureThreshold: 200,
failureThresholdType: 'pixel',
});
Set global defaults in the addMatchImageSnapshotCommand call in your support file:
addMatchImageSnapshotCommand({
failureThreshold: 0.003,
failureThresholdType: 'percent',
customDiffConfig: { threshold: 0.1 },
});
The customDiffConfig.threshold controls the per-pixel color sensitivity (0 to 1), where 0 requires exact color match and higher values tolerate more color variation. The combination of per-pixel threshold and overall failure threshold gives fine-grained control: the per-pixel threshold filters out anti-aliasing noise at the pixel level, and the overall threshold determines how many remaining different pixels constitute a test failure.
Handle Dynamic Content
Dynamic elements like timestamps, user avatars, live counters, and animations cause false positives because they change between test runs. Handle them before capturing the screenshot.
Hide dynamic elements with CSS:
cy.visit('/dashboard');
// Hide dynamic elements
cy.get('.timestamp').invoke('css', 'visibility', 'hidden');
cy.get('.user-avatar').invoke('css', 'visibility', 'hidden');
cy.matchImageSnapshot('dashboard');
Replace dynamic text with fixed values:
cy.get('.last-updated').invoke('text', 'Updated January 1, 2026');
cy.matchImageSnapshot('dashboard');
Disable animations globally by injecting a style tag in your beforeEach hook:
beforeEach(() => {
cy.visit('/');
cy.document().then(doc => {
const style = doc.createElement('style');
style.textContent = `
*, *::before, *::after {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
`;
doc.head.appendChild(style);
});
});
For data-driven pages, intercept API calls and return fixed responses so the page content is deterministic:
cy.intercept('GET', '/api/stats', { fixture: 'stats.json' });
cy.visit('/dashboard');
cy.matchImageSnapshot('dashboard');
Update Baselines
When you make intentional visual changes, update the baselines by running Cypress with the updateSnapshots environment variable:
npx cypress run --env updateSnapshots=true
This replaces all baseline images with new captures. Review the updated images, then commit them alongside your code changes. Pull request reviewers should see both the code diff and the updated baseline images, confirming that the visual change was intentional.
To update only specific baselines, delete the baseline files you want to regenerate and run the tests normally. The plugin will treat missing baselines as first-run captures and create new baseline files.
Integrate with CI
Consistent rendering is critical in CI. The same page looks different on macOS and Linux because of font rendering and anti-aliasing differences. Run Cypress in a Docker container to ensure the CI environment matches the environment where baselines were generated.
The official Cypress Docker images include all browser dependencies:
# GitHub Actions example
jobs:
visual-tests:
runs-on: ubuntu-latest
container:
image: cypress/included:13.6.0
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx cypress run --spec 'cypress/e2e/visual/**'
- uses: actions/upload-artifact@v4
if: failure()
with:
name: visual-diffs
path: cypress/snapshots/**/__diff_output__/
The upload-artifact step preserves diff images when tests fail, so reviewers can inspect what changed without running the tests locally. Generate baselines inside the same Docker image to ensure consistency:
docker run --rm -v $(pwd):/e2e -w /e2e cypress/included:13.6.0 \
npx cypress run --env updateSnapshots=true
Using Percy with Cypress
For teams that want a cloud-based review dashboard instead of file-based baseline management, Percy integrates with Cypress through an official SDK. Install it with npm install --save-dev @percy/cli @percy/cypress, import it in your support file, and add cy.percySnapshot() calls to your tests:
import '@percy/cypress';
it('homepage visual test', () => {
cy.visit('/');
cy.percySnapshot('Homepage');
});
Run tests with the Percy CLI wrapper: npx percy exec -- cypress run. Percy captures the DOM, renders it across configured browsers and viewports in the cloud, and posts a status check to your pull request. The review happens in Percy's web dashboard rather than in code review diffs, which is better for teams where designers participate in visual review but do not use Git.
Percy also supports responsive testing directly from Cypress. Pass viewport widths to each snapshot call, and Percy renders the page at each width in its cloud without requiring you to resize the Cypress browser:
cy.percySnapshot('Pricing Page', {
widths: [375, 768, 1280],
});
Each width counts as a separate snapshot for billing. A test with 20 pages at 3 widths generates 60 snapshots per build, so plan your viewport strategy to balance coverage against cost. The free tier covers 5,000 snapshots per month, which handles small projects at moderate CI frequency.
Common Pitfalls and Troubleshooting
Cypress visual testing has specific gotchas that trip up new users. Understanding them upfront saves debugging time.
Baseline mismatch across environments. The most frequent problem is baselines captured on macOS failing when compared on Linux CI. Fonts render differently on each platform, producing pixel diffs in every text element. The fix is to generate baselines inside the same Docker container that CI uses. Never generate baselines on your local machine and expect them to match CI output, unless your local machine and CI use the exact same OS, browser version, and font configuration.
Flaky tests from lazy-loaded content. Cypress takes screenshots at the moment you call matchImageSnapshot(). If images, fonts, or API-driven content are still loading at that moment, the screenshot captures an incomplete page. Add explicit waits before the snapshot call. Use cy.get('img').should('be.visible') for critical images, intercept API calls and wait for them to resolve with cy.wait('@apiAlias'), and consider adding a small cy.wait(500) after page load for pages with complex rendering pipelines. These waits add to test time but eliminate the most common source of flakiness.
Viewport sizing inconsistencies. Cypress's cy.viewport() command sets the application viewport, not the browser window size. The Cypress test runner UI takes some window space, so the actual viewport may differ from what you specified when running in headed mode versus headless mode. Always run visual tests in headless mode (cypress run, not cypress open) for consistent viewport sizing, and verify your baselines were captured in headless mode too.
Plugin version compatibility. The cypress-image-snapshot plugin sometimes lags behind Cypress major version updates. Check compatibility when upgrading Cypress, and pin your plugin version in package.json until you have verified that the new combination works. Breaking changes between Cypress and the plugin can cause silent failures where snapshots are captured but comparison is skipped, making it appear that tests pass when they are not actually comparing anything.
Large baseline directories. Screenshot files are typically 100KB to 2MB each. A test suite with 50 pages at 3 viewports generates 150 baseline files that can total 100MB or more. Git handles this, but it slows down clones and increases repository size over time. Consider using Git LFS for baseline images if your repository becomes unwieldy, or storing baselines outside the repository (in cloud storage or as CI artifacts) and fetching them during test runs.
Choosing Between Plugin and Cloud
Use cypress-image-snapshot when you want zero cost, you are comfortable managing baselines in version control, your team reviews visual changes during code review, and you do not need cross-browser rendering (since Cypress runs in one browser at a time).
Use Percy or Applitools when you want a dedicated visual review dashboard, you need cross-browser or cross-device screenshot comparison without managing the infrastructure, you have designers or QA reviewers who should approve visual changes outside of Git, or you need AI-powered comparison to reduce false positive rates on large test suites.
For teams choosing a test framework today with visual testing as a primary requirement, Playwright's built-in screenshot comparison provides a more integrated experience than Cypress plus plugins. For teams already invested in Cypress, the plugin ecosystem provides capable visual testing without switching frameworks.
Adding visual testing to Cypress requires a plugin or cloud service, but the setup is straightforward and provides the same regression-catching capability as any visual testing tool. Use cypress-image-snapshot for self-hosted simplicity or Percy/Applitools for managed comparison with review dashboards.