Level 1 · Junior Automation · Practice 02

Fill a Form & Assert

Interact with a real search form, submit it, and assert the page responded the way you expected — using Playwright locators and web-first assertions.

1 Goal

By the end of this exercise you will have a Playwright test that opens DuckDuckGo, types a search query into the input, submits the form, waits for the results page, and asserts the URL contains your query plus at least one result link is visible. This is the smallest useful test for any form-driven feature — fill, submit, verify — and the pattern repeats for login forms, contact forms, and checkout flows.

Why DuckDuckGo? Google actively blocks test automation (you will get a captcha on the first run). DuckDuckGo does not — it is the standard example target for public form-automation tutorials.

2 Install the tool

If you completed Practice 01, you already have Node.js and Playwright installed — skip to step 3 below. If you are starting fresh, do these in order:

  1. Install Node.js 20 LTS Download from nodejs.org, run the installer, accept defaults. Then confirm in a new terminal:
    node --version
    npm --version
  2. Scaffold a Playwright project
    cd $HOME\Desktop
    mkdir pw-practice-02
    cd pw-practice-02
    npm init playwright@latest
    cd ~/Desktop
    mkdir pw-practice-02
    cd pw-practice-02
    npm init playwright@latest

    Choose TypeScript, tests folder, No GitHub Actions, Yes install browsers.

  3. Ensure browsers are present
    npx playwright install

3 Project setup

From the project root, remove the generated example and create your own spec file.

Remove-Item tests\example.spec.ts
Remove-Item tests-examples -Recurse
New-Item tests\search.spec.ts
rm tests/example.spec.ts
rm -rf tests-examples
touch tests/search.spec.ts

Open tests/search.spec.ts in your editor — you will fill it in the next step.

Before writing code, open DuckDuckGo in your browser and inspect the search input so you know what you are targeting:

  • Go to https://duckduckgo.com/
  • Right-click the search box → Inspect
  • You'll see it has name="q" and the role is combobox. That is how we will locate it.

4 Write the script

Paste this into tests/search.spec.ts:

import { test, expect } from '@playwright/test';

test('duckduckgo returns results for an NZ query', async ({ page }) => {
  await page.goto('https://duckduckgo.com/');

  const searchBox = page.getByRole('combobox', { name: /search/i });
  await searchBox.fill('Resync Consulting New Zealand');
  await searchBox.press('Enter');

  await expect(page).toHaveURL(/q=Resync\+Consulting\+New\+Zealand/);

  const firstResult = page.getByRole('link').filter({ hasText: /resync/i }).first();
  await expect(firstResult).toBeVisible({ timeout: 10_000 });

  await page.screenshot({ path: 'results.png', fullPage: true });
});

What each piece does:

  • page.goto('https://duckduckgo.com/') — opens a fresh tab on the homepage.
  • page.getByRole('combobox', { name: /search/i }) — locates the search input by its accessibility role, not by a brittle CSS class. Role-based locators survive redesigns far better than input.search-box__field--v2.
  • searchBox.fill('...') — clears any existing text and types the query in one call. Use this instead of type() for form fields — it's faster and idempotent.
  • searchBox.press('Enter') — submits the form the same way a user would. Works whether the form has an explicit submit button or relies on Enter key.
  • await expect(page).toHaveURL(/q=Resync.../) — a web-first assertion. Playwright polls the URL for up to 5 seconds, so you don't need to add page.waitForNavigation(). The regex lets the URL contain other query params before and after.
  • page.getByRole('link').filter({ hasText: /resync/i }).first() — find the first link on the results page whose text contains "resync" (case-insensitive). .filter() narrows a locator; .first() picks element zero.
  • toBeVisible({ timeout: 10_000 }) — wait up to 10 seconds for that link to render. Search results load async, so the default 5s can be tight on a slow connection.
  • page.screenshot(...) — saves a PNG for evidence. Future-you debugging a failing CI run will thank present-you for having this.
Do not point this test at Google. Google's bot detection will either serve a captcha on the first navigation or silently return a stripped page with no results — either way your assertion breaks. DuckDuckGo and example.com are the safe targets for learning.

5 Run & verify

Run it headed the first time so you can see what is happening:

npx playwright test --headed

You should see a Chromium window open, type the query, submit it, and close. Terminal output:

Running 3 tests using 3 workers

  3 passed (12.4s)

To open last HTML report run:

  npx playwright show-report

Open results.png in the project folder — you should see the DuckDuckGo results page with at least one Resync-related link visible.

For step-by-step debugging:

npx playwright test --ui

The UI runner shows the DOM snapshot at every step — click the searchBox.fill step to see exactly what the form looked like when the text was entered.

Pass criteria: exit code 0, URL assertion matches, the filtered link is visible before timeout, and results.png exists. If any one fails, the test fails — don't "work around" it.

6 Troubleshooting

Error: locator.fill: Error: strict mode violation: getByRole('combobox') resolved to 2 elements

DuckDuckGo has two search boxes on some layouts — one in the header, one in the centre. Playwright's strict mode refuses to act when a locator matches more than one element.

Fix: tighten the locator with .first() or a more specific name. Example: page.getByRole('combobox', { name: /search/i }).first(), or use the name="q" attribute: page.locator('input[name="q"]').first().

Assertion fails: Expected URL to match /q=Resync.../ but got https://duckduckgo.com/?t=h_&q=Resync...

Good news — that URL does contain q=Resync, the regex just has to allow for other parameters. Check you used a regex literal (/.../), not a plain string. A plain string would require an exact match.

Result link never becomes visible (timeout)

DuckDuckGo sometimes serves a cookie consent interstitial in certain regions. If you see the consent page in --headed mode, your test is stuck behind it.

Fix: dismiss the consent first. Add, before the search:

const consent = page.getByRole('button', { name: /accept/i });
if (await consent.isVisible().catch(() => false)) {
  await consent.click();
}
Works on my machine, fails in CI

Headless runs can behave differently — fonts, viewport size, timezone. Enable the trace to capture what happened:

npx playwright test --trace on

Then npx playwright show-report → click the failed test → open the trace. You will see a per-step timeline with screenshots.

7 Challenge

Stretch yourself

  1. Add a second assertion that checks the number of results shown is at least 5. Hint: await expect(page.getByRole('link').filter({ has: page.locator('h2') })).toHaveCount(5, { timeout: 10_000 }) — or use a count() call plus a manual check.
  2. Parameterise the query. Convert the test into a test.describe block and use ['Resync', 'Playwright', 'Auckland'] with forEach to run the same test three times with different search terms. Each must pass independently.

Bonus: add a test that visits https://example.com, asserts no form exists on the page (using await expect(page.locator('form')).toHaveCount(0)), and captures a screenshot. This is your first "negative" test — proving something is not there is a skill in itself.