After more than a decade in QA architecture, I’ve seen firsthand how critical visual testing is to delivering high-quality web applications. In this blog, I’ll walk you through how to integrate Percy with Cypress, covering everything from the initial setup to advanced configuration
Why Percy with Cypress?
Visual testing ensures your UI remains pixel-perfect across different environments. Percy with Cypress offers:
- Automated visual regression detection
- Cross-browser compatibility checks
- Responsive design validation
- CI/CD integration
- Version control for UI changes
Getting started
Step 1: Project setup
Create and navigate to the project directory
Commands
mkdir visual-testing-project
cd visual-testing-project
Initialise npm project
Commands
npm init -y
Install required dependencies
Commands
npm install cypress --save-dev
npm install @percy/cli @percy/cypress --save-dev
npm install cypress-mochawesome-reporter --save-dev cypress-xpath --save-dev
Step 2: Project structure
Create this folder structure:

Step 3: Configuration Files
Syntax:
package.json
{
"name": "visual-testing-project",
"version": "1.0.0",
"description": "Visual Testing with Percy and Cypress",
"scripts": {
"cypress:open": "cypress open",
"percy:uat": "npx percy exec -- cypress run --config specPattern='cypress/e2e/percy/**.cy.js' --spec cypress/e2e/percy/*.cy.js --env testenv=uat",
"percy:prod": "npx percy exec -- cypress run --config specPattern='cypress/e2e/percy/**.cy.js' --spec cypress/e2e/percy/*.cy.js --env testenv=prod",
"test:e2e": "npx cypress run --spec cypress/e2e/*.cy.{js,jsx,ts,tsx}"
},
"devDependencies": {
"@percy/cli": "^1.29.3",
"@percy/cypress": "^3.1.3-beta.0",
"cypress": "^13.8.0",
"cypress-mochawesome-reporter": "^3.8.2",
"cypress-xpath": "^1.6.2",
"mochawesome": "^7.1.3",
"mochawesome-merge": "^4.3.0",
"mochawesome-report-generator": "^6.2.0"
}
}
File : Cypress.cofig.js
cypress.config.js
const { defineConfig } = require('cypress')
module.exports = defineConfig({
e2e: {
baseUrl: 'https://your-website.com',
reporter: 'cypress-mochawesome-reporter',
reporterOptions: {
charts: true,
reportDir: 'cypress/reports/mochawesome',
overwrite: false,
html: true,
json: true
},
env: {
testenv: 'uat'
}
}
})
Writing Your first test
Basic test structure
Here's a concise explanation of the test structure:
The visual test for the homepage is structured in a clear, hierarchical manner. Starting with a `describe` block that defines our test suite, we first ensure a clean testing environment using `beforeEach` to clear browser data. Within the test case (`it` block), we follow a logical sequence: first navigating to the page, then waiting for content to load, and finally capturing visual snapshots at different viewport sizes. This approach ensures consistent and reliable visual testing by addressing common challenges like page load timing and responsive design verification.
For example, when we write:
```javascript
cy.percySnapshot('Homepage', {
widths: [375, 768, 1280]
});
```
This single command captures the page's appearance across mobile, tablet, and desktop views, enabling us to verify our responsive design with one test. By using meaningful snapshot names and strategic viewport sizes, we create maintainable tests that provide valuable visual regression coverage.
Create file: cypress/e2e/percy/homepage.cy.js
Syntax:
describe('Homepage Visual Test', () => {
beforeEach(() => {
cy.clearLocalStorage();
cy.clearCookies();
});
it('should match homepage design', () => {
// Visit page
cy.visit('/');
// Wait for content
cy.get('main').should('be.visible');
// Take screenshot
cy.percySnapshot('Homepage', {
widths: [375, 768, 1280]
});
});
});
Multi-environment testing
Environment configuration
Create file: cypress/fixtures/siteURLs.json
{
"uat": {
"en": "https://uat.example.com",
"es": "https://uat.example.com/es"
},
"prod": {
"en": "https://example.com",
"es": "https://example.com/es"
}
}
Page Configuration
Create file: cypress/fixtures/pageURLs.json
[
{
"en": [
{
"page": "Home",
"path": "/"
},
{
"page": "Products",
"path": "/products"
}
]
}
]
Multi-Environment Test
const siteURLs = require('../../fixtures/siteURLs.json');
const pageURLs = require('../../fixtures/pageURLs.json');
describe('Multi-Environment Visual Tests', () => {
const siteNames = Object.keys(siteURLs[Cypress.env('testenv')]);
beforeEach(() => {
cy.clearLocalStorage();
cy.clearCookies();
});
siteNames.forEach(siteName => {
describe(`Testing ${siteName}`, () => {
const baseUrl = siteURLs[Cypress.env('testenv')][siteName];
const site = pageURLs.find(site => site.hasOwnProperty(siteName));
const paths = site ? site[siteName].map(page => page.path) : [];
paths.forEach(path => {
it(`Validates ${path}`, () => {
const url = baseUrl + path;
// Handle authentication if needed
cy.visit(url, {
auth: {
username: Cypress.env('AUTH_USER'),
password: Cypress.env('AUTH_PASS')
},
failOnStatusCode: false
});
// Handle lazy loading
cy.scrollTo('bottom', { duration: 1000 });
cy.wait(500);
// Take snapshot
cy.percySnapshot(`${siteName}-${path}`, {
widths: [320, 768, 1440]
});
});
});
});
});
});
Advanced configuration
Custom commands
Create file: cypress/support/commands.js
Syntax:
Cypress.Commands.add('waitForContent', () => {
cy.get('.loading-spinner').should('not.exist');
cy.wait(1000);
});
Cypress.Commands.add('handleDynamicContent', () => {
cy.intercept('GET', '/api/dynamic-data', {
fixture: 'stable-data.json'
});
});
Percy configuration options
cy.percySnapshot('Custom Name', {
widths: [375, 768, 1280], // Device sizes
minHeight: 1000, // Minimum height
percyCSS: '.dynamic-content { display: none }', // Hide dynamic elements
scope: '.main-content', // Specific element
enableJavaScript: true // Handle dynamic content
});
Reports and screenshots
Percy screenshots
- Stored in Percy cloud (percy.io)
- Accessible via the Percy dashboard
- Organised by builds and snapshots
Local reports
- Location: cypress/reports/mochawesome/
- Contains:
- HTML reports
- JSON reports
- Screenshots of failures
- Test execution logs
Best practices
1. Environment variables
// Use Cypress.env() for dynamic configuration
const environment = Cypress.env('testenv');
const baseUrl = Cypress.env('baseUrl');
2. Authentication
// Handle basic auth
cy.visit(url, {
auth: {
username: Cypress.env('AUTH_USER'),
password: Cypress.env('AUTH_PASS')
}
});
3. Lazy loading
// Ensure content is loaded
cy.scrollTo('bottom', { duration: 1000 });
cy.wait(500);
cy.percySnapshot('Full Page View');
4. Dynamic content
// Hide dynamic elements
cy.percySnapshot('Dynamic Page', {
percyCSS: `
.timestamp { display: none }
.user-specific { opacity: 0 }
`
});
Troubleshooting guide
Common issues
1. Missing screenshots
// Add explicit waits
cy.get('main').should('be.visible');
cy.waitForContent();
cy.percySnapshot();
2. Authentication failed
// Add retry mechanism
Cypress.Commands.add('loginWithRetry', () => {
cy.session('login', () => {
cy.visit('/login');
cy.get('#username').type(Cypress.env('AUTH_USER'));
cy.get('#password').type(Cypress.env('AUTH_PASS'));
cy.get('form').submit();
});
});
3. Dynamic content issues
// Stabilize dynamic content
cy.intercept('GET', '/api/dynamic-data', {
fixture: 'stable-data.json'
}).as('getData');
cy.wait('@getData');
Remember:
- Start with critical user paths
- Use meaningful snapshot names
- Test across multiple viewports
- Handle dynamic content appropriately
- Maintain clean, reusable test code
Percy-Cypress component guide
1. Percy configuration options explained
Syntax:
cy.percySnapshot('Custom Name', {
widths: [375, 768, 1280], // Capture at specific device widths
minHeight: 1000, // Set minimum page height for snapshot
percyCSS: '.dynamic-content { display: none }', // Hide dynamic elements
scope: '.main-content', // Only capture specific element
enableJavaScript: true // Enable JavaScript execution during capture
})
Key options:
- widths: Array of viewport widths to test (mobile, tablet, desktop)
- minHeight: Forces minimum page height, useful for infinite scroll pages
- percyCSS: Inject CSS to modify page appearance during capture
- scope: Limit screenshot to specific element (useful for component testing)
- enableJavaScript: Allow JavaScript execution during snapshot
2. Custom commands explained
Syntax:
// Wait for page stabilization
Cypress.Commands.add('waitForContent', () => {
// Wait for loading spinner to disappear
cy.get('.loading-spinner').should('not.exist');
// Additional delay for stability
cy.wait(1000);
});
// Handle dynamic data
Cypress.Commands.add('handleDynamicContent', () => {
// Intercept API calls and return stable fixture data
cy.intercept('GET', '/api/dynamic-data', {
fixture: 'stable-data.json'
});
});
Use cases:
- waitForContent: Essential for single-page applications
- handleDynamicContent: Stabilizes tests by providing consistent data
3. Multi-environment testing breakdown
Syntax:
const siteURLs = require('../../fixtures/siteURLs.json');
const pageURLs = require('../../fixtures/pageURLs.json');
describe('Multi-Environment Visual Tests', () => {
// Get site names for current environment (uat/prod)
const siteNames = Object.keys(siteURLs[Cypress.env('testenv')]);
beforeEach(() => {
cy.clearLocalStorage();
cy.clearCookies();
});
// Loop through each site (en, es, etc.)
siteNames.forEach(siteName => {
describe(`Testing ${siteName}`, () => {
// Get base URL for current environment and site
const baseUrl = siteURLs[Cypress.env('testenv')][siteName];
// Find corresponding page paths
const site = pageURLs.find(site => site.hasOwnProperty(siteName));
const paths = site ? site[siteName].map(page => page.path) : [];
// Test each page path
paths.forEach(path => {
it(`Validates ${path}`, () => {
const url = baseUrl + path;
// Visit with authentication
cy.visit(url, {
auth: {
username: Cypress.env('AUTH_USER'),
password: Cypress.env('AUTH_PASS')
},
failOnStatusCode: false
});
// Handle lazy loading content
cy.scrollTo('bottom', { duration: 1000 });
cy.wait(500);
// Take snapshot with responsive widths
cy.percySnapshot(`${siteName}-${path}`, {
widths: [320, 768, 1440]
});
});
});
});
});
});
4. Package.json script commands explained
Syntax:
{
"scripts": {
"cypress:open": "cypress open", // Open Cypress Test Runner
"percy:uat": "npx percy exec -- cypress run --config specPattern='cypress/e2e/percy/**.cy.js' --spec cypress/e2e/percy/*.cy.js --env testenv=uat", // Run Percy tests in UAT
"percy:prod": "npx percy exec -- cypress run --config specPattern='cypress/e2e/percy/**.cy.js' --spec cypress/e2e/percy/*.cy.js --env testenv=prod", // Run Percy tests in Production
"test:e2e": "npx cypress run --spec cypress/e2e/*.cy.{js,jsx,ts,tsx}" // Run all e2e tests
}
}
5. CI/CD integration
Syntax:
name: Visual Testing
on: [push]
jobs:
visual-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Install Dependencies
run: npm ci
- name: Percy Test
run: npm run percy:uat
env:
PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
AUTH_USER: ${{ secrets.AUTH_USER }}
AUTH_PASS: ${{ secrets.AUTH_PASS }}
6. Dynamic data management
Syntax:
// 1. Using Fixtures
beforeEach(() => {
cy.fixture('stable-data.json').as('testData');
cy.intercept('GET', '/api/data', { fixture: 'stable-data.json' });
});
// 2. Using Percy CSS
cy.percySnapshot('Dynamic Content', {
percyCSS: `
.timestamp { display: none }
.user-data { opacity: 0 }
.random-content { visibility: hidden } })
// 3. Using Custom Commands
Cypress.Commands.add('stabilizeData', () => {
cy.intercept('GET', '/api/**', (req) => {
req.reply((res) => {
res.body = require('../fixtures/stable-response.json');
});
});
});
7. Test maintenance tips
When selecting elements in Cypress tests, using dynamic selectors is crucial for test maintenance and reliability. Consider this approach:
// Bad practice
cy.get('#specific-id-123').click(); // Hard-coded IDs can change during development
// Good practice
cy.get('[data-testid="login-button"]').click(); // Data attributes stay consistent
This pattern creates resilient tests because data-testid attributes are specifically designed for testing and are less likely to change during UI updates or refactoring. Think of it like having a dedicated handle for testing purposes, rather than relying on IDs that might change for styling or JavaScript functionality.
1. Use dynamic selectors
When selecting elements in Cypress tests, using dynamic selectors is crucial for test maintenance and reliability. Consider this approach:
// Bad Practice
cy.get('#specific-id-123').click(); // Hard-coded IDs can change during development
// Good practice
cy.get('[data-testid="login-button"]').click(); // Data attributes stay consistent
This pattern creates resilient tests because data-testid attributes are specifically designed for testing and are less likely to change during UI updates or refactoring. Think of it like having a dedicated handle for testing purposes, rather than relying on IDs that might change for styling or JavaScript functionality.
2. Handle environment variables
Environment variables allow for flexible configuration across different testing environments.
const config = {
apiUrl: Cypress.env('API_URL'),
timeout: Cypress.env('TIMEOUT', 5000)
};
This setup lets you run the same tests against different environments (development, staging, production) without changing the test code. The config object acts as a central place for environment-specific values, making test maintenance and environment switching straightforward.
3. Create reusable test data
Having a structured approach to test data improves test readability and maintenance:
const testData = {
validUser: {
username: 'testuser',
password: 'testpass'
},
invalidUser: {
username: 'invalid',
password: 'wrong'
}
};
Conclusion
Using Percy with Cypress is a practical and efficient way to automate visual testing and catch UI issues early. It reduces manual effort, helps detect visual bugs before they reach production, and keeps the interface consistent across browsers, devices, and languages. Since it integrates smoothly with most CI/CD pipelines, it supports faster and more reliable releases.
To get the most out of this setup, it's important to follow a few best practices:
- Start with your most critical user flows
- Roll out the implementation gradually
- Use reusable commands and flexible selectors
- Keep test data stable
- Be deliberate about where and when you take snapshots
As applications grow, maintaining visual consistency becomes increasingly complex. This integration offers a clear advantage by surfacing visual changes directly in pull requests, making it easier for teams to catch regressions and collaborate more effectively.
In my view, Percy and Cypress together offer a dependable, scalable solution for teams that care about delivering a high-quality, visually consistent user experience.