Most form tests fill every field with valid data, click submit, and assert a success message. That path is real, but it's the one path users rarely take without a detour. The bugs live in the detours: the field that clears itself after a server error, the tab order that skips the submit button, the file input that accepts a 40 MB PDF it should reject.
This is a tour of the form behaviors worth testing past the happy path, each with a Playwright sketch you can adapt. A checklist at the end pulls it together.
Validation states are their own assertions
A required field left empty, an email missing its @, a password too short — each should produce a specific, visible message, and the form should not submit. Testing "it rejects bad input" is not enough. Test which message appears and where, because a form that shows a generic "Something went wrong" for every field is a usability bug even when the rejection technically works.
Assert on the message text tied to the field, not just that submission failed:
test('email field rejects malformed input inline', async ({ page }) => {
await page.goto('/signup');
await page.getByLabel('Email').fill('not-an-email');
await page.getByLabel('Password').fill('hunter2!');
await page.getByRole('button', { name: 'Create account' }).click();
// The right message, on the right field
await expect(page.getByText('Enter a valid email address')).toBeVisible();
// And the form did not advance
await expect(page).toHaveURL(/\/signup/);
});
Cover both client-side validation (fires before the request) and server-side validation (the email is valid but already taken). They surface differently — one blocks instantly, the other returns after a round trip — and a form often handles only one gracefully.
Error recovery: fix it and move on
The moment after a validation error is where forms fall apart. A user reads the message, corrects the one bad field, and resubmits. Two things must hold: the fields they already filled correctly are still filled, and correcting the bad field clears its error. Forms that wipe every input on a failed submit are still common, and they're infuriating.
test('recovers from a server rejection without losing input', async ({ page }) => {
await page.goto('/signup');
await page.getByLabel('Full name').fill('Casey Jordan');
await page.getByLabel('Email').fill('taken@example.com');
await page.getByLabel('Password').fill('hunter2!');
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByText('That email is already registered')).toBeVisible();
// The other fields survived the failed submit
await expect(page.getByLabel('Full name')).toHaveValue('Casey Jordan');
await expect(page.getByLabel('Password')).toHaveValue('hunter2!');
// Fixing the bad field and resubmitting succeeds
await page.getByLabel('Email').fill(`casey+${Date.now()}@example.com`);
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByText('Welcome, Casey')).toBeVisible();
});
Note the unique suffix on the retry email: any value the test creates should be unique per run — a timestamp, a UUID, whatever's cheap to generate — or the second run collides with the account the first run made and you're debugging a phantom failure.
Keyboard-only completion
A meaningful slice of users — and every screen-reader user — never touches the mouse to fill a form. The whole flow has to work from the keyboard: Tab reaches every field in a sensible order, Enter submits, focus lands somewhere useful after an error instead of jumping to the top of the page.
Playwright drives this directly. Walk the form with Tab and assert focus lands where you expect:
test('is completable with the keyboard alone', async ({ page }) => {
await page.goto('/signup');
await page.getByLabel('Full name').focus();
await page.keyboard.type('Casey Jordan');
await page.keyboard.press('Tab');
await expect(page.getByLabel('Email')).toBeFocused();
await page.keyboard.type(`casey+${Date.now()}@example.com`);
await page.keyboard.press('Tab');
await expect(page.getByLabel('Password')).toBeFocused();
await page.keyboard.type('hunter2!');
await page.keyboard.press('Enter'); // submit from within the form
await expect(page.getByText('Welcome, Casey')).toBeVisible();
});
Two things this catches: a div-with-a-click-handler masquerading as a button (unreachable by Tab, unactivated by Enter), and a broken tab order where a custom date picker or icon button drops focus somewhere unexpected. Both pass a mouse-only test and fail real keyboard users.
File uploads
File inputs are easy to skip because they feel fiddly to automate. They aren't. Playwright sets files directly on the input, no OS dialog involved:
test('accepts a valid upload and rejects an oversized one', async ({ page }) => {
await page.goto('/profile');
// Happy path: a small, allowed file
await page.getByLabel('Avatar').setInputFiles('fixtures/avatar.png');
await expect(page.getByText('avatar.png')).toBeVisible();
// The detour: wrong type, and too large
await page.getByLabel('Avatar').setInputFiles('fixtures/resume.pdf');
await expect(page.getByText('Only PNG or JPG images are allowed')).toBeVisible();
});
The behaviors that actually break: rejecting the wrong MIME type, enforcing a size cap, and clearing a previously selected file (setInputFiles([])). For drag-and-drop drop zones that hide the native input, either target the underlying <input type="file"> — it usually still exists — or drive the filechooser event. Test the rejection messages the same way you test form validation: the specific text, on the specific control.
Autofill interference
Browser autofill and password managers write into fields your test also writes into, and the order matters. A test that fills a field the browser then overwrites — or that submits before autofill settles — flakes intermittently in ways that are miserable to reproduce. This surfaces most on login and checkout forms, where autofill is aggressive.
You can't fully model a real password manager in a headless run, but you can defend against the class of bug it exposes: assert the field holds exactly what you typed right before you submit, so an unexpected overwrite fails loudly instead of silently.
await page.getByLabel('Email').fill('casey@example.com');
await page.getByLabel('Password').fill('hunter2!');
// Guard: confirm nothing rewrote the field between fill and submit
await expect(page.getByLabel('Email')).toHaveValue('casey@example.com');
await page.getByRole('button', { name: 'Sign in' }).click();
Also watch for autocomplete attributes that break expectations: a search box tagged autocomplete="off" that a manager still fills, or a one-time-code field that grabs the wrong stored value.
The checklist
Run every form under test through this list. Not all apply to every form, but each row is a bug someone has shipped.
| Area | Assert that... |
|---|---|
| Empty required fields | The specific field's message shows; form does not submit. |
| Malformed input | Client validation fires with the right message per field. |
| Server rejection | A valid-but-conflicting value returns a clear, field-level error. |
| Error recovery | Correct fields keep their values; fixing the bad one clears its error. |
| Keyboard flow | Tab reaches every control in order; Enter submits. |
| Focus after error | Focus moves to the first invalid field, not the page top. |
| File type / size | Wrong type and oversized files are rejected with a message. |
| Autofill | The field holds your value at submit time, not an overwrite. |
| Repeatability | Created values use a per-run unique token so reruns don't collide. |
You don't have to hand-write all nine. In TestVibe you describe each of these behaviors in plain language — "submitting with an already-registered email keeps the other fields filled and shows an error" — and it generates the Playwright test, then verifies it by actually running it in a cloud sandbox before you trust it. The honest part: a vague description gets a vague test, so the checklist above is exactly the specificity worth writing down.
Pick your most-used form and walk it through the nine rows before your next release. For the syntax behind these examples, see the Playwright and feature-file docs, or get early access and describe your first form flow.