Handling Forms, Dropdowns, and File Uploads

8 min read

Forms are where QA tests earn their keep — every checkout, every signup, every settings page lives or dies on whether the form does the right thing with the right inputs. This lesson pulls together everything from the previous three — locators, actions, assertions — into a complete pattern for testing forms in Playwright Python: text inputs, native and custom dropdowns, file uploads, date pickers, autocomplete, and the all-important invalid path — what happens when the user submits with bad input.

A complete form interaction example

The shape of every form test:

from playwright.sync_api import Page, expect
 
def test_registration_form(page: Page):
    page.goto("/register")
 
    # Text inputs
    page.get_by_label("Full name").fill("Alice Smith")
    page.get_by_label("Email").fill("alice@test.com")
    page.get_by_label("Password").fill("SecurePass123")
 
    # Native select dropdown
    page.get_by_label("Country").select_option(label="United Kingdom")
 
    # Custom dropdown (div-based combobox)
    page.get_by_role("combobox", name="Role").click()
    page.get_by_role("option", name="Tester").click()
 
    # Checkboxes — idempotent
    page.get_by_label("I agree to the terms").check()
    page.get_by_label("Subscribe to newsletter").uncheck()
 
    # Radio buttons
    page.get_by_label("Monthly plan").check()
 
    # File upload
    page.get_by_label("Profile photo").set_input_files("photo.jpg")
 
    # Submit
    page.get_by_role("button", name="Register").click()
 
    # Verify the happy path
    expect(page).to_have_url("/welcome")
    expect(page.get_by_text("Welcome, Alice")).to_be_visible()

Read it as a flow: text → dropdown → custom combobox → checkbox → radio → file → submit → assert. Every input type the form has, in the order a user would tab through them.

Date inputs — ISO format is the universal currency

Native <input type="date"> fields take an ISO date string:

page.get_by_label("Date of birth").fill("1990-01-15")

fill works because the browser parses the ISO string into the date picker's internal representation. For native datetime-local inputs, use ISO datetime:

page.get_by_label("Appointment time").fill("2024-12-15T14:30")

