Cypress Visual Testing Tutorial: Add Screenshot Comparison to Your E2E Tests
Learn how to add visual regression testing to Cypress with Vizzly. Catch CSS bugs and UI changes with automated screenshot comparison in your existing test suite.
You’ve got Cypress tests covering your critical user flows. Login works. Checkout works. Everything passes. Then you ship and realize a CSS refactor broke your header layout in Safari. Or a dependency update changed how buttons render on mobile. The functional assertions passed, but nobody caught the visual regression until production.
This is where Cypress visual testing comes in. By adding screenshot comparison to your existing E2E tests, you catch visual bugs before they reach users.
I’ve been there. Cypress is great at catching logic bugs, but it’s blind to visual changes. You could manually review screenshots after every test run, but let’s be honest - nobody’s doing that consistently. And screenshot diffing tools that bolt onto Cypress feel like a separate workflow you have to remember to check.
What if visual checks were just part of your existing Cypress workflow? You run tests locally and see visual diffs in real-time. Push to CI and your team gets automatic builds to review. No separate process. No context switching. Just Cypress tests that happen to catch visual bugs alongside functional ones.
That’s what we’re building here. Vizzly integrates with your existing Cypress tests, gives you a local TDD dashboard for instant visual feedback, and automatically creates team builds from your CI runs. Let’s walk through how to set it up.
Two Ways to Integrate with Cypress
You’ve got two options for getting screenshots from Cypress into Vizzly:
Option 1: Upload screenshots after tests run (simpler setup)
Cypress already saves screenshots to cypress/screenshots/. Just point Vizzly at that folder:
npx cypress run
npx vizzly upload ./cypress/screenshots --wait
Dead simple. This works great in CI and requires zero code changes to your tests.
Option 2: Stream screenshots from tests (more control)
Send screenshots to Vizzly in real-time using the SDK. This lets you attach metadata and get instant feedback in the local TDD dashboard:
import { vizzlyScreenshot } from '@vizzly-testing/cli/client';
cy.screenshot('homepage', { capture: 'viewport' });
cy.readFile('cypress/screenshots/homepage.png', 'base64').then(async (base64) => {
let buffer = Buffer.from(base64, 'base64');
await vizzlyScreenshot('homepage', buffer, {
properties: {
browser: 'chrome',
viewport: '1920x1080'
}
});
});
The examples below use the SDK approach since it gives you more flexibility, but the upload approach works just as well if you prefer simplicity.
Basic Integration: Full-Page Screenshots
The simplest way to add visual testing is to capture full-page screenshots at key points in your tests:
describe('Login page', () => {
it('should display login form correctly', () => {
cy.visit('https://example.com/login');
cy.screenshot('login-form', { capture: 'fullPage' });
});
});
If you’re using the upload approach, you’re done. If you’re using the SDK, wrap it with vizzlyScreenshot():
import { vizzlyScreenshot } from '@vizzly-testing/cli/client';
cy.screenshot('login-form', { capture: 'fullPage' });
cy.readFile('cypress/screenshots/login-form.png', 'base64').then(async (base64) => {
let buffer = Buffer.from(base64, 'base64');
await vizzlyScreenshot('login-form', buffer, {
properties: {
component: 'login-page',
feature: 'authentication',
state: 'empty-form'
}
});
});
The properties object lets you attach metadata that helps organize and filter screenshots in Vizzly’s dashboard.
Element-Level Screenshots
Want to test specific components instead of entire pages? Cypress lets you screenshot individual elements:
describe('Hero section', () => {
it('matches design', () => {
cy.visit('https://example.com');
cy.get('.hero-section').screenshot('hero-section');
});
});
This is great for design system components or testing specific UI sections without noise from the rest of the page. From here on, I’ll omit the vizzlyScreenshot() wrapper code to keep examples focused - just know you can add it to any cy.screenshot() call.
Cross-Browser Testing
Cypress runs in Chromium-based browsers, Firefox, and WebKit. Capture screenshots across browsers and let Vizzly track them separately:
describe('Homepage', () => {
['chrome', 'firefox', 'webkit'].forEach((browser) => {
it(`looks right in ${browser}`, () => {
cy.visit('https://example.com');
cy.screenshot(`homepage-${browser}`);
});
});
});
Vizzly organizes screenshots by metadata (like browser name), so you can easily compare the same page across different browsers.
Responsive Testing
Test your responsive breakpoints by capturing screenshots at different viewport sizes:
describe('Responsive homepage', () => {
let viewports = [
{ width: 375, height: 667, name: 'mobile' },
{ width: 768, height: 1024, name: 'tablet' },
{ width: 1920, height: 1080, name: 'desktop' }
];
viewports.forEach((viewport) => {
it(`works at ${viewport.name}`, () => {
cy.viewport(viewport.width, viewport.height);
cy.visit('https://example.com');
cy.screenshot(`homepage-${viewport.name}`, { capture: 'fullPage' });
});
});
});
This ensures your layouts work correctly at different screen sizes.
Testing Interactions and State
Capture screenshots after user interactions to catch visual bugs in dynamic UIs:
describe('Login flow', () => {
it('shows correct visual states', () => {
cy.visit('https://example.com/login');
// Capture initial empty state
cy.screenshot('login-empty');
cy.readFile('cypress/screenshots/login-empty.png', 'base64').then(async (base64) => {
let buffer = Buffer.from(base64, 'base64');
await vizzlyScreenshot('login-form', buffer, {
properties: {
component: 'login-page',
feature: 'authentication',
state: 'empty-form'
}
});
});
// Fill in the form
cy.get('#login').type('user@example.com');
cy.get('#password').type('password123');
// Capture filled state
cy.screenshot('login-filled');
cy.readFile('cypress/screenshots/login-filled.png', 'base64').then(async (base64) => {
let buffer = Buffer.from(base64, 'base64');
await vizzlyScreenshot('login-form-filled', buffer, {
properties: {
component: 'login-page',
feature: 'authentication',
state: 'filled-form'
}
});
});
// Submit with invalid credentials to trigger error
cy.get('#login').clear().type('invalid@example.com');
cy.get('button[type="submit"]').click();
cy.get('.text-red-400').should('be.visible');
// Capture error state
cy.screenshot('login-error');
cy.readFile('cypress/screenshots/login-error.png', 'base64').then(async (base64) => {
let buffer = Buffer.from(base64, 'base64');
await vizzlyScreenshot('login-error-state', buffer, {
properties: {
component: 'login-page',
feature: 'authentication',
state: 'invalid-credentials-error',
scenario: 'error-handling'
}
});
});
});
});
This catches visual issues in different UI states that functional tests might miss. Using consistent properties makes it easy to filter and organize screenshots by state, feature, or scenario.
Waiting for Stability
Avoid flaky screenshots by waiting for the DOM state you care about - just like a user would:
describe('Dashboard', () => {
it('displays after successful login', () => {
cy.visit('https://example.com/login');
// Log in
cy.get('#login').type('user@example.com');
cy.get('#password').type('password123');
cy.get('button[type="submit"]').click();
// Wait for the specific content that indicates the page is ready
// This is how users interact - they wait for what they need, not all network requests
cy.get('[data-testid="dashboard-content"]').should('be.visible');
cy.screenshot('dashboard-home', { capture: 'fullPage' });
cy.readFile('cypress/screenshots/dashboard-home.png', 'base64').then(async (base64) => {
let buffer = Buffer.from(base64, 'base64');
await vizzlyScreenshot('dashboard-home', buffer, {
properties: {
component: 'dashboard',
feature: 'navigation',
state: 'logged-in'
}
});
});
});
});
Pro tip: Wait for specific DOM elements that indicate your page is ready, not arbitrary timeouts. Users don’t wait for all network requests to settle - they interact as soon as the UI they need is visible. Your tests should do the same. This makes tests faster and more resilient.
Creating a Custom Command
As your test suite grows, you’ll want consistency in how you capture and organize screenshots. Instead of repeating the same patterns, create a Cypress custom command:
// cypress/support/commands.js
import { vizzlyScreenshot } from '@vizzly-testing/cli/client';
Cypress.Commands.add('vizzlyCapture', (name, options = {}) => {
let screenshotOptions = {
capture: options.fullPage ? 'fullPage' : 'viewport'
};
cy.screenshot(name, screenshotOptions);
cy.readFile(`cypress/screenshots/${name}.png`, 'base64').then(async (base64) => {
let buffer = Buffer.from(base64, 'base64');
await vizzlyScreenshot(name, buffer, {
properties: {
...options.properties
},
threshold: options.threshold ?? 0.1
});
});
});
Then use it in your tests:
describe('Login page', () => {
it('displays correctly', () => {
cy.visit('https://example.com/login');
cy.vizzlyCapture('login-form', {
fullPage: true,
properties: {
component: 'login-page',
feature: 'authentication',
state: 'empty-form'
}
});
});
});
This pattern gives you a single place to:
- Set default screenshot options
- Add consistent metadata
- Handle errors gracefully
- Extend functionality without changing every test
You can add options for common patterns like responsive screenshots, element captures, or interaction states. The key is wrapping vizzlyScreenshot() with your project’s conventions.
Local Development: The vizzly tdd Workflow
Here’s where this gets fun. Spin up the local TDD dashboard:
vizzly tdd start --open
Now run your Cypress tests in another terminal (or use npx cypress open for interactive mode). Every time a test captures a screenshot, the dashboard updates instantly. You see the diff, decide if it’s intentional, and either accept the new baseline or fix the bug.
No waiting for CI builds. No opening screenshots in preview apps and squinting at differences. No mental gymnastics trying to remember what changed between runs. Just immediate visual feedback while you’re coding.
I’ve been using this workflow for months and it’s changed how I develop UI. You catch visual bugs the moment you introduce them, not three commits later when you’ve already forgotten what you changed. It’s like TDD for your eyeballs.
CI Integration: GitHub Actions Example
Vizzly plugs into whatever CI you’re already running. Here’s a GitHub Actions workflow that runs Cypress tests and uploads screenshots:
name: Visual Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- name: Install dependencies
run: npm ci
- name: Run Cypress tests
env:
VIZZLY_TOKEN: ${{ secrets.VIZZLY_TOKEN }}
run: npx cypress run
# If using upload approach instead of SDK
- name: Upload screenshots to Vizzly
if: always()
env:
VIZZLY_TOKEN: ${{ secrets.VIZZLY_TOKEN }}
run: npx vizzly upload ./cypress/screenshots --wait
The --wait flag makes the build fail if visual differences are detected, gating your PRs on visual approval.
Note: If you’re using the SDK approach with vizzlyScreenshot(), you don’t need the upload step - screenshots are sent in real-time during test execution. Always store your VIZZLY_TOKEN as a repository secret, never commit it to your codebase.
When your CI runs, Vizzly automatically creates a build from your test run. This means your entire team can review visual changes, leave position-based comments, and approve changes before they ship - all without you having to do anything beyond running your tests.
This seamless integration is what makes visual quality part of your development process instead of a separate testing phase.
Best Practices: Stable Screenshots
A few tips to keep your visual tests reliable:
Hide dynamic content: Mask timestamps, user-specific data, or anything that changes on every run:
cy.get('.timestamp').invoke('css', 'visibility', 'hidden');
Stabilize animations: Disable animations during tests to avoid timing issues:
cy.visit('https://example.com', {
onBeforeLoad: (win) => {
let style = win.document.createElement('style');
style.innerHTML = '*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }';
win.document.head.appendChild(style);
}
});
Wait for fonts to load: Ensure custom fonts are loaded before capturing:
cy.document().then((doc) => {
return doc.fonts.ready;
});
Consistent viewport: Always use the same viewport size for each screenshot unless you’re explicitly testing responsive behavior:
cy.viewport(1920, 1080);
Organizing Your Visual Tests
The key to organizing screenshots in Vizzly is using properties consistently. Properties let you filter, search, and group screenshots in the dashboard:
cy.vizzlyCapture('checkout-page', {
properties: {
feature: 'checkout',
component: 'cart',
state: 'empty',
flow: 'purchase'
}
});
cy.vizzlyCapture('checkout-page', {
properties: {
feature: 'checkout',
component: 'cart',
state: 'with-items',
flow: 'purchase'
}
});
Notice how both screenshots use the same name ('checkout-page') but different state properties. This is the pattern: use consistent screenshot names and differentiate with properties.
With consistent properties, you can:
- Filter builds by feature: “Show me all checkout screenshots”
- Group by state: “Compare empty vs filled states”
- Track specific flows: “Review the entire purchase flow”
- Organize by component: “See all cart-related screenshots”
Pro tip: Establish property conventions early. Common ones include feature, component, state, flow, scenario, and browser. Use them consistently across your test suite and they become powerful organizational tools in the Vizzly dashboard.
Get Started
- Follow the Vizzly Quick Start to set your auth token
- Add
cy.vizzlyCapture()calls to a couple of your existing Cypress tests - Start
vizzly tdd start --openlocally and run your tests to see instant visual diffs - Add the Vizzly upload step to your CI pipeline
Visual quality becomes part of your development process, not a separate testing phase. That’s how it should be.
Questions? Feedback? I’d love to hear how you’re using Vizzly with Cypress. Feel free to reach out.
Happy testing!