113: React Testing — Components, Network, Accessibility, Contracts, and E2E
Learning objectives
You will learn to:
- test behavior rather than implementation details;
- use Vitest + React Testing Library + user-event;
- mock network boundaries with MSW;
- test TanStack Query v5 components reliably;
- test route behavior;
- include accessibility assertions;
- define contract tests;
- use Playwright for critical browser journeys;
- avoid brittle timing and DOM-shape assertions.
Test pyramid for React
A useful test strategy:
many fast pure/unit tests many component/integration tests some contract tests few high-value E2E journeys
Do not force every behavior into E2E.
Do not mock so much that component tests prove only the mocks.
Install
npm install -D \ vitest \ jsdom \ @testing-library/react \ @testing-library/user-event \ @testing-library/jest-dom \ msw
User-centered query priority
Prefer:
screen.getByRole(
'button',
{
name:
/save task/i,
},
);
Then:
screen.getByLabelText(
/title/i,
);
Avoid test IDs when a user-facing semantic query exists.
If getByRole is difficult, that can reveal a real accessibility problem.
Component behavior test
test(
'creates a task',
async () => {
const user =
userEvent.setup();
const onCreate =
vi.fn();
render(
<TaskForm
onCreate={
onCreate
}
/>,
);
await user.type(
screen
.getByLabelText(
/title/i,
),
'Review tests',
);
await user.click(
screen
.getByRole(
'button',
{
name:
/save/i,
},
),
);
expect(
onCreate,
).toHaveBeenCalledWith(
expect.objectContaining({
title:
'Review tests',
}),
);
},
);
This test does not know the name of the useState variable.
Async queries
Use:
await screen.findByText(
/task loaded/i,
);
when UI appears asynchronously.
Use:
screen.queryByText(...)
when asserting absence.
Avoid arbitrary sleeps:
await new Promise(
(resolve) =>
setTimeout(
resolve,
500,
),
);
Tests should wait for observable behavior.
MSW
Set up handlers:
import {
http,
HttpResponse,
} from 'msw';
import {
setupServer,
} from 'msw/node';
export const server =
setupServer(
http.get(
'/api/tasks',
() =>
HttpResponse.json({
tasks: [
{
id: 't1',
title:
'Review MSW',
completed:
false,
},
],
}),
),
);
Test setup:
beforeAll(() => {
server.listen({
onUnhandledRequest:
'error',
});
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
Failing on unhandled requests prevents accidental real network usage.
Network error case
server.use(
http.get(
'/api/tasks',
() =>
new HttpResponse(
null,
{
status: 500,
},
),
),
);
render(<TaskScreen />);
expect(
await screen
.findByRole(
'alert',
),
).toHaveTextContent(
/could not load/i,
);
Test:
- pending;
- success;
- empty;
- error;
- retry.
Testing TanStack Query v5
Each test should usually get its own QueryClient.
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
});
}
Wrapper:
function renderWithQuery(
ui,
) {
const client =
createTestQueryClient();
return render(
<QueryClientProvider
client={client}
>
{ui}
</QueryClientProvider>,
);
}
Do not reuse one cache across tests unless isolation is intentionally managed.
Optimistic mutation test
Test the visible contract:
- initial row;
- click Complete;
- optimistic UI appears;
- server fails;
- rollback is visible;
- error is announced.
Do not assert private mutation callback ordering unless that ordering itself is the library contract under test.
Reducer/store tests
Pure reducer tests remain useful.
Redux component tests should generally render with a real test store and interact with the UI rather than mocking useSelector/useDispatch.
Router tests
Use the router APIs to create an in-memory route environment.
Test:
- route rendering;
- params;
- redirects;
- loader errors;
- action validation;
- not-found state.
Avoid mocking the router Hooks individually if a real in-memory router gives a more meaningful test.
Accessibility tests
Automated accessibility checks are useful but incomplete.
Add an axe-based scan where appropriate, but also manually test:
- keyboard order;
- focus visibility;
- modal focus behavior;
- screen-reader labels;
- live error announcements;
- 200% zoom;
- reduced motion.
A passing automated scan does not prove an accessible workflow.
Contract tests
A client/server contract includes:
- method;
- URL;
- request fields;
- headers/auth mode;
- success body;
- error body;
- status codes.
Example:
{
"request": {
"method": "POST",
"path": "/api/tasks",
"body": {
"title": "Plan"
}
},
"success": {
"status": 201,
"body": {
"task": {
"id": "t1",
"title": "Plan",
"completed": false
}
}
}
}
Use shared fixtures/OpenAPI/schema checks when appropriate.
Do not let MSW drift away from the real API contract.
E2E with Playwright
High-value journey:
sign in create task filter task complete task reload page verify server truth
E2E proves browser/system wiring that isolated component tests cannot.
Keep E2E focused on critical business journeys.
Flaky test diagnosis
Common causes:
- arbitrary waits;
- shared state/cache between tests;
- real network calls;
- race-prone assertions;
- animation not disabled;
- time/date dependence;
- unstable generated IDs;
- test order dependence.
Fix the root cause; do not simply increase timeout values.
Test implementation details to avoid
Avoid:
expect(
component.state.open,
).toBe(true);
Avoid:
expect(
wrapper.find(
'.internal-class',
),
).toHaveLength(1);
Prefer user-visible behavior.
Deprecated test renderer awareness
Modern React recommends testing with current testing-library strategies rather than relying on react-test-renderer for application behavior.
Exercises
- Test controlled TaskForm behavior.
- Test query pending/success/error through MSW.
- Test optimistic rollback.
- Test a route loader 404.
- Add one automated accessibility scan and one manual keyboard checklist.
- Define an API contract fixture.
- Write one Playwright critical journey.
Exit questions
- Why does RTL prefer role-based queries?
- What does MSW mock?
- Why should each query test get a fresh QueryClient?
- What can an E2E test prove that a component test cannot?
- Why are arbitrary sleeps a test smell?
- Why can accessibility not be fully automated?
Official references
- https://testing-library.com/docs/react-testing-library/intro/
- https://testing-library.com/docs/user-event/intro/
- https://vitest.dev/
- https://mswjs.io/
- https://playwright.dev/
- https://react.dev/blog/2024/04/25/react-19-upgrade-guide
Deep dive: test confidence comes from realistic boundaries
A test should fail when user-visible behavior breaks, not whenever you refactor implementation.
Weak
expect(useState).toHaveBeenCalledTimes(3);
Strong
await user.click(screen.getByRole('button', { name: /complete/i }));
expect(
screen.getByRole('button', { name: /reopen/i }),
).toBeVisible();
The second test survives internal changes from useState to reducer/store/query.
Test environment
Configure jsdom for DOM component tests.
But jsdom is not a real browser.
It does not fully model:
- layout;
- CSS rendering;
- scrolling geometry;
- actual navigation;
- browser permissions;
- all accessibility APIs.
Use Playwright/browser tests for those behaviors.
Test setup discipline
Example setup:
import '@testing-library/jest-dom/vitest';
MSW lifecycle:
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Also clear:
- fake timers;
- localStorage if used;
- mocks;
- query clients;
- global DOM side effects.
Tests must not leak state.
act
React Testing Library wraps most user interactions/render updates appropriately.
Do not manually wrap everything in act by habit.
If warnings appear, investigate asynchronous work not awaited.
Often correct fix:
await user.click(...)
await screen.findBy...
not:
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 100));
});
userEvent
Use:
const user = userEvent.setup();
await user.type(input, 'Task title');
await user.click(button);
This models interaction sequences better than:
fireEvent.change(input, { target: { value: 'Task title' } });
fireEvent is still useful for lower-level events, but userEvent should be default for user behavior.
Query priority
Prefer:
- role;
- label;
- placeholder/text where semantically appropriate;
- test ID last.
If you cannot query a button by role/name, inspect accessibility.
MSW contract discipline
Handler:
http.post('/api/tasks', async ({ request }) => {
const body = await request.json();
if (body.title.length < 3) {
return HttpResponse.json(
{
error: {
code: 'VALIDATION_ERROR',
fields: {
title: 'Too short',
},
},
},
{ status: 422 },
);
}
return HttpResponse.json(
{
task: {
id: 't1',
...body,
completed: false,
},
},
{ status: 201 },
);
});
This mock should match actual API contract.
Prevent drift through:
- shared OpenAPI schemas;
- generated types/validators;
- integration contract tests against backend.
Query test retry behavior
Production:
retry transient queries
Tests:
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
Otherwise error tests can take longer and appear flaky.
Test retry itself separately if it is product behavior.
Testing query cache behavior
You can test:
- first render requests tasks;
- second observer reuses cache;
- invalidation triggers refetch;
- staleTime prevents unnecessary refetch;
- mutation update appears across consumers.
Do not assert internal private Query object implementation. Assert observable requests/UI/cache public APIs.
Optimistic concurrency test
A valuable test:
toggle task → optimistic Done visible before response, edit title → optimistic title visible toggle request fails edit succeeds final title remains edited completed rolls back
If your rollback restores full old object, this test exposes data loss.
This is much stronger than a single happy-path optimistic test.
Router testing
Build memory router with route definitions.
Test deep routes directly:
initialEntries: ['/tasks/t1?mode=edit']
Then assert:
- loader request;
- route component;
- param;
- action;
- redirect;
- error boundary.
Do not start every router test at / and click through five pages unless navigation path itself is the behavior under test.
Form test matrix
For important form:
| Case | Expected |
|---|---|
| blank | required error |
| short | client/schema error |
| valid | submit request |
| 422 | server field error |
| 409 | conflict UI |
| 500 | root error |
| double click | no duplicate harmful write |
| success | reset/navigate |
| failure | preserve draft |
This matrix is more useful than chasing coverage percentage alone.
Accessibility automated testing
Using axe can catch:
- missing names;
- invalid ARIA;
- some contrast depending environment;
- landmarks.
But it cannot fully verify:
- useful focus order;
- announcement quality;
- cognitive clarity;
- keyboard custom-widget semantics;
- zoom/reflow;
- motion.
Manual accessibility remains required.
Focus tests
For modal:
open → focus enters dialog Tab cycles appropriately Escape closes focus returns to trigger
This is interaction behavior worth browser/component testing.
Time testing
Avoid tests depending on real current time.
Inject clock or fake timers for:
- debounce;
- retries;
- expiry;
- relative timestamps.
After fake timers, restore real timers.
Do not fake timers globally if userEvent/library interactions rely on real scheduling without configuration.
Visual regression
Useful for:
- design system;
- responsive layout;
- complex charts;
- accidental CSS changes.
Not a replacement for semantic behavior tests.
Pixel changes can be noisy; choose stable environments.
E2E authentication
Avoid slow UI login for every E2E test if test framework can safely create authenticated state.
But keep at least one real login journey.
Never bypass authorization in production code solely for tests.
Network control in E2E
Playwright can:
- intercept;
- simulate failure;
- throttle;
- assert requests.
Use real backend integration for selected journeys to verify contracts.
Balance speed and realism.
Flake triage
When E2E flakes, collect:
- trace;
- screenshot;
- video if enabled;
- console;
- network;
- server logs/correlation ID.
Do not rerun until green and ignore root cause.
Coverage
Code coverage can reveal untested branches.
It cannot tell whether tests represent valuable behavior.
A 100% covered broken form is possible.
Use risk-based coverage:
- money;
- permissions;
- destructive actions;
- state transitions;
- concurrency;
- critical navigation.
Test doubles hierarchy
Prefer realistic boundary doubles:
MSW HTTP
over mocking:
useQuery fetch axios internals router hooks
when integration behavior matters.
Mock low-level dependencies only when isolation is the explicit goal.
Exercises
- Build a complete form test matrix.
- Test Query cache reuse between two consumers.
- Write optimistic concurrency rollback test.
- Test direct nested route with memory router.
- Add accessibility scan + manual keyboard checklist.
- Add Playwright network failure scenario.
- Configure contract fixture to match backend schema.
- Investigate one intentionally flaky test using traces rather than increasing timeout.
Mastery check
Explain:
- behavior testing;
- jsdom limitations;
- MSW boundary value;
- Query test isolation;
- optimistic concurrency testing;
- accessibility manual requirements;
- risk-based E2E strategy.
Production case study: test the state-owner boundaries, not only individual components
For the final Task Workspace, write a test that exercises several owners together:
URL status=open → Query requests open tasks → response shows one task → user completes task → mutation optimistic state appears → server succeeds → list query invalidates → refreshed response has zero open tasks → Empty state appears → URL remains status=open
This proves:
- Router owns filter;
- Query key uses filter;
- mutation updates/invalidation work;
- empty state is correct;
- no Redux/local copy keeps stale task visible.
A component-only test for TaskRow cannot prove this architecture.
An E2E test could prove even more, but an integration test with memory router + real QueryClient + MSW is faster and still exercises critical boundaries.
Use the cheapest test level that proves the risk.
Additional depth: testing Suspense, Actions, and Error Boundaries
Suspense
Render with a controlled Promise/resource.
Assert:
fallback visible resolve content visible
Avoid relying on arbitrary time.
Error Boundary
Use a test component:
function Broken({ fail }) {
if (fail) {
throw new Error('Boom');
}
return <p>Working</p>;
}
Wrap in boundary and assert fallback.
Suppress expected console noise only within the test and restore it afterward.
Action/form pending
Submit via userEvent.
Assert:
button pending/disabled status visible server resolves success state
For server validation:
submit MSW returns 422 field error associated draft preserved
Activity
If testing hidden Activity behavior, assert user-observable behavior/state preservation rather than internal Effect scheduling unless scheduling is exactly what your component contract depends on.
React Compiler
Do not unit-test "compiler memoized this component" as application behavior.
Compiler correctness is tooling responsibility.
Test performance-sensitive product behavior through profiling/benchmarks where necessary, not fragile render-count assertions.
