Level 1 · Junior Automation Engineer

Junior automation techniques

Locate the element. Wait for it. Assert something meaningful. At junior level you write and maintain straightforward test scripts — and you learn the habits that separate reliable tests from flaky ones.

Junior ISTQB CTFL v4.0 — Ch. 4 / Selenium fundamentals

1. Locator strategies

A locator tells the automation tool how to find an element on the page. Bad locators are the single biggest cause of flaky tests. Prefer in this order:

  • id — fastest and most stable when available.
  • name / data-testid — stable if the team uses them consistently.
  • CSS selector — flexible, readable, works everywhere.
  • Link text / partial link text — fine for nav links, fragile if copy changes.
  • XPath — powerful but brittle. Use only when nothing else works.
Mini-Hunt: Pick the Locator

HTML: <button id="submit-btn" class="btn primary">Save</button>

Which locator is most stable — #submit-btn, .btn.primary, or //button[text()='Save']?

Answer: #submit-btn. IDs rarely change. Class-based selectors break when styling is refactored; text-based XPath breaks on copy changes or translation.

2. Waits & synchronisation

Web pages load asynchronously. If your script clicks an element before it exists, the test fails — not because the app is broken, but because the test is impatient.

  • Implicit wait — set once, applied to every findElement call. Convenient, but hides slow pages and can mask real bugs.
  • Explicit wait — wait for a specific condition (element visible, clickable, text present). This is the right default.
  • Never use sleep()/Thread.sleep() as your primary sync — it either wastes time or fails unpredictably.
Mini-Hunt: The Flaky Test

Problem: A test passes locally but fails ~30% of the time in CI. The failing step is driver.findElement(By.id("results")).click().

Most likely fix?

Answer: Add an explicit wait for results to be visible/clickable. CI is often slower than your laptop — the element isn’t ready yet when the click runs.

3. Assertions

An automated test without an assertion isn’t a test — it’s a monkey clicking buttons. Every test needs at least one assertion that proves the expected outcome actually happened.

  • Positive assertions — "the success message is visible", "the URL contains /dashboard".
  • Negative assertions — "the error banner is not displayed", "the Delete button is disabled".
  • Hard vs soft — hard stops on first failure; soft collects failures and reports at the end. Hard is the default; soft is useful for checking many fields on one form.

Assert on behaviour, not implementation details. "The user sees their order number" is durable. "div.order-result-inner-v3 contains #12345" will break on the next UI change.

4. Test script structure

Most frameworks use the Arrange — Act — Assert pattern:

  • Arrange — set up preconditions (open browser, log in, seed data).
  • Act — perform the single behaviour under test.
  • Assert — verify the outcome.

At junior level you’ll also meet setUp / tearDown hooks (JUnit, pytest, Mocha): shared setup runs before each test, cleanup runs after. Keep each test independent — it must pass when run alone and in any order.

5. Running & reading results

  • Run locally — know the one command (pytest, mvn test, npm test) and how to target a single test or tag.
  • Read the report — pass/fail counts, stack trace, screenshot on failure (if configured), test duration.
  • Reproduce a failure — can you run just that one test on your machine and see the same result? If not, investigate environment differences before changing the test.

6. Maintaining existing tests

Most of your day-one work is maintenance, not greenfield. When an existing test breaks, ask the questions in this order:

  1. Is the application broken (real bug)? → log a defect.
  2. Did the spec change and the test is out of date? → update the test.
  3. Is the locator stale because the UI changed? → update the locator.
  4. Is the test flaky (passes on retry)? → fix synchronisation, don’t mask with retries.

Never "fix" a test by deleting the assertion or adding a sleep. Those are red flags in code review.

7. Debugging a failing test

  • Read the stack trace top to bottom — find the first line in your code, not the framework’s.
  • Run headed, not headless — watch the browser execute. Most "mystery" failures are obvious when you can see the page.
  • Add a breakpoint or pause() before the failing step and inspect the DOM in DevTools.
  • Check the screenshot and page source artifacts the framework saves on failure.
  • Is the data right? — tests often fail because a previous run left the system in an unexpected state.

Manual-to-automation mapping

At junior level your manual techniques still apply — you’re now expressing them in code rather than in a test case document:

Manual technique → automation expression
Manual techniqueIn automated tests
Equivalence PartitioningOne test per valid/invalid partition — data-driven with a small dataset.
Boundary Value AnalysisParameterised test: [min-1, min, min+1, max-1, max, max+1].
Decision Table TestingOne test per column of the table; condition combinations as test parameters.
State TransitionOne test per valid transition; separate tests for invalid transitions.
Error GuessingA small “nasty inputs” suite: empty, spaces, very long, SQL-ish strings.

8 Now You Try

Three graded exercises — spot, fix, then build a first Playwright script. Write your answer, run it for AI feedback, then compare to the model answer.

🔍 Exercise 1 of 3 — Spot: pick the most stable locator

On a Trade Me listing page the "Watch this listing" control renders as: <button id="watch-listing" class="btn btn--primary watch-cta" data-testid="watch-btn">Watch</button>. List the candidate locators a junior might reach for, rank them from most to least stable, and name the one you would write — with a one-line reason.

