Component Tests — The Layer Between Unit and E2E
Use component tests for UI states that do not need a full journey, and keep end-to-end tests for the paths that do.
This post is part of the Test Architecture series. If you missed the previous post, read Part 4 — Designing for Testability first.
Introduction — The Journey Is the Wrong Size
A disabled pay button does not need a browser, a user, and three services. A unit test of the disable function does not prove the button is the thing on screen. Component tests sit in the gap: the component is real, the page around it is not.
What This Layer Owns
Own a component test when the behaviour is visible and local:
- empty, loading, error, and success states of one component
- a field error that appears next to that field
- a button that enables when the component’s inputs say it should
- keyboard behaviour inside one widget, such as a menu or a dialog
Do not own a whole checkout. The moment the test needs routing, a session, and a second page, it is an end-to-end test. The moment it needs no rendering at all, it is a unit test.
One Example
The runner depends on the UI framework. The shape is the same: mount the component, give it props, assert the accessible name.
import { test, expect } from '@playwright/experimental-ct-react';
import { PayButton } from './PayButton';
test('stays disabled when the price is missing', async ({ mount }) => {
const component = await mount(<PayButton price={null} />);
await expect(component.getByRole('button', { name: 'Pay' })).toBeDisabled();
});
test('explains a refused payment', async ({ mount }) => {
const component = await mount(<PayButton price={20} status="refused" />);
await expect(component.getByRole('alert')).toHaveText('Payment was refused');
});
Two tests, no login, no network. If PayButton cannot be mounted without the whole application, that is a design problem from part 4, not a reason to push both cases into end-to-end.
How This Changes the Pyramid
End-to-end tests keep the journeys: sign in, pay, see the order. Component tests take the states of the widgets those journeys pass through. You will delete UI tests that existed only to reach an error message. You will not delete the one journey that proves the pieces are wired together.
Count them. If component tests outnumber unit tests ten to one, you are rendering to test logic. If you have no component tests and dozens of end-to-end tests for copy and disabled buttons, the layer is missing.
What Breaks Them
- importing a real API client in the component’s module scope
- asserting CSS classes instead of role and name
- mounting the application root and calling it a component test
A component test that boots the router is an end-to-end test with extra steps.
Conclusion
Put local UI states in component tests. Put the path a user walks in end-to-end tests. Put pure rules in unit tests. The layer earns its place when a state change no longer costs a full journey.
Action for this week: Pick one end-to-end test whose only assertion is an inline error or a disabled button. Mount that component directly. Leave the journey test for the success path.
This concludes the Test Architecture series.