Testing Form Error Recovery and Retry
The moment a submission fails is the moment a form is most likely to lose a user. They have typed everything, pressed the button, and something went wrong — and what the form does next decides whether they try again or leave. A good form keeps every value, says precisely what went wrong and where, puts focus on the problem, and lets them retry without creating a duplicate. A bad form clears itself, shows “Something went wrong”, or silently submits twice. This guide covers testing each kind of failure a form must survive — field errors from server validation, form-level rejections, network failures and timeouts — and the recovery behaviour that follows, using MSW v2 and user-event. It sits under form and validation testing.
Root Cause Analysis
Error recovery is under-tested because it requires a failure the happy-path mock never produces. Most form tests intercept the submission with a handler that always succeeds, so the error-handling branch of the submit function — often the longest and most complex branch — never runs. When it finally runs in production, bugs that would have been obvious in a test surface as support tickets.
The failures come in distinct shapes, and each needs its own handling. Server-side field validation returns a 422 with errors keyed by field, which must be mapped back onto the right inputs. A form-level rejection — “this offer has expired”, “your session timed out” — has no field and needs a summary message. A network failure has no response at all, and a timeout looks like a network failure after a long wait. Treating all four as “show a generic error” loses the information users need to recover.
The retry is where the subtlest bug lives. A user who submits, sees a network error and presses submit again may create two records if the first request actually reached the server before the connection dropped. Preventing that requires an idempotency key that stays the same across the retry, and only a test that retries can verify it.
Reproducible Setup
A payment details form that submits with an idempotency key generated once per form session and maps server responses onto its fields.
// src/billing/BillingForm.tsx (excerpt)
const idempotencyKey = useMemo(() => crypto.randomUUID(), []);
async function submit(values: Billing) {
setFormError(null);
try {
const res = await fetchWithTimeout('/api/billing', {
method: 'POST',
headers: { 'content-type': 'application/json', 'idempotency-key': idempotencyKey },
body: JSON.stringify(values),
}, 10_000);
if (res.status === 422) {
const { errors } = await res.json();
for (const [field, message] of Object.entries(errors)) setError(field as keyof Billing, { message: String(message) });
return;
}
if (!res.ok) { setFormError('We could not save your details. Please check them and try again.'); return; }
onSaved();
} catch {
setFormError('We could not reach our servers. Your details are still here — try again when you are back online.');
}
}
// test/msw/billing.ts — one handler per failure shape
import { http, HttpResponse, delay } from 'msw';
export const billing = {
ok: () => http.post('/api/billing', () => HttpResponse.json({ ok: true })),
fieldError: () => http.post('/api/billing', () =>
HttpResponse.json({ errors: { vatNumber: 'This VAT number is not registered' } }, { status: 422 })),
rejected: () => http.post('/api/billing', () => new HttpResponse(null, { status: 409 })),
offline: () => http.post('/api/billing', () => HttpResponse.error()),
hang: () => http.post('/api/billing', async () => { await delay('infinite'); return HttpResponse.json({}); }),
};
Implementation
Step 1 — Map a server field error onto its input, with input kept. The error must be associated with the right field and the user’s value must remain.
// src/billing/BillingForm.test.tsx
test('shows the server’s VAT error on the VAT field and keeps the value', async () => {
server.use(billing.fieldError());
const user = userEvent.setup();
render(<BillingForm onSaved={vi.fn()} />);
await fillBilling(user, { vatNumber: 'GB000000000' });
await user.click(screen.getByRole('button', { name: 'Save billing details' }));
const vat = await screen.findByLabelText('VAT number');
expect(vat).toHaveAccessibleDescription('This VAT number is not registered');
expect(vat).toHaveValue('GB000000000');
expect(vat).toHaveFocus();
});
Step 2 — Show a form-level message for rejections without a field. The message should be announced and should not wipe the form.
test('explains a rejection that is not about one field', async () => {
server.use(billing.rejected());
const user = userEvent.setup();
render(<BillingForm onSaved={vi.fn()} />);
await fillBilling(user);
await user.click(screen.getByRole('button', { name: 'Save billing details' }));
expect(await screen.findByRole('alert')).toHaveTextContent('We could not save your details');
expect(screen.getByLabelText('Company name')).toHaveValue('Analytical Engines Ltd');
});
Step 3 — Offer a retry after a network failure, and succeed on it. The recovery path is only proven when the second attempt works.
test('recovers from a network failure when the user retries', async () => {
server.use(billing.offline());
const onSaved = vi.fn();
const user = userEvent.setup();
render(<BillingForm onSaved={onSaved} />);
await fillBilling(user);
await user.click(screen.getByRole('button', { name: 'Save billing details' }));
expect(await screen.findByRole('alert')).toHaveTextContent('could not reach our servers');
server.use(billing.ok());
await user.click(screen.getByRole('button', { name: 'Save billing details' }));
await waitFor(() => expect(onSaved).toHaveBeenCalledOnce());
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
Step 4 — Assert the retry reuses the idempotency key. Capture the header on every attempt; they must all match, so a request that did reach the server before the failure is not applied twice.
test('retries with the same idempotency key', async () => {
const keys: string[] = [];
let attempt = 0;
server.use(http.post('/api/billing', ({ request }) => {
keys.push(request.headers.get('idempotency-key')!);
return ++attempt === 1 ? HttpResponse.error() : HttpResponse.json({ ok: true });
}));
const user = userEvent.setup();
render(<BillingForm onSaved={vi.fn()} />);
await fillBilling(user);
const save = screen.getByRole('button', { name: 'Save billing details' });
await user.click(save);
await screen.findByRole('alert');
await user.click(save);
await waitFor(() => expect(keys).toHaveLength(2));
expect(keys[0]).toBe(keys[1]);
});
Step 5 — Never leave the form stuck on “Saving…”. A hung request must end in an error the user can act on; fake timers advance past the client’s timeout without waiting.
test('a hung request ends in a retryable error, not an endless spinner', async () => {
vi.useFakeTimers();
server.use(billing.hang());
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<BillingForm onSaved={vi.fn()} />);
await fillBilling(user);
await user.click(screen.getByRole('button', { name: 'Save billing details' }));
expect(screen.getByRole('button', { name: 'Saving…' })).toBeDisabled();
await act(() => vi.advanceTimersByTimeAsync(10_000));
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save billing details' })).toBeEnabled();
vi.useRealTimers();
});
Step 6 — Clear stale errors on the next attempt. A form-level error from the first failure should disappear when the user retries, so it is never shown alongside a later success.
Keeping one handler per failure shape in a shared module turns these five tests into a template for every important form. A new form gets the same five tests by swapping the endpoint and the field names, and the failure shapes stay consistent across the application — which also keeps the user-facing messages consistent, since the same shapes lead to the same wording everywhere.
Verification
Confirm the preservation assertions matter by making the submit handler reset the form in a finally block. The field-error and rejection tests must fail on the value assertions — the exact regression users experience as “it deleted everything”.
npx vitest run src/billing/BillingForm.test.tsx --reporter=verbose
# ✓ shows the server’s VAT error on the VAT field and keeps the value
# ✓ explains a rejection that is not about one field
# ✓ recovers from a network failure when the user retries
# ✓ retries with the same idempotency key
# ✓ a hung request ends in a retryable error, not an endless spinner
Then confirm the key test is meaningful by generating the key inside submit rather than once per form. The two captured keys will differ and the test must fail.
Troubleshooting
Symptom: the field error from the server never appears. Diagnosis: the server’s field names differ from the form’s — vat_number versus vatNumber. Fix: map names explicitly in one function and test it with the server’s actual response shape, captured from a real 422.
Symptom: the retry test sees only one request. Diagnosis: the submit button stayed disabled after the failure, so the second click did nothing. Fix: reset the submitting state in every error branch, which the hung-request test also verifies.
Symptom: a stale alert remains after a successful retry. Diagnosis: the form-level error is cleared only on success, not at the start of each attempt. Fix: clear it when a submission begins, and assert its absence after the retry.
Symptom: the timeout test hangs. Diagnosis: fake timers were installed after the request started, or user-event was not told to advance them. Fix: install fake timers first and pass advanceTimers to userEvent.setup.
FAQ
Should the form retry automatically?
For idempotent reads, a quiet automatic retry is fine. For submissions that create or charge something, let the user decide, with the key keeping it safe; automatic retries of writes hide failures and can surprise users who have already left the page.
How do I decide which failures map to which message?
By what the user can do about each. Field errors tell them what to change; form-level rejections tell them what happened and what to try; network failures reassure them their input is safe and invite a retry. If two failures lead to the same action, they can share a message.
Is this the same as testing loading and error states in data fetching?
The mechanics overlap — MSW failure handlers, fake timers — but forms add preserved input and idempotent retry. For read-side error states, see testing loading and error states deterministically.
Where should the failure handlers live?
In a shared module per endpoint, as in the setup, so every form test can switch shapes with one line. The generic versions — network error, hang, gateway HTML — belong in the shared test utilities alongside those described in simulating network errors and timeouts with MSW.
Related
- Back to Form & Validation Testing
- Testing React Hook Form submissions — the submission path these failures interrupt.
- Stubbing retry and backoff logic deterministically — automatic retries, where they belong.
- Testing webhook handlers with signed payloads — idempotency on the receiving side.