Testing Loading and Error States Deterministically
The happy path is the state a component spends the least time designing for and the most time tested in. Loading indicators flash by too quickly to see locally, error states appear only when a staging server misbehaves, and partial-failure states — half a dashboard loaded, the other half failed — are rarely seen at all before users see them. Each is a real state users encounter, with real accessibility requirements, and each can be produced on demand in a test once the response’s timing and outcome are under the test’s control. This guide covers holding a request open to assert on loading, scripting every failure shape, testing skeletons and busy indicators accessibly, retry controls, and partial failures across several independent requests. It sits under data fetching and cache testing.
Root Cause Analysis
Loading states are untested because they are transient. Against a local mock that responds in a millisecond, the loading state renders for one frame and is replaced before an assertion can run — or, worse, a test asserts on it and passes or fails depending on scheduling. Teams respond by not testing it, and the loading state then ships without an accessible label, or with a layout shift, or not at all when a refactor removes it.
Error states are untested because the mock never fails. The fetcher’s error branch, the component’s error rendering and the retry control all go unexecuted, and the first time they run is in production — which is how “undefined is not a function” ends up displayed to users in place of a helpful message.
Partial failures are untested because they require several requests with different outcomes. A dashboard with four panels fetching independently can show three and fail one, and the right behaviour — the three render, the fourth shows its own error with its own retry — is invisible to a test that only makes all requests succeed or all fail.
Reproducible Setup
A deferred-response helper for MSW handlers, so a test can release a response exactly when it chooses.
// test/msw/deferred.ts
export function deferred<T>() {
let resolve!: (v: T) => void;
const promise = new Promise<T>((r) => { resolve = r; });
return { promise, resolve };
}
// src/dashboard/RevenuePanel.tsx (excerpt)
export function RevenuePanel() {
const { data, isPending, isError, refetch } = useQuery({ queryKey: ['revenue'], queryFn: getRevenue });
if (isPending) return <section aria-label="Revenue" aria-busy="true"><div role="progressbar" aria-label="Loading revenue" /></section>;
if (isError) return (
<section aria-label="Revenue">
<p role="alert">Revenue could not be loaded.</p>
<button onClick={() => refetch()}>Retry revenue</button>
</section>
);
return <section aria-label="Revenue" aria-busy="false"><p>{formatMoney(data.totalPence)}</p></section>;
}
Implementation
Step 1 — Hold the response and assert on the loading state. The handler awaits the deferred promise; the test asserts on loading, then releases.
test('shows an accessible loading state until revenue arrives', async () => {
const gate = deferred<void>();
server.use(http.get('/api/revenue', async () => { await gate.promise; return HttpResponse.json({ totalPence: 1_250_000 }); }));
renderWithQuery(<RevenuePanel />);
const panel = screen.getByRole('region', { name: 'Revenue' });
expect(panel).toHaveAttribute('aria-busy', 'true');
expect(screen.getByRole('progressbar', { name: 'Loading revenue' })).toBeInTheDocument();
gate.resolve();
expect(await screen.findByText('£12,500.00')).toBeInTheDocument();
expect(panel).toHaveAttribute('aria-busy', 'false');
});
The aria-busy attribute is the assertion that matters most here and the one most often missing. Screen readers use it to avoid announcing a region’s content while it is changing, and to tell users something is happening; a visual spinner alone communicates nothing to someone who cannot see it. Asserting that the attribute flips from true to false also proves the component leaves the loading state cleanly rather than stacking the data beneath a spinner that never disappears.
Step 2 — Script each failure shape. A server error, an unreachable network and a malformed body produce different code paths; each should end in the same user-facing error state, not a crash.
test.each([
['a server error', () => new HttpResponse(null, { status: 500 })],
['a network failure', () => HttpResponse.error()],
['an unparseable body', () => new HttpResponse('<html>oops</html>', { headers: { 'content-type': 'text/html' } })],
])('shows the error state for %s', async (_label, respond) => {
server.use(http.get('/api/revenue', respond));
renderWithQuery(<RevenuePanel />);
expect(await screen.findByRole('alert')).toHaveTextContent('Revenue could not be loaded.');
});
The third row is the one that catches real bugs. An error page from a proxy or gateway returns HTML with a success-looking content type or an error status the fetcher does not check, and a fetcher that calls res.json() unconditionally throws a parse error rather than a clean application error. Depending on how that error propagates, the component may show the intended message or a crash. Parameterising over failure shapes makes all three paths lead to the same, deliberate result.
Step 3 — Prove the retry control recovers. Fail first, succeed second, and click through the interface.
test('retry loads the revenue after a failure', async () => {
let attempt = 0;
server.use(http.get('/api/revenue', () => (++attempt === 1 ? new HttpResponse(null, { status: 503 }) : HttpResponse.json({ totalPence: 900 }))));
const user = userEvent.setup();
renderWithQuery(<RevenuePanel />);
await user.click(await screen.findByRole('button', { name: 'Retry revenue' }));
expect(await screen.findByText('£9.00')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
Step 4 — Test a partial failure across independent panels. One failing request must not take down the others.
test('a failing panel does not blank the rest of the dashboard', async () => {
server.use(
http.get('/api/revenue', () => new HttpResponse(null, { status: 500 })),
http.get('/api/orders/summary', () => HttpResponse.json({ count: 42 })),
http.get('/api/customers/summary', () => HttpResponse.json({ active: 310 })),
);
renderWithQuery(<Dashboard />);
expect(await screen.findByText('42 orders')).toBeInTheDocument();
expect(screen.getByText('310 active customers')).toBeInTheDocument();
expect(within(screen.getByRole('region', { name: 'Revenue' })).getByRole('alert')).toBeInTheDocument();
});
Scoping the error assertion with within is deliberate: an alert somewhere on the page is not enough, it must be inside the revenue region, and the other regions must contain their data. That is the precise statement of the behaviour — failures are contained — and it is what distinguishes a resilient dashboard from one that happens to render some text when something breaks.
Step 5 — Test that loading does not flash for fast responses. Components that delay the spinner to avoid flicker can be tested with a short hold under fake timers — the indicator must not appear before its threshold.
Step 6 — Test refetch states distinctly from first loads. A background refresh should keep the current data visible with a subtle busy indicator, not replace it with a full loading state.
test('keeps revenue visible while refreshing', async () => {
const client = createTestQueryClient();
client.setQueryData(['revenue'], { totalPence: 100 });
const gate = deferred<void>();
server.use(http.get('/api/revenue', async () => { await gate.promise; return HttpResponse.json({ totalPence: 200 }); }));
renderWithQuery(<RevenuePanel />, client);
expect(screen.getByText('£1.00')).toBeInTheDocument();
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
gate.resolve();
expect(await screen.findByText('£2.00')).toBeInTheDocument();
});
Distinguishing first load from refresh is a user-experience decision worth pinning. Replacing visible data with a full-panel spinner every time the cache refreshes makes an interface feel slower than it is and causes layout shift, while keeping data visible and showing a subtle indicator feels instant. The test above makes the chosen behaviour explicit, so a later change to the component’s loading logic cannot quietly reintroduce the jarring version.
Verification
Confirm the loading test is deterministic by running it many times; with a deferred response it passes every run, where a timing-based version would flake.
for i in $(seq 1 20); do npx vitest run src/dashboard/RevenuePanel.test.tsx --silent || echo "run $i failed"; done
# (no output)
Then confirm the failure-shape table protects against crashes by making the fetcher call res.json() without checking the status. The unparseable-body case must fail — it now throws a parse error that bypasses the component’s error handling — which proves the table covers a real code path.
Troubleshooting
Symptom: the loading assertion fails intermittently. Diagnosis: the handler responds immediately, so loading is gone by the assertion. Fix: hold the response with a deferred promise, as in Step 1, rather than relying on scheduling.
Symptom: the test never finishes after asserting loading. Diagnosis: the deferred promise was never resolved. Fix: always release gates in the test, or in afterEach for safety, so no request is left pending.
Symptom: an error test shows the error only after several seconds. Diagnosis: the query client retries. Fix: use a test client with retry: false, as the render helper does.
Symptom: the whole page shows an error when one panel fails. Diagnosis: an error boundary above the dashboard catches the panel’s error. Fix: that is a genuine finding — scope error handling to each panel, and keep the partial-failure test as its guard.
FAQ
Are skeleton screens worth testing?
Their presence and accessibility are: the region should be marked busy, and the skeleton should not be read out as content. Their exact shape is visual and belongs in visual regression tests, not assertions on markup.
How do I test a timeout message?
Use delay('infinite') in the handler and fake timers to advance past the client’s timeout, as described in simulating network errors and timeouts with MSW. The deferred helper here is for states the test chooses to end; infinite is for states the client must end itself.
Should error messages be identical across failure shapes?
Usually, from the user’s point of view — they cannot act differently on a 500 versus a network failure in a read-only panel. Where they can, as with offline detection, a distinct message is worth its own test.
Does this apply to Suspense-based components?
Yes, with the loading state being the Suspense fallback and errors going to an error boundary. Render inside the same boundaries production uses, and the deferred technique holds the fallback visible just the same.
Related
- Back to Data Fetching & Cache Testing
- Testing live region announcements — announcing loaded and failed states.
- Asserting streaming Suspense boundaries — loading states rendered by the server.
- Replacing arbitrary waits with deterministic conditions — why deferred beats sleep.