Cypress Component Testing for React: From Setup to CI
End-to-end tests verify your whole application works together. Unit tests verify individual functions in isolation. Component tests sit between them: they mount a single React component in a real browser, let you interact with it, and assert on what the user actually sees.
Cypress component testing brings the Cypress developer experience — time-travel debugging, automatic retries, real browser rendering — down to the component level. No full app. No routing. Just your component, mounted and ready to interact with.
Component Testing vs E2E Testing
The distinction matters for how you structure your test suite:
E2E tests navigate through your real application. They're slow (full page loads, real network), brittle (break when UI layout changes), and valuable for critical user flows.
Component tests mount one component in isolation. They're fast (no routing, no full app boot), focused (only test one component's behavior), and valuable for catching UI regressions at the source.
A good rule of thumb: if you're testing "does the checkout flow work end-to-end", use E2E. If you're testing "does the AddToCart button emit the right event when clicked", use component tests.
Setting Up Cypress Component Testing in a React App
If you already have Cypress installed:
npm install cypress --save-dev
npx cypress openCypress will detect your framework and generate config. For a React app with Vite:
// cypress.config.js
const { defineConfig } = require('cypress');
module.exports = defineConfig({
component: {
devServer: {
framework: 'react',
bundler: 'vite',
},
specPattern: 'src/**/*.cy.{js,jsx,ts,tsx}',
supportFile: 'cypress/support/component.js',
},
e2e: {
baseUrl: 'http://localhost:3000',
specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
},
});For Create React App (webpack):
module.exports = defineConfig({
component: {
devServer: {
framework: 'react',
bundler: 'webpack',
},
},
});Create the support file:
// cypress/support/component.js
import './commands';
import { mount } from 'cypress/react18';
Cypress.Commands.add('mount', mount);Mounting Components with cy.mount()
The cy.mount() command renders your component into a test iframe. Here's a basic example with a ProductCard component:
// src/components/ProductCard.cy.jsx
import React from 'react';
import ProductCard from './ProductCard';
describe('ProductCard', () => {
it('renders product name and price', () => {
cy.mount(
<ProductCard
product={{
id: 'prod-1',
name: 'Widget Pro',
price: 29.99,
description: 'A high-quality widget',
image: '/images/widget.jpg',
inStock: true,
}}
onAddToCart={cy.stub().as('addToCart')}
/>
);
cy.contains('Widget Pro').should('be.visible');
cy.contains('$29.99').should('be.visible');
});
});Run component tests:
npx cypress open --component
# or headless:
npx cypress run --componentTesting Props
Test that your component renders correctly for different prop combinations:
describe('ProductCard — prop variations', () => {
it('shows "Out of Stock" badge when inStock is false', () => {
cy.mount(
<ProductCard
product={{ id: '1', name: 'Widget', price: 9.99, inStock: false }}
onAddToCart={cy.stub()}
/>
);
cy.get('[data-testid="out-of-stock-badge"]').should('be.visible');
cy.get('[data-testid="add-to-cart-btn"]').should('be.disabled');
});
it('shows sale price when discount is provided', () => {
cy.mount(
<ProductCard
product={{
id: '2',
name: 'Widget',
price: 29.99,
salePrice: 19.99,
inStock: true,
}}
onAddToCart={cy.stub()}
/>
);
cy.contains('$19.99').should('be.visible');
cy.contains('$29.99').should('have.class', 'line-through');
});
});Testing Events
Verify that user interactions emit the correct callbacks:
describe('ProductCard — interactions', () => {
it('calls onAddToCart with product ID when button is clicked', () => {
const onAddToCart = cy.stub().as('addToCart');
cy.mount(
<ProductCard
product={{ id: 'prod-42', name: 'Widget', price: 9.99, inStock: true }}
onAddToCart={onAddToCart}
/>
);
cy.get('[data-testid="add-to-cart-btn"]').click();
cy.get('@addToCart').should('have.been.calledOnceWith', 'prod-42');
});
it('increments quantity and passes it to onAddToCart', () => {
const onAddToCart = cy.stub().as('addToCart');
cy.mount(
<ProductCard
product={{ id: 'prod-42', name: 'Widget', price: 9.99, inStock: true }}
onAddToCart={onAddToCart}
/>
);
cy.get('[data-testid="quantity-increase"]').click().click();
cy.get('[data-testid="quantity-input"]').should('have.value', '3');
cy.get('[data-testid="add-to-cart-btn"]').click();
cy.get('@addToCart').should('have.been.calledWith', 'prod-42', 3);
});
});Testing State Changes
For components that manage their own state:
// src/components/QuantitySelector.cy.jsx
import QuantitySelector from './QuantitySelector';
describe('QuantitySelector', () => {
it('starts at 1 and increments/decrements correctly', () => {
const onChange = cy.stub().as('onChange');
cy.mount(<QuantitySelector min={1} max={10} onChange={onChange} />);
cy.get('[data-testid="qty-display"]').should('contain', '1');
// Increment
cy.get('[data-testid="qty-increase"]').click();
cy.get('[data-testid="qty-display"]').should('contain', '2');
cy.get('@onChange').should('have.been.calledWith', 2);
// Decrement
cy.get('[data-testid="qty-decrease"]').click();
cy.get('[data-testid="qty-display"]').should('contain', '1');
// Can't go below min
cy.get('[data-testid="qty-decrease"]').click();
cy.get('[data-testid="qty-display"]').should('contain', '1');
cy.get('[data-testid="qty-decrease"]').should('be.disabled');
});
it('cannot exceed max value', () => {
cy.mount(<QuantitySelector min={1} max={3} onChange={cy.stub()} />);
cy.get('[data-testid="qty-increase"]').click().click().click();
cy.get('[data-testid="qty-display"]').should('contain', '3');
cy.get('[data-testid="qty-increase"]').should('be.disabled');
});
});Intercepting Network Calls in Component Tests
Components that fetch data directly (using useEffect + fetch, or React Query, or SWR) need network interception:
// src/components/ProductDetail.cy.jsx
import ProductDetail from './ProductDetail';
describe('ProductDetail — data fetching', () => {
it('shows product details after loading', () => {
cy.intercept('GET', '/api/products/prod-42', {
statusCode: 200,
body: {
id: 'prod-42',
name: 'Widget Pro',
price: 29.99,
description: 'Professional grade widget',
images: [{ url: '/img/widget.jpg', altText: 'Widget' }],
inStock: true,
},
}).as('getProduct');
cy.mount(<ProductDetail productId="prod-42" />);
cy.wait('@getProduct');
cy.contains('Widget Pro').should('be.visible');
cy.contains('$29.99').should('be.visible');
cy.get('[data-testid="product-image"]').should('have.attr', 'src', '/img/widget.jpg');
});
it('shows error state when product fetch fails', () => {
cy.intercept('GET', '/api/products/bad-id', {
statusCode: 404,
body: { error: 'Product not found' },
});
cy.mount(<ProductDetail productId="bad-id" />);
cy.get('[data-testid="error-message"]').should('contain', 'Product not found');
});
it('shows skeleton while loading', () => {
cy.intercept('GET', '/api/products/prod-42', (req) => {
req.reply((res) => {
res.delay = 500;
res.send({ statusCode: 200, body: { id: 'prod-42', name: 'Widget' } });
});
});
cy.mount(<ProductDetail productId="prod-42" />);
cy.get('[data-testid="skeleton-loader"]').should('be.visible');
cy.contains('Widget').should('be.visible');
});
});Testing with Context Providers
Many components require React context (theme, auth, cart state). Wrap them in cy.mount():
// cypress/support/component.js
import { mount } from 'cypress/react18';
import { CartProvider } from '../../src/context/CartContext';
import { ThemeProvider } from '../../src/context/ThemeContext';
Cypress.Commands.add('mount', (component, options = {}) => {
const { cartState = {}, theme = 'light', ...mountOptions } = options;
const wrapped = (
<ThemeProvider initialTheme={theme}>
<CartProvider initialState={cartState}>
{component}
</CartProvider>
</ThemeProvider>
);
return mount(wrapped, mountOptions);
});Now tests can pass context state:
it('shows item count from cart context', () => {
cy.mount(<CartIcon />, {
cartState: { items: [{ id: '1', qty: 3 }, { id: '2', qty: 1 }] },
});
cy.get('[data-testid="cart-count"]').should('contain', '4');
});Running Component Tests in GitHub Actions
# .github/workflows/component-tests.yml
name: Cypress Component Tests
on:
push:
branches: [main, 'feature/**']
pull_request:
jobs:
component-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run Cypress component tests
uses: cypress-io/github-action@v6
with:
component: true
browser: chrome
headed: false
- name: Upload screenshots on failure
uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress-screenshots
path: cypress/screenshots
retention-days: 7
- name: Upload videos
uses: actions/upload-artifact@v4
if: always()
with:
name: cypress-videos
path: cypress/videos
retention-days: 3The cypress-io/github-action@v6 handles Cypress installation caching automatically. Component tests don't need a running dev server — Cypress starts its own internal server using your Vite/webpack config.
Performance Considerations
Component tests run significantly faster than E2E tests:
- No full page load
- No routing overhead
- Network calls intercepted by default (no real HTTP)
- Tests run in parallel across workers
A typical component test runs in 1-3 seconds. An E2E test that navigates to the same page and performs the same action might take 5-15 seconds.
For a React app with 50 components, expect component tests to complete in 2-4 minutes vs 15-30 minutes for equivalent E2E coverage.
When Component Tests Beat Unit Tests
React Testing Library (RTL) is excellent for unit-level component tests. Cypress component tests are better when:
- You need to visually verify the rendered output (Cypress has a real browser, RTL uses jsdom)
- You're debugging a test failure and want to see exactly what the component looks like
- You're testing CSS transitions, animations, or scroll behavior
- You want to use Cypress's time-travel debugger to step through a failing test
- You need to test resize observer behavior or other browser APIs that jsdom doesn't support
For most logic tests (does this callback fire, does this text appear), RTL is faster and lighter. Add Cypress component tests where you need a real browser environment or where jsdom's limitations have bitten you before.