Quality Engineering
min read
Last update on

Basic guide: visual testing with Percy and Cypress

Basic guide: visual testing with Percy and Cypress
Table of contents

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:

Project 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.

Written by
Editor
Ananya Rakhecha
Tech Advocate