A mechanic doesn't test a car's brakes by driving it across town. She lifts the car, spins one wheel, and presses the pedal. If the wheel stops, the brakes work. The rest of the car can wait.
So far in this series, we've been driving across town. Every test started the whole app: the dev server, the router, the login page, all of it. That's end-to-end testing, and it's great at answering "does the whole thing work?". But sometimes you only want to know if one part works. For that, Cypress lets you lift a single component onto a workbench and test it on its own. That's component testing, and it's what we'll do with our LoginForm today.
Catching Up? Here's the Road So Far
This is part 8 of the Cypress Tutorial Series. If you've just joined, these will get you up to speed:
- A Beginner's Guide to Cypress: End-to-End Testing Made Easy
- Understanding Cypress Basics: Core Features and Syntax Explained
- Cypress Stubs, Spies, and Clocks: Take Control of Your Tests
- Mocking Network Requests in Cypress with cy.intercept()
- Why Your Cypress Tests Break: Selectors and Test Design Best Practices
- Cypress Custom Commands: Write Less, Test More
- Stop Logging In a Hundred Times: Cypress Sessions, Tokens, and Protected Pages
Why Test a Piece Without the Whole?
- It's fast. No dev server to start, no pages to load, no login to get through. Just the component.
- Problems are easy to find. If a component test fails, the bug is inside that component. With an E2E test, it could be anywhere along the way.
- Odd cases are easy to set up. Want to see the form with a long error message or an empty username? Mount it that way. No clicking through the app to get there.
Setting Up the Workbench
Good news: Cypress does most of this for you. It looks at your project, sees React and Vite, and wires itself up.
Step 1: Let Cypress Configure Itself
Run the runner as usual:
npx cypress openThis time, choose Component Testing instead of E2E. Cypress detects React and Vite, asks you to confirm, and then creates a few files:
cypress/support/component.tssets upcy.mount().cypress/support/component-index.htmlis the empty page your components get mounted into.- A new
componentsection incypress.config.ts.
Open cypress/support/component.ts. It should look roughly like this:
import './commands';
import { mount } from 'cypress/react';
declare global {
namespace Cypress {
interface Chainable {
mount: typeof mount;
}
}
}
Cypress.Commands.add('mount', mount);That first line matters to us. It imports commands.ts, so getByCy, login, and mockLogin from part 6 work in component tests too. Nothing to rewrite.
Step 2: Choose Where Component Tests Live
We'll keep component tests in their own folder, cypress/component, separate from our E2E tests. Update the component section in cypress.config.ts:
component: {
devServer: {
framework: 'react',
bundler: 'vite',
},
specPattern: 'cypress/component/**/*.cy.{ts,tsx}',
},Step 3: Teach TypeScript About JSX
Component tests use JSX, like <LoginForm />, so their files end in .tsx. Our cypress/tsconfig.json from part 2 only knew about .ts files. Update it:
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM"],
"jsx": "react-jsx",
"types": ["cypress"]
},
"include": ["**/*.ts", "**/*.tsx"]
}The workbench is ready.
Lifting the LoginForm onto the Bench
Step 1: Your First Mount
Create cypress/component/LoginForm.cy.tsx:
import LoginForm from '../../src/components/LoginForm';
describe('<LoginForm />', () => {
it('should render the empty form', () => {
cy.mount(<LoginForm />);
cy.getByCy('username-input').should('have.value', '');
cy.getByCy('password-input').should('have.value', '');
cy.getByCy('login-button').should('have.text', 'Login');
});
});Pick the file in the runner and watch. There's no URL, no login page, no app around it. Just the form, sitting alone on a white page.
The only new thing here is cy.mount(<LoginForm />). It does the job cy.visit() did in E2E tests, but for a single component instead of a whole page. Everything after it is plain Cypress you already know.
Step 2: Everything You Learned Still Works
This is the part I love most. The intercepts from part 4, the stubs from part 3, and the commands from part 6 all work on the bench, unchanged. Add these tests inside the describe block:
it('should show a success message after login', () => {
cy.mount(<LoginForm />);
cy.mockLogin();
cy.login('testuser', 'password123');
cy.wait('@login');
cy.getByCy('success-message').should('have.text', 'Logged in as testuser');
});
it('should show the server error for a wrong password', () => {
cy.mount(<LoginForm />);
cy.mockLogin('login-invalid.json', 401);
cy.login('testuser', 'wrongpassword');
cy.wait('@login');
cy.getByCy('error-message').should('have.text', 'Invalid username or password');
});
it('should clear the form when reset is confirmed', () => {
cy.mount(<LoginForm />);
cy.window().then((win) => {
cy.stub(win, 'confirm').returns(true);
});
cy.getByCy('username-input').type('testuser');
cy.getByCy('reset-button').click();
cy.getByCy('username-input').should('have.value', '');
});Notice how the form's fetch('/api/login') still gets caught by cy.mockLogin(). The component has no idea it's on a bench. It behaves exactly as it would in the full app.
When a Component Needs Company
Some components can't stand alone. Our Dashboard from part 7 calls useNavigate(), and that only works inside a router. Mount it bare, and React throws an error before your test even starts.
The fix is to bring along just enough of the app for the component to feel at home. React Router offers MemoryRouter, a router that lives entirely in memory, with no address bar involved. Perfect for a bench.
Create cypress/component/Dashboard.cy.tsx:
import { MemoryRouter } from 'react-router-dom';
import Dashboard from '../../src/components/Dashboard';
describe('<Dashboard />', () => {
beforeEach(() => {
localStorage.setItem('username', 'testuser');
localStorage.setItem('token', 'fake-jwt-token-123');
cy.mount(
<MemoryRouter>
<Dashboard />
</MemoryRouter>
);
});
it('should greet the user by name', () => {
cy.getByCy('dashboard-title').should('have.text', 'Welcome back, testuser!');
});
it('should clear the token on logout', () => {
cy.getByCy('logout-button').click();
cy.window().its('localStorage').invoke('getItem', 'token').should('be.null');
});
});See what happened to the login? It's gone. We didn't need cy.session(), a mock server, or a single click. We just put the username and token in local storage and mounted the Dashboard. On the bench, you set the scene directly.
Tip: If many components need the same wrapper, like a router or a theme, wrap them once in a custom mount command, such as cy.mountWithRouter(). It's the same idea as the custom commands from part 6.
Three Ways to Test, Side by Side
Cypress component tests aren't the only way to test a component. Many React teams use Jest (or its Vite-friendly cousin, Vitest) with React Testing Library (RTL). Here's the same idea, typing into the username field, written with RTL:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
test('types into the username field', async () => {
render(<LoginForm />);
const input = screen.getByPlaceholderText('Username');
await userEvent.type(input, 'testuser');
expect(input).toHaveValue('testuser');
});It looks familiar, and that's no accident. Both tools mount a component and act like a user. The big difference is where the component runs. RTL runs it in jsdom, a pretend browser made of JavaScript. Cypress runs it in a real browser you can watch.
Cypress E2E | Cypress Component | Jest / Vitest + RTL | |
|---|---|---|---|
What it tests | The whole app, page by page | One component | One component |
Where it runs | Real browser | Real browser | jsdom (simulated) |
Speed | Slowest | Fast | Fastest |
Can you watch it? | Yes, step by step | Yes, step by step | No, terminal only |
Real CSS and layout | Yes | Yes | No |
Best at | User journeys, like login then dashboard | UI behavior of a single component | Logic-heavy components and quick feedback |
So Which One Should You Use?
You don't have to pick just one. A healthy project usually mixes them:
- Use E2E tests for the few journeys that must never break, like logging in and reaching the dashboard.
- Use component tests for the many states a single component can be in: errors, loading, empty fields, long text.
- If your team already has Jest or Vitest with RTL for components, there's no need to rewrite them. Add Cypress component tests where seeing the real browser helps, like layout, focus, or anything visual.
A simple rule of thumb: if a bug would need a whole page to show up, write an E2E test. If it lives inside one component, test it on the bench.
All Together on the Bench
Here's the finished cypress/component/LoginForm.cy.tsx:
import LoginForm from '../../src/components/LoginForm';
describe('<LoginForm />', () => {
beforeEach(() => {
cy.mount(<LoginForm />);
});
it('should render the empty form', () => {
cy.getByCy('username-input').should('have.value', '');
cy.getByCy('password-input').should('have.value', '');
cy.getByCy('login-button').should('have.text', 'Login');
});
it('should show a success message after login', () => {
cy.mockLogin();
cy.login('testuser', 'password123');
cy.wait('@login');
cy.getByCy('success-message').should('have.text', 'Logged in as testuser');
});
it('should show the server error for a wrong password', () => {
cy.mockLogin('login-invalid.json', 401);
cy.login('testuser', 'wrongpassword');
cy.wait('@login');
cy.getByCy('error-message').should('have.text', 'Invalid username or password');
});
it('should clear the form when reset is confirmed', () => {
cy.window().then((win) => {
cy.stub(win, 'confirm').returns(true);
});
cy.getByCy('username-input').type('testuser');
cy.getByCy('reset-button').click();
cy.getByCy('username-input').should('have.value', '');
});
});Run it next to the Dashboard spec, and all six component tests pass in a few seconds. To run them from the terminal instead of the runner, use:
npx cypress run --componentWhere Beginners Trip
- Opening the E2E runner by mistake. Component specs only show up after you choose Component Testing. If your file is missing from the list, check which mode you picked.
- Mounting a component that needs a provider. Errors about routers, themes, or context almost always mean the component needs a wrapper, like
MemoryRouter. - Wondering where the styles went. The bench page loads only what you import. If your component relies on global CSS, import that file in
cypress/support/component.ts. - Copying the RTL snippet as is.
toHaveValue()comes from@testing-library/jest-dom, which needs its own setup in a Jest or Vitest project.
Back to the Garage
Like the mechanic with one wheel in the air, we stopped driving the whole app around and tested a single part on the bench. We mounted the LoginForm with cy.mount(), reused our intercepts, stubs, and custom commands without changing a line, gave the Dashboard a MemoryRouter to lean on, and saw where Cypress component tests sit next to E2E and Jest or Vitest with RTL.
Tests are only half the story, though. The other half is what you do when one fails and you can't see why. Next time, we'll get hands-on with debugging: time-travel through the Command Log, pause a test mid-step with cy.pause(), and squeeze every clue out of screenshots and videos. Stay tuned, and happy testing!
