Level 1 · Junior Automation Engineer

Junior Automation Answer Key

Challenge solutions, expected output, and the common mistakes that trip juniors up on each exercise.

Use this page after you've attempted the practice. The goal isn't to copy the solutions — it's to check your work and understand why the canonical answer is structured the way it is. Every snippet here was tested on Node 20 and Python 3.12 at the time of writing.

1 Practice 01 · Your First Playwright Test (open practice)

Install Playwright, hit playwright.dev, assert the title, take a screenshot.

Expected pass output

With a fresh install and the script from the exercise, npx playwright test runs your one test against three browsers because that's the default in the scaffolded playwright.config.ts:

Running 3 tests using 3 workers

  3 passed (6.2s)

To open last HTML report run:

  npx playwright show-report

You should also have a homepage.png in the project root that shows the full scrollable playwright.dev homepage, not just the viewport.

Challenge solution

The challenge asks for two extra assertions plus an optional second test. Here is the full tests/homepage.spec.ts with all three:

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

test('playwright homepage has the right title, heading and CTA', async ({ page }) => {
  await page.goto('https://playwright.dev/');

  await expect(page).toHaveTitle(/Playwright/);

  await expect(
    page.getByRole('heading', { name: /reliable end-to-end/i })
  ).toBeVisible();

  await expect(
    page.getByRole('link', { name: 'Get started' })
  ).toBeVisible();

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

test('example.com shows the Example Domain heading', async ({ page }) => {
  await page.goto('https://example.com');
  await expect(
    page.getByRole('heading', { name: 'Example Domain' })
  ).toBeVisible();
});

Why role locators? getByRole('heading', ...) and getByRole('link', ...) are what a screen reader would find. They break far less often than CSS classes like .hero__title--v3, which change every redesign. Role-based locators are the default recommendation in the Playwright docs for exactly this reason.

Why a single await expect(...).toBeVisible() and not a manual wait? expect polls automatically for up to 5 seconds. You never need page.waitForSelector in front of an assertion — the assertion is already the wait.

Common mistakes

  • Forgetting await on a Playwright call

    The #1 cause of mysterious failures. page.goto('...') without await returns a Promise that never resolves before the next line runs. Rule of thumb: if it comes from page, expect, or a locator, it needs await.

  • Using a string instead of a regex for toHaveTitle

    toHaveTitle('Playwright') requires an exact match. The real title is longer. toHaveTitle(/Playwright/) accepts any title that contains the word — which is what the exercise asks for.

  • Running the test from a parent folder

    Playwright looks for playwright.config.ts in the current directory. If you run npx playwright test from Desktop/ instead of pw-practice-01/, you'll see "No tests found". Always cd into the project folder first.

  • Expecting one "passed" line, seeing three

    The default config runs every test against Chromium, Firefox and WebKit. That's not a bug — cross-browser by default is Playwright's whole pitch. To run only one browser while learning: npx playwright test --project=chromium.

Self-check: you should be able to explain, out loud, what /Playwright/ is (a regex literal), why await is needed, and what three browsers Playwright just exercised. If any of those are fuzzy, re-read section 4 of the practice before moving on.

2 Practice 02 · Fill a Form and Assert (open practice)

Fill the DuckDuckGo search, submit, assert the URL and a visible result link.

Expected pass output

Running 3 tests using 3 workers

  3 passed (12.4s)

To open last HTML report run:

  npx playwright show-report

results.png should exist in the project folder and show a DuckDuckGo results page with at least one Resync-related link visible above the fold.

Challenge solution

The challenge has two parts — a count assertion, and parameterising the query. Here is the full tests/search.spec.ts:

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

const queries = ['Resync', 'Playwright', 'Auckland'];

test.describe('DuckDuckGo search returns results', () => {
  for (const query of queries) {
    test(`returns results for "${query}"`, async ({ page }) => {
      await page.goto('https://duckduckgo.com/');

      const searchBox = page.getByRole('combobox', { name: /search/i }).first();
      await searchBox.fill(query);
      await searchBox.press('Enter');

      await expect(page).toHaveURL(new RegExp(`q=${query}`, 'i'));

      // At least 5 result headings rendered
      const resultHeadings = page.locator('[data-testid="result-title-a"]');
      await expect(resultHeadings.first()).toBeVisible({ timeout: 10_000 });
      expect(await resultHeadings.count()).toBeGreaterThanOrEqual(5);

      await page.screenshot({ path: `results-${query}.png`, fullPage: true });
    });
  }
});

test('example.com has no form', async ({ page }) => {
  await page.goto('https://example.com');
  await expect(page.locator('form')).toHaveCount(0);
  await page.screenshot({ path: 'example.png' });
});

On for-of-test vs .forEach: both work, but for...of plays nicer with the type inference of test()'s async callback. Either is fine — the key is that each iteration defines a separate test, not one test that loops internally. Separate tests give you three independent pass/fail signals.

Why [data-testid="result-title-a"] for the count? Counting "links on the page" gives noisy numbers — header, footer, ads. DuckDuckGo renders result titles with a stable data-testid, which is exactly what test automation is supposed to use. Inspect the results page yourself to confirm before relying on it.

On the "no form" assertion: asserting something is absent is your first negative test. toHaveCount(0) is the idiom — it retries, so it survives late-rendering DOM. Do not write expect(await page.locator('form').count()).toBe(0) — that resolves the count once and loses the retry.

Common mistakes

  • Strict-mode violation on getByRole('combobox')

    DuckDuckGo sometimes has two search inputs (header + centre). Playwright refuses to act when a locator matches more than one. Fix: append .first() or anchor with page.locator('input[name="q"]').first().

  • Asserting toHaveURL('q=Resync+...') as a string

    A string means exact match, and the real URL has other query params like ?t=h_&q=.... Use a regex literal: toHaveURL(/q=Resync/).

  • Cookie consent interstitial swallowing clicks

    In some regions DuckDuckGo shows a consent modal. Your test sits behind it and times out. Add a dismiss step before the search: const consent = page.getByRole('button', { name: /accept/i }); if (await consent.isVisible().catch(() => false)) await consent.click();.

  • Using type() when fill() would do

    page.type() simulates keystrokes one at a time and doesn't clear existing text. page.fill() clears and sets the value in one call — faster and idempotent. Use type only when you actually need per-character keyboard events (autocomplete testing, etc.).

Self-check: run the parameterised version with npx playwright test --headed and watch three tests run in sequence — three browser windows, three screenshots, three pass lines. If only one shows, your test() declarations are inside the loop body but the loop is inside a single test() — re-check the nesting.

3 Practice 03 · Same Test in Selenium (Python) (open practice)

The Practice 02 scenario, ported to Selenium + Python with webdriver-manager.

Expected pass output

First run also downloads ChromeDriver (10–20 MB):

====== WebDriver manager ======
Get LATEST chromedriver version for google-chrome
Driver [~/.wdm/drivers/chromedriver/.../chromedriver] found in cache
PASS

Chrome pops up, performs the search, closes. results.png is written (viewport only — Selenium's save_screenshot isn't full-page).

Via pytest:

======================= test session starts =======================
collected 1 item

test_search.py::test_duckduckgo_returns_results_for_an_nz_query PASSED [100%]

======================= 1 passed in 8.42s ========================

Challenge solution

Headless + a proper pytest fixture. Here is the full test_search.py:

import pytest
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from webdriver_manager.chrome import ChromeDriverManager


@pytest.fixture
def driver():
    options = Options()
    options.add_argument("--headless=new")
    options.add_argument("--window-size=1280,900")
    service = Service(ChromeDriverManager().install())
    drv = webdriver.Chrome(service=service, options=options)
    yield drv
    drv.quit()


def test_duckduckgo_returns_results_for_an_nz_query(driver):
    driver.get("https://duckduckgo.com/")

    search_box = WebDriverWait(driver, 10).until(
        EC.element_to_be_clickable((By.NAME, "q"))
    )
    search_box.send_keys("Resync Consulting New Zealand")
    search_box.send_keys(Keys.RETURN)

    WebDriverWait(driver, 10).until(
        EC.url_contains("q=Resync+Consulting+New+Zealand")
    )
    assert "q=Resync+Consulting+New+Zealand" in driver.current_url

    first_result = WebDriverWait(driver, 10).until(
        EC.visibility_of_element_located(
            (By.XPATH, "//a[contains(translate(., 'RESYNC', 'resync'), 'resync')]")
        )
    )
    assert first_result.is_displayed()

    driver.save_screenshot("results.png")

Run with pytest test_search.py -v. No more try/finally in the test body — the fixture's yield / drv.quit() handles cleanup whether the test passes, fails, or errors.

Why --window-size=1280,900? Headless Chrome defaults to a tiny viewport (800×600). DuckDuckGo serves a mobile-ish layout at that size, so your XPath targeting desktop links may miss. Set a desktop size explicitly, and now the headless run matches the headed run.

Why --headless=new and not --headless? =new opts into the rewritten headless mode that shipped in Chrome 112. The old mode is effectively a different browser engine and has subtle rendering differences. Always prefer the new mode for anything beyond a quick smoke test.

README reflection (example):

# Playwright vs Selenium — quick reflection

Playwright felt cleaner: one install command, browsers bundled,
cross-browser for free, and auto-waiting means no WebDriverWait
boilerplate. The trace viewer is a genuine debugging win.

Selenium felt heavier: venv, separate driver, manual waits, no
full-page screenshot. But the ecosystem is deeper — pytest
fixtures, Allure reporting, integrations with every CI under
the sun, and the vast majority of existing enterprise test
suites are Selenium.

For a greenfield project tomorrow: Playwright. For joining a
team that already has 2,000 Selenium tests: Selenium.

Common mistakes

  • Bare driver.find_element(By.NAME, "q") with no wait

    Selenium, unlike Playwright, does not auto-wait. If the element isn't in the DOM the millisecond you ask for it, you get NoSuchElementException. Always wrap lookups in WebDriverWait(driver, 10).until(EC....).

  • Skipping driver.quit() (or putting it outside finally)

    Selenium leaves a zombie Chrome process on every error if you don't clean up. The fixture pattern above solves this; in a scripted test, the try/finally is mandatory, not optional.

  • Running the script with the wrong Python

    ModuleNotFoundError: No module named 'selenium' almost always means the venv isn't active — check your prompt shows (.venv). A second cause: on macOS/Linux, running python test_search.py invokes system Python; use python3 or activate the venv first.

  • Expecting save_screenshot to capture the full page

    It doesn't — viewport only. If you need the full page in Selenium, either resize the window to the document height via a CDP call, or use a helper library. Most teams just accept viewport screenshots for Selenium-based suites.

  • PowerShell blocking Activate.ps1 with an execution-policy error

    Run once, in your user scope: Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned. This is a Windows-only hurdle that trips everyone on their first Python project.

Self-check: you should be able to describe — without looking — why Playwright doesn't need WebDriverWait, what webdriver-manager solves that historically was painful, and why the fixture pattern is preferred over try/finally inside the test body. If you can, you're ready for Level 2.