Custom date pickers (libraries like Material UI's DatePicker, react-day-picker, etc.) usually render as a button that opens a calendar. For those, click the trigger, then click the day:

page.get_by_role("button", name="Pick a date").click()
page.get_by_role("button", name="January 15, 1990").click()

The exact accessible name varies per library — use the Inspector to discover it once.

Autocomplete and combobox patterns

The standard pattern: type to filter, click the option that appears.

# Type 3 characters to trigger the autocomplete
page.get_by_role("combobox", name="City").fill("Lon")
 
# Click the option that the autocomplete renders
page.get_by_role("option", name="London, UK").click()

If the autocomplete needs a moment to render its dropdown, you don't need a manual wait — get_by_role("option") won't match anything until the option is actually in the DOM, and the auto-wait on the next click handles it.

For autocompletes that demand realistic typing speed (some have a debounce on every keystroke), use type() with a delay instead of fill:

page.get_by_role("combobox", name="City").type("London", delay=80)
page.get_by_role("option", name="London, UK").click()

Multiple file upload

set_input_files accepts a list:

page.get_by_label("Attachments").set_input_files(["doc1.pdf", "doc2.pdf", "image.png"])

The <input type="file" multiple> attaches all three. To clear an existing selection without picking new files:

page.get_by_label("Attachments").set_input_files([])

Drag-and-drop dropzones

Most "drag and drop your file here" zones in modern web apps are sugar on top of a hidden <input type="file">. You don't need to actually drag — find the input and use set_input_files:

# Even if the dropzone is a styled div, the underlying input still works
page.get_by_test_id("dropzone").locator("input[type=file]").set_input_files("report.pdf")

If the dropzone really is a custom drop handler (JS listening to drop events with no input), you'll need page.dispatch_event(...) or a real DataTransfer — those are corner cases. 95% of dropzones in real apps fall through to a hidden file input.

Form validation testing — the invalid path

A complete form test checks both the happy path and the failure modes:

from playwright.sync_api import Page, expect
 
class TestRegistrationFormValidation:
    def test_empty_form_shows_required_errors(self, page: Page):
        page.goto("/register")
        page.get_by_role("button", name="Register").click()
        expect(page.get_by_text("Name is required")).to_be_visible()
        expect(page.get_by_text("Email is required")).to_be_visible()
        expect(page.get_by_text("Password is required")).to_be_visible()
 
    def test_invalid_email_shows_format_error(self, page: Page):
        page.goto("/register")
        page.get_by_label("Full name").fill("Alice")
        page.get_by_label("Email").fill("not-an-email")
        page.get_by_label("Password").fill("SecurePass123")
        page.get_by_role("button", name="Register").click()
        expect(page.get_by_text("Enter a valid email")).to_be_visible()
 
    def test_short_password_shows_length_error(self, page: Page):
        page.goto("/register")
        page.get_by_label("Full name").fill("Alice")
        page.get_by_label("Email").fill("alice@test.com")
        page.get_by_label("Password").fill("abc")
        page.get_by_role("button", name="Register").click()
        expect(page.get_by_text("Password must be at least 8 characters")).to_be_visible()
 
    def test_unchecked_terms_blocks_submission(self, page: Page):
        page.goto("/register")
        page.get_by_label("Full name").fill("Alice")
        page.get_by_label("Email").fill("alice@test.com")
        page.get_by_label("Password").fill("SecurePass123")
        # do NOT check the terms box
        page.get_by_role("button", name="Register").click()
        expect(page.get_by_text("You must accept the terms")).to_be_visible()

Each test isolates one validation rule. Together they form the contract the form promises to enforce. Bug fixes that loosen validation will fail one of these tests; bug fixes that tighten validation might fail the happy-path test. Either signal is useful.

Form testing flow

The five steps map to a typical form-feature ticket. Step 5 is the most often skipped — a form can show "Success!" while the underlying API call failed silently. We'll cover the API-side verification properly in chapter 4.

Pythonic test data — dataclass and parametrize

For a form test that runs the same flow with multiple sets of bad inputs, combine dataclass (from the Python for QA course) with pytest.mark.parametrize:

from dataclasses import dataclass
import pytest
from playwright.sync_api import Page, expect
 
@dataclass
class InvalidInput:
    name: str
    email: str
    password: str
    expected_error: str
 
INVALID_CASES = [
    InvalidInput("", "alice@test.com", "SecurePass123", "Name is required"),
    InvalidInput("Alice", "not-an-email", "SecurePass123", "Enter a valid email"),
    InvalidInput("Alice", "alice@test.com", "abc", "Password must be at least 8 characters"),
]
 
@pytest.mark.parametrize("case", INVALID_CASES, ids=lambda c: c.expected_error)
def test_form_rejects_invalid_input(page: Page, case: InvalidInput):
    page.goto("/register")
    page.get_by_label("Full name").fill(case.name)
    page.get_by_label("Email").fill(case.email)
    page.get_by_label("Password").fill(case.password)
    page.get_by_role("button", name="Register").click()
    expect(page.get_by_text(case.expected_error)).to_be_visible()

One test function, three test runs, three readable failure names (test_form_rejects_invalid_input[Name is required]). Adding a fourth invalid case is one line — add a new InvalidInput to the list, no copy-paste of the test body. This is the kind of leverage Python's structural features unlock that the TS course's table-test syntax can't quite match.

Coming from Playwright TypeScript?

The form-action mappings:

  • selectOption({ label: 'UK' })select_option(label="UK")
  • setInputFiles('file.pdf')set_input_files("file.pdf")
  • setInputFiles({ name, mimeType, buffer })set_input_files({"name": ..., "mimeType": ..., "buffer": ...})
  • check() / uncheck() → identical
  • TS table tests via for (const tc of cases) test(...) → Python @pytest.mark.parametrize("case", ...) plus dataclass

The Python parametrize + dataclass pattern is the closest analogue to TS's data-driven test loops, and arguably more readable — the test ID in the report comes from the dataclass field, not from ${name}-string interpolation.

⚠️ Common mistakes

  • Calling select_option on a custom dropdown. It only works on real <select> tags. Material UI, Headless UI, Radix render <div role="combobox"> and you need click-to-open then click-the-option. The error message — Element is not a <select> element — is unmistakable; just don't waste 20 minutes debugging it.
  • Using set_input_files on a non-input dropzone. If the dropzone has a hidden <input type=file> underneath (most do), find the input first: dropzone.locator("input[type=file]").set_input_files(...). If the page truly only listens to drop events with no input fallback, you'll need page.dispatch_event with a fabricated DataTransfer — corner case worth knowing exists, but rarely needed.
  • Testing only the happy path. A form test that fills valid data and asserts success has caught the obvious bug — the form works at all. The failure-mode tests catch real regressions: the validation rule that quietly stopped firing, the password complexity check that loosened by accident. A form ticket should always ship with both happy and unhappy tests.

🎯 Practice task

Build a complete form-test suite for Sauce Demo's checkout flow — happy path plus four validation cases. 30 minutes.

  1. Create tests/test_checkout_form.py. Reuse the autouse login fixture from the previous lessons (or inline it).

  2. Add a happy-path test that completes checkout end-to-end:

    from playwright.sync_api import Page, expect
     
    class TestCheckoutForm:
        def test_complete_checkout_happy_path(self, page: Page):
            page.locator(".inventory_item").filter(has_text="Backpack") \
                .get_by_role("button", name="Add to cart").click()
            page.locator(".shopping_cart_link").click()
            page.get_by_role("button", name="Checkout").click()
     
            page.get_by_placeholder("First Name").fill("Alice")
            page.get_by_placeholder("Last Name").fill("Smith")
            page.get_by_placeholder("Zip/Postal Code").fill("SW1A 1AA")
            page.get_by_role("button", name="Continue").click()
     
            page.get_by_role("button", name="Finish").click()
            expect(page.get_by_text("Thank you for your order")).to_be_visible()
  3. Add four invalid-path tests via parametrize and a dataclass:

    from dataclasses import dataclass
    import pytest
     
    @dataclass
    class InvalidCheckout:
        first_name: str
        last_name: str
        postcode: str
        expected_error: str
     
    INVALID_CASES = [
        InvalidCheckout("", "Smith", "SW1A 1AA", "First Name is required"),
        InvalidCheckout("Alice", "", "SW1A 1AA", "Last Name is required"),
        InvalidCheckout("Alice", "Smith", "", "Postal Code is required"),
        InvalidCheckout("", "", "", "First Name is required"),  # all missing → first error wins
    ]
     
    @pytest.mark.parametrize("case", INVALID_CASES, ids=lambda c: c.expected_error)
    def test_checkout_form_validation(page: Page, case: InvalidCheckout):
        # Get to the checkout form
        page.locator(".inventory_item").filter(has_text="Backpack") \
            .get_by_role("button", name="Add to cart").click()
        page.locator(".shopping_cart_link").click()
        page.get_by_role("button", name="Checkout").click()
     
        page.get_by_placeholder("First Name").fill(case.first_name)
        page.get_by_placeholder("Last Name").fill(case.last_name)
        page.get_by_placeholder("Zip/Postal Code").fill(case.postcode)
        page.get_by_role("button", name="Continue").click()
     
        expect(page.get_by_test_id("error")).to_contain_text(case.expected_error)
  4. Run the file. The happy path passes; the four parametrized cases each report their own pass/fail line, with the test ID showing the expected error message — test_checkout_form_validation[First Name is required], etc.

  5. Demonstrate the leverage. Add a fifth case to INVALID_CASES — e.g., a postcode that's clearly invalid format. Re-run the file. Five validation tests now run, no copy-paste of the test body needed.

  6. Stretch: find a public form with a file upload (e.g., a sandbox at https://the-internet.herokuapp.com/upload) and write two tests — one that uploads from disk with set_input_files("photo.jpg"), one that uploads in-memory with the buffer-dict form. Confirm both succeed without a real file existing on the CI runner.

You've completed the locators, actions, and assertions chapter — the foundation of every Playwright Python test you'll write. The next chapter shifts gears: pytest fixtures, conftest.py patterns, parametrize done properly, and the marker/tag conventions that let a 500-test suite stay maintainable.

// tip to track lessons you complete and pick up where you left off across devices.