Cypress Component Testing for React: From Setup to CI

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 open

Cypress 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 --component

Testing 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: 3

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

Read more

Start now free