Form Testing Patterns
Standard Playwright assertion patterns for TanStack Form validation. Copy these patterns for consistent E2E tests.
Standard Validation Test Pattern
Submit invalid data, then assert the error is visible in the DOM via aria-invalid or role="alert".
test('shows validation error for invalid email', async ({ page }) => {
// Navigate to the form
await page.goto('/crm/contacts/new');
// Fill in an invalid email
await page.getByLabel('Email').fill('not-an-email');
// Submit the form
await page.getByRole('button', { name: 'Create' }).click();
// Assert: error is visible in the DOM
// Strategy 1: Check aria-invalid on the input
await expect(page.getByLabel('Email')).toHaveAttribute(
'aria-invalid',
'true'
);
// Strategy 2: Check for error message text
await expect(
page.getByText('Enter a valid email address')
).toBeVisible();
});Dual Assertion Strategy
Use both aria-invalid AND visible error text for maximum reliability.
// Both assertions together — belt and suspenders
async function assertFieldError(
page: Page,
fieldLabel: string,
errorMessage: string
) {
// 1. Input has aria-invalid
await expect(page.getByLabel(fieldLabel)).toHaveAttribute(
'aria-invalid',
'true'
);
// 2. Error message is visible in the DOM
await expect(page.getByText(errorMessage)).toBeVisible();
}
// Usage:
await assertFieldError(page, 'Email', 'Enter a valid email address');
await assertFieldError(page, 'Name', 'Name is required');Anti-Pattern: Browser Tooltip Validation
What happens without FormShell (noValidate):
- User submits a form with
<input type="email"> - Browser shows its own tooltip: “Please enter a valid email”
- TanStack Form's
onSubmitnever fires - No
aria-invalidis set on the input - No error message appears in the DOM
- Playwright sees nothing — the test passes with false confidence
// THIS TEST GIVES FALSE CONFIDENCE
test('validates email', async ({ page }) => {
await page.getByLabel('Email').fill('bad');
await page.getByRole('button', { name: 'Submit' }).click();
// With raw <form>: browser tooltip fires,
// but this assertion passes because no error
// element exists (it was never created).
// The test appears to pass but validates nothing.
const error = page.getByText('Invalid email');
// error doesn't exist — test might not even check it
});Complete CRUD Validation Test
Full pattern for testing a create form with multiple required fields.
test('prevents creation with empty required fields', async ({ page }) => {
await page.goto('/crm/contacts/new');
// Submit without filling anything
await page.getByRole('button', { name: 'Create Contact' }).click();
// Assert required field errors appear
await expect(page.getByLabel('First Name')).toHaveAttribute(
'aria-invalid', 'true'
);
await expect(page.getByLabel('Email')).toHaveAttribute(
'aria-invalid', 'true'
);
// Assert error messages are DOM-visible
await expect(page.getByText('First name is required')).toBeVisible();
await expect(page.getByText('Email is required')).toBeVisible();
// Assert we're still on the create page (no redirect)
await expect(page).toHaveURL(/\/contacts\/new/);
});
test('creates contact with valid data', async ({ page }) => {
await page.goto('/crm/contacts/new');
// Fill required fields
await page.getByLabel('First Name').fill('Jane');
await page.getByLabel('Last Name').fill('Doe');
await page.getByLabel('Email').fill('jane@example.com');
// Submit
await page.getByRole('button', { name: 'Create Contact' }).click();
// Assert redirect to contacts list
await expect(page).toHaveURL(/\/crm\/contacts$/);
});