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
awaiton a Playwright callThe #1 cause of mysterious failures.
page.goto('...')withoutawaitreturns a Promise that never resolves before the next line runs. Rule of thumb: if it comes frompage,expect, or a locator, it needsawait. -
Using a string instead of a regex for
toHaveTitletoHaveTitle('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.tsin the current directory. If you runnpx playwright testfromDesktop/instead ofpw-practice-01/, you'll see "No tests found". Alwayscdinto 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.
/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 withpage.locator('input[name="q"]').first(). -
Asserting
toHaveURL('q=Resync+...')as a stringA 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()whenfill()would dopage.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. Usetypeonly when you actually need per-character keyboard events (autocomplete testing, etc.).
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 waitSelenium, 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 inWebDriverWait(driver, 10).until(EC....). -
Skipping
driver.quit()(or putting it outsidefinally)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/finallyis 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, runningpython test_search.pyinvokes system Python; usepython3or activate the venv first. -
Expecting
save_screenshotto capture the full pageIt 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.ps1with an execution-policy errorRun 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.
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.