Show model answer
Ranked most to least stable:
1. data-testid="watch-btn" — added by the team for testing, won't change on a restyle. In Playwright: page.getByTestId('watch-btn').
2. id="watch-listing" — stable and fast, but IDs are sometimes reused or removed in refactors. page.locator('#watch-listing').
3. The "Watch" link/button text — page.getByRole('button', { name: 'Watch' }). Readable and accessibility-aligned, but breaks if the copy changes (e.g. "Add to watchlist").
4. .btn.btn--primary.watch-cta — class-based, the most fragile: classes change every time the design system is touched.

The locator I would use: page.getByTestId('watch-btn'). The team put data-testid there on purpose, so it survives both copy changes and CSS refactors. If there were no test id, I'd fall back to the role+name locator, then the id. I'd avoid the class selector entirely.
🔧 Exercise 2 of 3 — Fix: repair a flaky script

This Playwright test for an IRD myIR login passes locally but fails about a third of the time in CI. Rewrite it so it waits correctly and asserts on behaviour, and say what was wrong with the original.

Flaky test: await page.goto('https://myir.example.govt.nz/login'); await page.locator('#user').fill('test-user'); await page.locator('#password').fill('Passw0rd!'); await page.locator('#submit').click(); await page.waitForTimeout(3000); expect(page.url()).toContain('/dashboard');

Rewrite it and explain the fixes:

Show model answer
Fixed test:
await page.goto('https://myir.example.govt.nz/login');
await page.getByLabel('User ID').fill('test-user');
await page.getByLabel('Password').fill('Passw0rd!');
await page.getByRole('button', { name: 'Log in' }).click();
// Wait for the real outcome, not a fixed timer:
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByRole('heading', { name: 'My account' })).toBeVisible();

What was wrong with the original:
1. waitForTimeout(3000) is a fixed sleep. On a slow CI runner the page may take longer than 3 s, so the assertion runs too early and fails — that is the source of the one-in-three failure. Playwright's web-first assertions (expect(...).toHaveURL / toBeVisible) auto-wait and retry, so they remove the guesswork.
2. expect(page.url()).toContain(...) reads the URL once, immediately. It does not wait. Use expect(page).toHaveURL(...) so Playwright polls until the navigation completes.
3. A single URL check is weak. Add a visible-element assertion (the account heading) so you prove the dashboard actually rendered, not just that the address bar changed.
🏗️ Exercise 3 of 3 — Build: a first Playwright test

Write a single Playwright test (any language binding) for Trade Me search. Steps: open the homepage, type "mountain bike" into the search box, submit, and assert that results appear and the page shows the search term. Use the Arrange–Act–Assert structure, stable locators, and at least one meaningful assertion — no fixed sleeps.

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

test('Trade Me search returns results for a query', async ({ page }) => {
  // Arrange — open the page under test
  await page.goto('https://www.trademe.co.nz/');

  // Act — perform the single behaviour under test
  const search = page.getByRole('searchbox', { name: 'Search' });
  await search.fill('mountain bike');
  await search.press('Enter');

  // Assert — prove the outcome (auto-waiting, no sleep)
  await expect(page).toHaveURL(/search/i);
  await expect(page.getByText(/mountain bike/i).first()).toBeVisible();
  await expect(page.getByRole('listitem').first()).toBeVisible();
});

Notes for a junior reviewer:
- Arrange/Act/Assert is clearly separated.
- Locators use role + accessible name, not brittle CSS classes.
- The assertions auto-wait — there is no waitForTimeout, so the test is not impatient on slow CI.
- It asserts two things: the URL changed AND at least one result is visible, proving the search actually ran rather than just that a key was pressed.

Self-Check

Click each question to reveal the answer.

Q1: Why is a class-based locator like .btn.primary a poor first choice?

Classes exist for styling, so they change whenever the design is refactored — and the same class is often shared by many elements, so the locator may match the wrong one. Prefer a stable hook the team controls: an id or a data-testid, or a role+accessible-name locator.

Q2: A test passes on your laptop but fails about 30% of the time in CI on a click step. What is the usual cause and the right fix?

CI runners are often slower, so the element isn’t ready yet when the click fires. The fix is an explicit/web-first wait for the element to be visible or clickable (or an auto-waiting assertion), not a fixed sleep and not a blanket retry that hides the timing bug.

Q3: What makes a test "not really a test", and how do you fix it?

A script that drives the UI but never asserts an expected outcome — it can only fail if the app crashes. Add at least one meaningful assertion on observable behaviour (a success message is visible, the URL contains /dashboard), not on internal implementation details.

Q4: An existing test starts failing after a UI change. What order do you check things in?

First decide whether the application itself is broken (a real defect → log it). If not, check whether the spec changed (update the test), then whether a locator is stale because the UI changed (update the locator), then whether it’s flaky timing (fix synchronisation). Never "fix" it by deleting the assertion or adding a sleep.

Q5: What does the Arrange–Act–Assert pattern give you, and why must each test stay independent?

It keeps a test focused: set up preconditions, perform the single behaviour, verify the outcome. Each test must pass when run alone and in any order — if it depends on state another test left behind, it becomes flaky and can’t be run in parallel or in isolation.