Design System

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):

  1. User submits a form with <input type="email">
  2. Browser shows its own tooltip: “Please enter a valid email”
  3. TanStack Form's onSubmit never fires
  4. No aria-invalid is set on the input
  5. No error message appears in the DOM
  6. 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$/);
});