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.

Four failure shapes and the recovery each needs A field validation error maps onto its input, a form-level rejection shows a summary message, a network failure offers a retry with input kept, and a timeout does the same after the client aborts; every case preserves what the user typed. 422 field errors map each onto its input focus the first one form-level rejection summary alert, no field explain what to do next network failure offer retry, keep input same idempotency key timeout abort, then offer retry never hang on "Saving…"
Each failure has a different recovery, and a single generic error message serves none of them well.

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]);
});
Why the idempotency key must survive a retry The first request reaches the server and is saved, but the response is lost; the retry carries the same key, so the server recognises it and returns the original result instead of creating a second record. attempt 1, key K saved on the server response lost user sees a network error retry, same key K server returns the original a new key on retry would create a second record only a test that retries can see the difference
The key is generated once per form, not once per request — the test pins exactly that.

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.

Properties every recovery test checks Input is preserved, the error is specific and associated, the form is usable again, and a retry neither duplicates the request nor keeps stale errors. input kept toHaveValue error specific linked or summarised usable again button re-enabled retry safe same key, errors cleared
Four properties, asserted after every failure shape — together they are what "recoverable" means.

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.