How to Test Form Validation and Error States
Short answer
Form validation spans HTML5 hints, client libraries, async server checks, and accessibility announcements—green submit buttons are not proof of valid data. Test server rejection via API Arrange even when the UI blocks input, assert aria-invalid and error associations for a11y, and probe persisted records instead of toast copy that changes with i18n.
Part of Testing Guides by UI patterns.
Who this is for
Teams shipping multi-field forms—onboarding wizards, checkout, admin CRUD, insurance quote steps, HR applications—where invalid data causes downstream billing errors, compliance failures, or ADA lawsuits. Typical stacks: React Hook Form + Zod, Formik, Angular reactive forms, or server-rendered HTML with progressive enhancement.
Why testing form validation matters
Validation bugs rarely look like "broken CSS." They show up as:
- Bad data in production — users bypass client checks via DevTools or direct API calls; orphaned records break reporting and integrations.
- Revenue loss — checkout accepts invalid card metadata; trial signup stores malformed company domains that fail provisioning silently.
- Support load — async uniqueness checks ("email taken") race with double-submit; users see success while server rejected.
- Accessibility lawsuits — errors only shown as red borders without
aria-describedby; screen reader users submit repeatedly without knowing why. - Compliance exposure — HR or healthcare forms accept out-of-range dates or missing required attestations that auditors flag later.
E2E must assert both user-visible error UX and authoritative server state—never toast text alone.
Complexity map
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
HTML5 required | Bypassed via formnovalidate or API | Invalid rows saved | POST invalid payload to API in Arrange |
| Client-only Zod/Yup | Server schema differs | UI passes, API 422 | Mirror negative cases on both layers |
| Async field validation | Debounced email check | Assert before response | expect.poll on error or probe |
| Cross-field rules | End date before start | Only one field highlighted | Probe rejection reason code |
| Conditional required | Field B required if A checked | Stale required state | Toggle A, assert B required attribute |
| Max length / pattern | Unicode grapheme count | Emoji length mismatch | Boundary strings in fixture table |
| Server 422 shape | { errors: { field: [] } } | UI maps wrong key | Probe + optional DOM check |
| Duplicate submit | Double-click Save | Two records | Assert single row via probe |
| i18n error copy | Message string changes | Flaky text assert | Probe status + aria-invalid |
| File type validation | MIME spoof | Malicious upload stored | setInputFiles with wrong extension |
| Masked inputs | Phone (555) vs digits stored | Wrong normalized value | Probe stored E.164 format |
| Disabled submit | Still clickable via Enter | Invalid submit | Keyboard submit + probe 400 |
Client vs server: test both layers
| Layer | What it proves | When to skip in E2E |
|---|---|---|
| UI validation | User sees errors before network | Never for primary flows |
| API validation | Authoritative rejection | Never—this is the compliance bar |
| Unit tests on schema | Exhaustive rule matrix | Keep E2E to representative negatives |
Pattern — API Arrange bypasses UI
// Arrange: post invalid payload directly (test-only route or public API)
const res = await request.post('/api/applications', {
data: { email: 'not-an-email', startDate: '2099-01-01' },
});
expect(res.status()).toBe(422);
const body = await res.json();
expect(body.errors.email).toBeDefined();
// UI path: same invalid data through form
await page.getByLabel('Email').fill('not-an-email');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByLabel('Email')).toHaveAttribute('aria-invalid', 'true');
Reserve full UI walks for error presentation and keyboard flow; use API Arrange for every field rule you cannot afford to miss in parallel CI.
Accessibility assertions
Screen reader users depend on programmatic error wiring:
const email = page.getByLabel('Email');
await email.fill('bad');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(email).toHaveAttribute('aria-invalid', 'true');
const describedBy = await email.getAttribute('aria-describedby');
expect(describedBy).toBeTruthy();
await expect(page.locator(`#${describedBy}`)).toContainText(/valid email/i);
Run ExploreChimp on long forms after locale switches—overflow and missing aria-live regions often appear only in translated layouts (explorations).
Async and debounced validation
await page.getByLabel('Username').fill('taken-user');
await expect.poll(async () => {
return page.getByRole('alert').filter({ hasText: /already taken/i }).count();
}).toBeGreaterThan(0);
Prefer polling error state over waitForTimeout. For uniqueness checks, seed a taken username in Arrange so the async path is deterministic.
Multi-step wizards
Wizards hide fields across steps—validation errors on step 3 may block step 1 navigation silently.
- Seed partial draft via API with invalid hidden field
- Navigate wizard to summary
- Probe submit blocked with step index in error payload
- UI: assert step indicator shows error badge
Link wizard scenarios with // @Scenario: in SmartTests for requirement traceability when forms map to compliance controls.
CI checklist
- Fixture table of invalid payloads per field (CSV or JSON in repo)
- At least one API-only negative per critical entity
- No assertions on exact error string copy—use codes or regex
- Parallel-safe: unique emails per
runIdfor uniqueness tests - Probe DB/API after submit—never trust client-only success toast
- File upload negatives use Playwright
setInputFileswith decoy files
Anti-patterns
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
| Only happy-path UI submit | Bypass paths untested | API Arrange invalid payloads |
| Assert toast message text | i18n and copy churn | Probe 422 + aria-invalid |
waitForTimeout after blur | Debounce timing varies | expect.poll on error |
| Skip accessibility checks | Legal and UX risk | aria-describedby on every error |
| One invalid field per suite | Combinatorial gaps | Table-driven negatives |
| Trust HTML5 alone | Trivially bypassed | Server probe always |
Example scenario
Situation: Applicant submits HR form with end date before start date.
Expected outcome: Submit blocked; both fields flagged; no application row created.
Why UI-only automation breaks: Red border on end date only; API accepts reversed range via direct POST.
- Arrange: Seed open requisition; leave application table empty for runId.
- Act: Fill dates reversed in UI; attempt submit; also POST same payload via request fixture.
- Assert: UI: both fields aria-invalid; probe POST returns 422 with cross_field error; probe zero applications for runId.
TestChimp workflow: Tag validation_error_type on client events; compare prod vs test distribution when expanding scenarios via /testchimp evolve.
Same Arrange/Act/Assert pattern as expired-coupon checkout.
Connect scenarios to your QA workflow
Capture business rules in markdown test plans and enforce them with seed routes and probe Assert. Link SmartTests with // @Scenario: for requirement traceability. Use /testchimp test on PRs; /testchimp explore on SmartTest paths for non-functional gaps (ExploreChimp).
Related scenarios
- SaaS onboarding flows — multi-step signup validation
- Search and filters — filter input validation
- Insurance quotes — wizard knock-out validation
- File uploads — MIME and size rules
- Localization — error message keys
External references
- Playwright locators and assertions
- WCAG error identification
- ARIA invalid states
- HTML form validation
Frequently asked questions
Should I test HTML5 validation or server validation?
Both. UI tests prove users see errors before submit; API Arrange posts invalid payloads to verify the server rejects even when client validation is bypassed via DevTools or direct HTTP.
How do I avoid flaky tests on debounced async validation?
Use expect.poll on aria-invalid or role=alert rather than fixed sleeps. Seed known conflict state in Arrange (e.g., email already exists) so async checks return deterministically.
Should E2E assert exact error message text?
Prefer probe status codes, error field keys, and aria attributes. Exact copy changes with i18n and product copy—use regex only when message semantics are the behavior under test.
How do I test file field validation?
Use page.setInputFiles with wrong extension or oversize fixture files, submit, probe 422 and confirm no object stored in blob bucket via test probe route.
How do multi-step wizards fit into a lean suite?
One E2E per critical cross-step rule; table-driven API negatives for field rules. Link SmartTests with // @Scenario: for compliance matrices in markdown test plans.
Can ExploreChimp help with form validation?
Yes—run localized and RTL paths through long forms to catch layout breaks that hide errors or clip aria-live regions; convert findings to SmartTests with shared Arrange seeds.
What should TrueCoverage track for forms?
Instrument validation_error_type and field_type on client or server events without PII. Compare prod vs test-run distributions to prioritize missing negative paths with /testchimp evolve.
Apply these patterns in your repo
Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.