Cypress component testing mounts a single component in a real browser, without starting your whole application. You get the same Cypress commands, time-travel debugging and network control as in end-to-end tests, but each test renders one button, form or card with exactly the props you choose. That makes it the right tool for checking a component's behavior across many states, which would be slow and awkward to reach through full pages.
This guide sets up component testing for a React + Vite project, then covers cy.mount, cy.spy, cy.stub (including changing return values between calls) and intercepting API requests from inside a component test. The same ideas apply to Vue, Angular and Svelte; only the mount import changes. For Cypress basics such as describe, it and assertions, see the Cypress E2E testing guide.
Component tests vs. E2E tests
| Component test | E2E test | |
|---|---|---|
| What runs | One component, served by your dev server bundler | The whole app at a URL |
| Entry point | cy.mount(<Component />) | cy.visit('/path') |
| Backend needed | No, mock requests with cy.intercept | Usually yes, or mocked |
| Speed | Fast, no routing or login | Slower |
| Best for | States, edge cases, props and callbacks | User journeys across pages |
| Spec location | Next to the component, e.g. Button.cy.tsx | cypress/e2e/*.cy.ts |
Most teams use both: a handful of E2E tests for critical flows, and component tests for the detailed behavior of each piece.
Setting up Cypress component testing
Install Cypress and open it:
npm install --save-dev cypress
npx cypress openChoose Component Testing in the launcher. Cypress detects your framework and bundler, installs anything missing, and generates:
cypress.config.tswith acomponentsection,cypress/support/component.ts, which registerscy.mount,cypress/support/component-index.html, the page components are mounted into.
The config for a React + Vite project looks like this:
// cypress.config.ts
import { defineConfig } from 'cypress';
export default defineConfig({
component: {
devServer: {
framework: 'react',
bundler: 'vite',
},
specPattern: 'src/**/*.cy.{ts,tsx}',
},
});Cypress reuses your vite.config.ts, so path aliases, plugins and environment variables work the same way as in the app. For webpack projects, set bundler: 'webpack'. For Next.js, use framework: 'next'.
The support file registers the mount command. Import global styles here so components render with the same CSS as in production:
// cypress/support/component.ts
import './commands';
import '../../src/index.css';
import { mount } from 'cypress/react';
declare global {
namespace Cypress {
interface Chainable {
mount: typeof mount;
}
}
}
Cypress.Commands.add('mount', mount);On Cypress 14 and later, cypress/react supports React 18 and 19. If you're still on Cypress 13 with React 18, import from cypress/react18 instead. Vue uses cypress/vue, Angular cypress/angular, and Svelte cypress/svelte.
Mounting a component with cy.mount
cy.mount renders a component into the test page. After that, you interact with it using normal Cypress commands:
// src/components/Counter.cy.tsx
import { Counter } from './Counter';
describe('<Counter />', () => {
it('starts at the initial value', () => {
cy.mount(<Counter initial={5} />);
cy.findByText('Count: 5').should('be.visible');
});
it('increments and decrements', () => {
cy.mount(<Counter initial={0} />);
cy.findByRole('button', { name: 'Increment' }).click().click();
cy.findByRole('button', { name: 'Decrement' }).click();
cy.findByText('Count: 1').should('be.visible');
});
});The findBy* queries come from Cypress Testing Library. Import @testing-library/cypress/add-commands in the component support file to use them; Cypress Testing Library and accessible queries explains why they beat CSS selectors.
Wrapping components in providers
Real components often need a router, a theme, or a data-fetching client. Instead of repeating the wrappers in every test, extend the mount command:
// cypress/support/component.tsx
import { mount, type MountOptions } from 'cypress/react';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
type Options = MountOptions & { route?: string };
Cypress.Commands.add('mount', (component: ReactNode, options: Options = {}) => {
const { route = '/', ...mountOptions } = options;
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return mount(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[route]}>{component}</MemoryRouter>
</QueryClientProvider>,
mountOptions,
);
});Rename the support file to .tsx when it contains JSX, and update supportFile in the config if you move it. Creating a new QueryClient per mount keeps cached data from leaking between tests. Update the Chainable type so mount accepts the extra route option.
Spying on callbacks with cy.spy
cy.spy() records calls to a function without changing what it does. The most common use in component tests is checking that a callback prop was called correctly:
import { LoginForm } from './LoginForm';
it('submits the email and password', () => {
const onSubmit = cy.spy().as('onSubmit');
cy.mount(<LoginForm onSubmit={onSubmit} />);
cy.findByLabelText('Email').type('[email protected]');
cy.findByLabelText('Password').type('secret123{enter}');
cy.get('@onSubmit').should('have.been.calledOnce');
cy.get('@onSubmit').should('have.been.calledWith', {
email: '[email protected]',
password: 'secret123',
});
});
it('does not submit an empty form', () => {
const onSubmit = cy.spy().as('onSubmit');
cy.mount(<LoginForm onSubmit={onSubmit} />);
cy.findByRole('button', { name: 'Sign in' }).click();
cy.get('@onSubmit').should('not.have.been.called');
});cy.spy(object, 'methodName') wraps an existing method in place, so you can watch calls that the component makes internally:
cy.spy(console, 'error').as('consoleError');
cy.mount(<ProfileCard user={user} />);
cy.get('@consoleError').should('not.have.been.called');Spies and stubs created with cy.spy and cy.stub are restored automatically after each test. Using .as() gives them an alias, which lets you assert with cy.get('@alias') and shows the call count in the command log.
Replacing behavior with cy.stub
cy.stub() has the same call-recording features as a spy, but it replaces the function, so you control what it returns. Use it when the real function would do something you don't want in a test: open a native dialog, hit a payment SDK, or read the clock.
it('deletes only after the user confirms', () => {
const onDelete = cy.spy().as('onDelete');
cy.mount(<DeleteButton onDelete={onDelete} />);
cy.window().then((win) => {
cy.stub(win, 'confirm').as('confirm').returns(false);
});
cy.findByRole('button', { name: 'Delete' }).click();
cy.get('@confirm').should('have.been.calledOnce');
cy.get('@onDelete').should('not.have.been.called');
});Changing a stub's return value
Stubs are Sinon stubs, so you can script different results per call or per argument:
const getPrice = cy.stub();
getPrice.returns(100); // every call returns 100
getPrice.onFirstCall().returns(100); // first call
getPrice.onSecondCall().returns(120); // second call
getPrice.withArgs('THB').returns(3500); // only for this argument
const fetchUser = cy.stub();
fetchUser.resolves({ id: 1, name: 'Ana' }); // returns a resolved Promise
fetchUser.rejects(new Error('Network')); // returns a rejected PromiseCalling .returns() again later in the test replaces the earlier behavior, which is useful for testing retry buttons:
it('shows the data after a retry', () => {
const api = { loadStats: cy.stub().rejects(new Error('Server error')) };
cy.mount(<StatsPanel api={api} />);
cy.findByRole('alert').should('contain.text', 'Could not load stats');
cy.then(() => {
api.loadStats.resolves({ visitors: 1200 });
});
cy.findByRole('button', { name: 'Try again' }).click();
cy.findByText('1,200 visitors').should('be.visible');
});Note the cy.then(). Cypress commands are queued, so a plain line of JavaScript between commands would run before the first assertion finishes. Wrapping it in cy.then() puts the change in the right place in the queue.
This example passes the dependency as a prop. That's deliberate: stubbing a named ES module export (import { loadStats } from './api') doesn't work reliably, because module bindings can't be reassigned. Pass services as props or context, or export them as an object whose methods you can stub, like cy.stub(statsApi, 'load').
Intercepting API requests in component tests
If the component fetches its own data, you don't need a backend. cy.intercept works in component tests exactly as it does in E2E tests, because requests from the mounted component still go through the Cypress proxy.
import { UserCard } from './UserCard';
describe('<UserCard />', () => {
it('renders the user from the API', () => {
cy.intercept('GET', '/api/users/42', {
body: { id: 42, name: 'Ana Silva', role: 'admin' },
}).as('getUser');
cy.mount(<UserCard userId={42} />);
cy.wait('@getUser');
cy.findByRole('heading', { name: 'Ana Silva' }).should('be.visible');
cy.findByText('admin').should('be.visible');
});
it('shows a loading state', () => {
cy.intercept('GET', '/api/users/42', {
delay: 1000,
body: { id: 42, name: 'Ana Silva', role: 'admin' },
});
cy.mount(<UserCard userId={42} />);
cy.findByRole('progressbar').should('be.visible');
});
it('shows an error when the request fails', () => {
cy.intercept('GET', '/api/users/42', { statusCode: 500 });
cy.mount(<UserCard userId={42} />);
cy.findByRole('alert').should('contain.text', 'Something went wrong');
});
});Always register the intercept before cy.mount, since the component may fire its request as soon as it renders. Relative URLs are matched against the dev server that Cypress starts; if your component calls a different host, use the full URL or a glob such as **/api/users/*.
The loading and error states are where component tests pay off. In an E2E test you would need a slow or broken backend to see them; here each is a single line. For fixtures, mock data factories and asserting on request bodies, see mocking APIs in Cypress with intercept and Faker.
Running component tests in CI
npx cypress run --componentAdd it as a script next to your E2E run:
{
"scripts": {
"test:component": "cypress run --component",
"test:e2e": "cypress run --e2e"
}
}Component tests don't need the app server running, so they can run early in the pipeline, right after linting and type checks.
FAQ
What is the difference between cy.spy and cy.stub?
cy.spy records calls and lets the original function run. cy.stub records calls and replaces the function, so you decide what it returns or whether it throws. Use a spy to verify a callback, and a stub to cut out side effects.
Can I use cy.visit in a component test?
No. Component tests start with cy.mount, and there is no app URL to visit. If a test needs routing across pages, write it as an E2E test, or wrap the component in a memory router.
Does Cypress component testing replace Jest or Vitest?
It overlaps with component tests written in React Testing Library on jsdom. Cypress runs in a real browser, so layout, CSS and focus behave like production. Unit tests for plain functions are still faster in Vitest or Jest.
Why is my intercept not matching in component tests?
Check that it's registered before cy.mount, that the method matches, and that the URL matches the full request URL, including the host if the component calls another origin. The command log shows every request and whether a route matched it.
Checklist
- Run
npx cypress open, choose Component Testing, and let Cypress generate the config. - Import global CSS and Testing Library commands in
cypress/support/component.ts. - Wrap providers in a custom
mountso each test stays short. - Use
cy.spy().as(...)for callback props andcy.stub()for side effects. - Register
cy.interceptbeforecy.mount, and cover the loading, empty and error states. - Run
cypress run --componentin CI before the slower E2E suite.
A good split is to test every state of a component here and keep E2E tests for the journeys that cross pages. If your team needs help building that kind of test setup for a web app, Vectorkub can help.
