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.
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.
HTML: <button id="submit-btn" class="btn primary">Save</button>
Which locator is most stable — #submit-btn, .btn.primary, or //button[text()='Save']?
#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
findElementcall. 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.
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?
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:
- Is the application broken (real bug)? → log a defect.
- Did the spec change and the test is out of date? → update the test.
- Is the locator stale because the UI changed? → update the locator.
- 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 | In automated tests |
|---|---|
| Equivalence Partitioning | One test per valid/invalid partition — data-driven with a small dataset. |
| Boundary Value Analysis | Parameterised test: [min-1, min, min+1, max-1, max, max+1]. |
| Decision Table Testing | One test per column of the table; condition combinations as test parameters. |
| State Transition | One test per valid transition; separate tests for invalid transitions. |
| Error Guessing | A 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.
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.
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.
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.
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.