Introduction
Form testing is where many automation suites stumble. We fill fields, submit, and wait for some vague network condition to settle, only to flake on Friday deployments. Writing reliable form tests: what developers should know means understanding that Playwright gives us precise tools for each interaction—and the discipline to use them correctly. Today, we'll walk through the patterns that keep your form tests stable, predictable, and fast.
Why form testing matters in production
Forms are the gatekeepers of user intent. A broken signup form or payment page doesn't just fail a test—it fails your business. When we automate form flows, we're validating the critical path: Can users register, log in, update profiles, or complete checkouts? The difference between a flaky form test and a reliable one often comes down to one thing: we must interact with form elements the way a user does, and we must verify the outcome users actually care about, not the implementation details underneath.
Most form tests fail because they either wait for the wrong signals or interact with fields in ways that don't match real behavior. Let's fix that.
fill() clears and types
Playwright's fill() method is deceptively simple. Unlike some other frameworks that just type into a field, fill() first clears the target input, then types the provided text. This matters because real users clear fields when they need to correct themselves, and your tests should too.
Here's a common scenario: you're testing an e-commerce checkout form. The user needs to enter an email, billing address, and phone number. Using fill() ensures each field starts clean:
await page.fill('input[name="email"]', 'customer@example.com');
await page.fill('input[name="address"]', '123 Main St');
await page.fill('input[name="phone"]', '+1-555-0123');The clarity here is important. fill() is idempotent—running it twice with the same value produces the same result. That's reliability. If you use click() then type() separately, you risk leaving old text behind if your selector doesn't focus the field correctly, or if the page has lag. fill() handles both focus and clearing as part of its contract.
One practical note: fill() will wait for the element to be ready (visible and enabled). If your form has conditional fields that appear only after previous answers, fill() will wait up to your timeout for that field to exist and be interactable. This is safer than manual focus-then-type sequences.
selectOption for dropdowns
Dropdowns and select elements trip up many teams. Some try to click the dropdown, find the option by text, and click it. Others attempt to interact with hidden `` elements using JavaScript. Both approaches are fragile. Playwright gives us selectOption(), which handles both native HTML `` elements and custom dropdown implementations (when they follow accessibility patterns).
Let's say you're testing a CRM system where users must select their industry and company size during onboarding:
// Native HTML select
await page.selectOption('select[name="industry"]', 'software');
// If you need to select by display text instead of value
await page.selectOption('select[name="company_size"]', { label: 'Enterprise' });The power of selectOption() is that it understands the semantics of selection. When you call it on a native ``, it finds the matching `
For custom dropdowns built with divs and JavaScript, selectOption() may not work directly. In those cases, you'll need to click to open, locate the option, and click it. But before you do that, check if the component exposes a `role="listbox"` and options with `role="option"`—if it does, you can often use the same selectOption() approach. If not, file a bug with your component team. Accessible dropdowns are testable dropdowns.
// Custom accessible dropdown
await page.selectOption('[role="combobox"]', 'lead_source_referral');
// Fallback for non-accessible custom dropdowns (less reliable)
await page.click('button[aria-label="Select region"]');
await page.click('[role="option"]:has-text("North America")');
await page.waitForTimeout(300); // Anti-pattern: should use stability signals insteadNotice the comment on the fallback. If you're clicking into custom dropdowns without accessibility markup, you're building a test that's brittle by design. Push back in code review or in your component discussions.
check() for checkboxes
Checkboxes and radio buttons should use check() and uncheck(), not click. These methods are explicit about intent and handle both native inputs and styled checkbox wrappers.
Imagine a settings form where users opt into email notifications, SMS alerts, and push notifications:
// Enable email notifications
await page.check('input[name="email_notifications"]');
// Disable SMS alerts
await page.uncheck('input[name="sms_alerts"]');
// Check by value if you have multiple checkboxes with same name
await page.check('input[name="channels"][value="push"]');The semantic difference matters. check() ensures the checkbox ends up checked, regardless of its current state. If you click() a checkbox, you're relying on its current state—if the test reruns and the checkbox is already checked, your click toggles it to unchecked. That's a source of flakiness.
check() is idempotent, just like fill(). Call it twice, the checkbox is checked twice, but the result is the same: checked.
For radio buttons within a group, use check() on the specific radio you want selected:
// User selects "Invoice" as payment method
await page.check('input[name="payment_method"][value="invoice"]');Assert success messages, not network details
This is where most teams go wrong. They fill a form, submit, then wait for a network request to complete or a loading spinner to disappear. Both are implementation details. What the user actually cares about—and what your test should verify—is whether the action succeeded from their perspective.
Consider a contact form submission. A weak test looks like this:
// Anti-pattern: waiting for network
await page.fill('input[name="name"]', 'Alice Chen');
await page.fill('input[name="message"]', 'Hello, I have a question.');
await page.click('button:has-text("Send")');
// This is wrong: waiting for a POST to complete
await page.waitForResponse(response => response.url().includes('/api/contact'));
// Test passes because the request happened, but did the user see success?A better test waits for the user-facing success state:
await page.fill('input[name="name"]', 'Alice Chen');
await page.fill('input[name="message"]', 'Hello, I have a question.');
await page.click('button:has-text("Send")');
// Wait for success message to appear (user sees this)
await page.waitForSelector('.success-message:has-text("Message sent successfully")');
// Or verify the form cleared and shows confirmation
await expect(page.locator('input[name="name"]')).toHaveValue('');
await expect(page.locator('.confirmation-heading')).toContainText('Thank you');The second approach is more resilient. If the backend is slightly slower one day, or the success message is added to the DOM asynchronously, your test still waits for the right signal. If the network request completes but the success message never appears (bug in the front-end), your test catches it. That's production-quality testing.
Sometimes you need to verify that a form submission succeeds and navigates to a new page or modal. In that case, combine content assertion with navigation assertion:
const [responsePromise] = await Promise.all([
page.waitForNavigation(),
page.click('button[type="submit"]')
]);
const response = await responsePromise;
// Verify we landed on the expected page
expect(response.url()).toContain('/checkout/success');
// Also verify the success content is visible
await expect(page.locator('h1')).toContainText('Order Confirmed');Common mistakes
Mistake 1: Using click() for form fields instead of fill() or selectOption(). This leaves old values in place, causes race conditions, and doesn't match user behavior. Solution: use the semantic method for the field type.
Mistake 2: Waiting for spinners or network requests instead of the success state. Your test passes when the implementation is done, not when the user sees success. Wait for the message, the page change, or the form reset—something the user observes.
Mistake 3: Not waiting at all before asserting form results. Some teams submit a form and immediately check for success without any wait. Use waitForSelector(), waitForNavigation(), or expect() with implicit waits. Never put form assertions in a tight loop without waiting.
Mistake 4: Interacting with dropdowns via JavaScript instead of the UI. This bypasses validation that the dropdown logic would run, and misses user-facing errors. Use selectOption() whenever possible.
Mistake 5: Clearing form state between tests inconsistently. If one test leaves a form in a filled state and the next test assumes it's empty, you'll see random failures. Either reset the form in your page object, or navigate to a fresh form instance at the start of each test.
Key takeaways
- Use fill() for text inputs. It clears and types, and it's idempotent. This is more reliable than click + type sequences.
- Use selectOption() for dropdowns and selects. It handles both native and accessible custom dropdowns, and it's semantic.
- Use check() and uncheck() for checkboxes and radio buttons. They guarantee the final state, regardless of the starting state.
- Assert user-facing outcomes, not implementation details. Wait for success messages, page navigation, or form reset—not network requests or loading spinners.
- Always wait before asserting. Use waitForSelector(), waitForNavigation(), or Playwright's built-in expect() retries. Never assume the DOM is ready immediately after an action.
- Test with real form values when possible. Use realistic email addresses, phone numbers, and addresses. This catches validation bugs early.






Comments
Loading comments…