Forms and controls
Fill forms through labels and roles, test validation, handle native controls, and assert user-visible results.
Forms are where browser tests often pay for themselves. A form combines labels, validation, focus, JavaScript, network requests, redirects, error messages, and accessibility. A reliable form test should use the same contract a user depends on: labels, roles, visible copy, and results.
The safe default is:
label or role locator -> control-specific action -> visible assertion
Mental model: a form is a user contract
The DOM shape is not the contract. The user contract is:
- which field is being requested;
- which control changes which value;
- what validation says when input is wrong;
- what result appears after submit;
- whether the user can recover.
That is why form tests should start with labels and roles rather than CSS paths.
This also makes the test a useful product review. If the test cannot find "Email" by label, a keyboard or screen reader user may have the same problem. If the submit button has no stable name, the form may be visually clear but not accessible.
use function Playwright\Testing\expect;
$page->getByLabel('Email')->fill('ada@example.com');
$page->getByLabel('Password')->fill('correct-horse-battery-staple');
$page->getByRole('button', ['name' => 'Sign in'])->click();
expect($page)->toHaveURL('https://app.example.test/dashboard');
expect($page->getByRole('heading', ['name' => 'Dashboard']))->toBeVisible();
If the label changes and the test fails, that is useful information. A user-facing contract changed.
Fill real controls
Use the method that matches the control:
$page->getByLabel('Email')->fill('ada@example.com');
$page->getByLabel('Remember me')->check();
$page->getByLabel('Country')->selectOption('fr');
$page->getByLabel('Avatar')->setInputFiles(__DIR__.'/fixtures/avatar.png');
Each method expresses intent. check() means the control should become checked. selectOption() means a specific option should be selected. setInputFiles() avoids the native file chooser and sets the file input directly.
When you need to inspect a field value, use the locator value API deliberately:
$email = $page->getByLabel('Email');
$email->fill('ada@example.com');
if ('ada@example.com' !== $email->inputValue()) {
throw new RuntimeException('Email value was not preserved.');
}
Do not make value inspection the main assertion when the real behavior is a submitted result.
Test validation as behavior
Validation is not an implementation detail. It is part of the user experience, especially when it blocks progress.
$page->getByRole('button', ['name' => 'Create account'])->click();
expect($page->getByText('Email is required'))->toBeVisible();
expect($page->getByText('Password is required'))->toBeVisible();
Keep validation tests focused:
- one test for the empty form path;
- one test for an invalid but plausible value;
- one test for a server-side error when that error changes the UI;
- one happy-path test that proves a valid submit succeeds.
Avoid testing every validation rule through a browser when a lower-level test can cover the rule faster. Use browser tests for what the browser adds: focus, messages, disabled states, preserved values, redirects, and visible recovery.
That keeps the browser suite focused on integration risk rather than duplicating every validator.
A good validation test includes recovery when recovery is part of the promise:
$page->getByRole('button', ['name' => 'Create account'])->click();
expect($page->getByText('Email is required'))->toBeVisible();
$page->getByLabel('Email')->fill('ada@example.com');
$page->getByRole('button', ['name' => 'Create account'])->click();
expect($page->getByText('Password is required'))->toBeVisible();
That proves the form can guide the user forward, not only reject input.
Assert the output, not just the controls
After a submit, assert what the user receives:
$page->getByRole('button', ['name' => 'Create account'])->click();
expect($page->getByText('Check your email'))->toBeVisible();
expect($page->getByText('We sent a confirmation link'))->toBeVisible();
The input value is rarely the final product promise. The account was created, the error appeared, the checkout advanced, the profile saved, or the file was attached.
When the backend response matters, keep the browser assertion focused on what the user sees. Use API or network checks as supporting evidence, not as a replacement for the visible result. A successful HTTP response with no user feedback is still a broken form experience.
Handle custom controls carefully
Custom controls should still expose roles and accessible names:
$page->getByRole('combobox', ['name' => 'Country'])->click();
$page->getByRole('option', ['name' => 'France'])->click();
If a custom control cannot be found by role, label, text, or a stable product contract, ask whether the component is accessible. A test ID can be acceptable for highly dynamic widgets, but it should not hide that users and assistive technology cannot understand the control.
Use this order:
- label or role;
- visible text or placeholder;
- test ID as an explicit contract;
- CSS only when structure is the contract.
For a custom select, prefer testing the behavior a keyboard or screen reader user would rely on: the combobox opens, the option is visible, the selection is reflected in the UI. If that cannot be expressed through roles or visible text, the component may need product work before it needs test work.
File inputs are form controls
File upload belongs with forms, but it has its own rule: do not drive the operating system dialog.
$page->getByLabel('Invoice PDF')->setInputFiles(__DIR__.'/fixtures/invoice.pdf');
$page->getByRole('button', ['name' => 'Upload'])->click();
expect($page->getByText('invoice.pdf'))->toBeVisible();
expect($page->getByText('Upload complete'))->toBeVisible();
Use small fixtures. Keep them deterministic. If the file content matters, assert the visible result or the backend side effect separately.
For downloads, move to the files recipe or capture docs. Upload is form input. Download is usually an artifact or browser event. Keeping those concepts separate makes tests easier to debug.
Common mistakes
Using CSS for every field. CSS says how the form is built. Labels say what the user is doing.
Clicking custom controls by coordinates. That hides focus, keyboard, and accessibility problems.
Asserting only the value after submit. A field containing text does not prove the product accepted it.
Putting all validation in one browser test. It becomes slow and hard to diagnose. Split by behavior.
Using test IDs to bypass broken accessibility. A missing label is often a product bug.
Combining too many form states. A single test that covers empty fields, invalid values, valid submit, upload, and redirect will be slow and hard to diagnose. Split by the user decision point.
Verification checklist
- Can the main fields be located by label or role?
- Does each control use the matching action method?
- Does the test cover both happy path and one meaningful validation path?
- Does the final assertion prove a user-visible result?
- Are file fixtures small and deterministic?
- Are custom controls accessible enough to test like user-facing controls?
Go next
- Previous guide: Actions and input
- Next guide: Network and API testing
- Subject map: Forms, Files
- Copy tasks: Fill and submit a form, Upload a file
- API reference: Locator, FileChooser