Level 2 · Mid-Level Automation Engineer

Mid-level automation techniques

Write it once, reuse it everywhere. At mid-level you stop copy-pasting scripts and start shaping the framework. You test APIs directly, parameterise your data, and take responsibility for why a test is flaky.

Mid-Level ISTQB CT-TAE — Ch. 2 & 3

1. Page Object Model (POM)

Locators belong in one place. A page object wraps the locators and actions for a page behind a clean API — tests call loginPage.loginAs(user), not driver.findElement(...).

  • Tests read like scenarios — no selectors in the test body.
  • UI change → one fix — update the page object, every test benefits.
  • Actions, not just fields — expose business verbs (addItemToCart), not raw setters.
Mini-Hunt: The Right Boundary

Question: Should the page object method return void, or return the next page object?

Example: loginPage.submit() takes you to the dashboard.

Answer: Return the next page object (DashboardPage submit()). This makes navigation explicit in the test, enforces sync (you know you’re on the next page), and enables method chaining.

2. API test automation

Most business rules live in the API, not the UI. API tests are faster, more stable, and more focused. At mid-level you should own the API layer.

  • Tools — REST Assured (Java), requests + pytest (Python), supertest (JS), Postman/Newman for collections.
  • Schema validation — assert the response shape against a JSON schema, not just a single field.
  • Status codes & headers — check the contract, not just the body.
  • Auth — know how to obtain a token, refresh it, and share it across a suite without leaking into test bodies.
  • Negative paths — 400/401/403/404/409/422 deserve tests too. UI rarely exposes all of these clearly.

Full API testing reference →

Mini-Hunt: What’s missing?

Test: POST /orders returns 200 and {"orderId": "abc"}. The test asserts the status code is 200.

What’s the biggest gap?

Answer: It never verifies the order was actually created. Follow up with a GET /orders/abc (or DB check) to prove persistence — a 200 just means the handler didn’t crash.

3. Data-driven testing

One test body, many inputs. Parameterisation turns a single flow into systematic coverage — and turns your EP / BVA / decision tables into tests.

  • JUnit 5@ParameterizedTest with @CsvSource, @MethodSource.
  • pytest@pytest.mark.parametrize.
  • Mocha/Jesttest.each([...]).
  • Sources — inline, CSV, JSON, or a generator function. External files are easier for non-devs to edit but harder to refactor.

Name each parameterised case so failures point straight at the bad input. login[user=alice, expect=success] beats login[0].

4. Test isolation & data setup

Flaky suites almost always fail here. Every test must:

  • Create its own data (via API/DB), not rely on what a previous test left behind.
  • Run in any order — enforce this by running with --random / --shuffle.
  • Clean up or use disposable data (unique IDs, per-test users, teardown via API).
  • Not share state through global variables, singletons, or files on disk.

Prefer API/DB setup over UI setup — it’s orders of magnitude faster and less fragile. Use the UI only for the thing the test is actually asserting.

5. Fighting flakiness

A flaky test is worse than no test — it erodes trust. At mid-level you’re expected to diagnose, not just retry.

  • Sync, not sleep — every Thread.sleep/time.sleep in the framework is a bug waiting to happen.
  • Deterministic data — freeze time, fix random seeds, use fixed fixtures.
  • One concept per test — long end-to-end tests that hit every layer flake for unrelated reasons.
  • Quarantine, don’t ignore — move flaky tests to a separate lane, fix them within a sprint.
  • Measure — track pass rate per test over time. The top offenders are where you focus.

6. Contributing to the framework

Mid-level engineers start giving back:

  • Add a new page object / API client when you hit the second copy-paste.
  • Extract a reusable helper (date builder, unique-ID generator, auth fixture) when three tests do the same thing.
  • Improve a failure message so the next person doesn’t need to read the stack trace to know what broke.
  • Write a short README when you add a capability — your future team-mate needs it.

Rule of thumb: leave the framework cleaner than you found it, but don’t refactor unrelated code in a test-fix PR.

7. Choosing the right layer (the test pyramid)

Before writing a UI test, ask: could this be an API test, or a unit test? Each layer up the pyramid is slower, more brittle, and more expensive to maintain.

  • Unit — fast, many. Pure logic, edge cases, error paths.
  • Integration / API — business rules, contracts, persistence.
  • UI / end-to-end — a small number of critical journeys.

A good mid-level heuristic: if a test would work at the API layer, do it there. Save UI tests for things that only matter in the UI (rendering, accessibility, interaction).

Technique mapping

Mid-level automation concepts — canonical references
AreaReference
Test design techniques applied in codeEP, BVA, Decision tables, Pairwise
API testing foundationsAPI testing reference
Regression strategy & suite structureRegression testing
Smoke vs full runs in CISmoke testing
Tooling at this levelSelenium, Playwright, Cypress, Postman, REST Assured
ISTQB alignmentCT-TAE Ch. 2 (architecture), Ch. 3 (implementation)

8 Now You Try

Three graded exercises — spot, fix, then build. These target the mid-level shift: page objects, data-driven coverage, and proper synchronisation. Write your answer, run it for AI feedback, then compare to the model answer.

🔍 Exercise 1 of 3 — Spot: critique a page object

A teammate’s KiwiFirst Bank transfer page object is below. Name everything that breaks good POM design, and describe what a clean version would do differently.

Page object under review: class TransferPage { fromField = '#frm input.acct-from-v3'; enterAmount(v) { driver.findElement('#amt').sendKeys(v); } submit() { driver.findElement('#go').click(); Thread.sleep(2000); assert(driver.getTitle() == 'Transfer complete'); } }
Show model answer
Problems:
1. Assertions live inside the page object. A page object should expose actions and state, not decide pass/fail — the test owns assertions. submit() asserting the title couples the page to one scenario.
2. Thread.sleep(2000) is a fixed sleep inside the framework. Use an explicit/auto-wait for the "Transfer complete" state instead; the sleep is both slow and flaky.
3. Brittle locator: '#frm input.acct-from-v3' mixes an id with a versioned class — it will break on the next restyle. Prefer a stable id or data-testid.
4. submit() returns nothing. It should return the next page object (e.g. a ConfirmationPage) so navigation is explicit and chainable.

A clean version would:
- Hold locators in one place, expose business verbs (enterAmount, transferTo), keep zero assertions.
- Wait on a real condition (confirmation banner visible) rather than sleeping.
- Return the next page object from submit() so the test reads like a scenario and the test — not the page object — asserts the outcome.
🔧 Exercise 2 of 3 — Fix: turn copy-paste into data-driven tests

A tester wrote three near-identical tests for an Revenue NZ GST-number validator. Refactor them into one data-driven (parameterised) test — any framework (pytest, JUnit, or Playwright test.each) — with clearly named cases, and say which design technique the dataset should come from.

Copy-pasted tests: test_valid_gst() { assert validate('123-456-789') == true } test_short_gst() { assert validate('123-456') == false } test_letters_gst() { assert validate('12A-456-789') == false }
Show model answer
Parameterised test (pytest example):
import pytest

@pytest.mark.parametrize('gst, expected', [
    pytest.param('123-456-789', True,  id='valid_9_digit'),
    pytest.param('123-456',     False, id='too_short'),
    pytest.param('12A-456-789', False, id='contains_letter'),
    pytest.param('',            False, id='empty'),
], )
def test_gst_validation(gst, expected):
    assert validate(gst) == expected

Why this is better:
- One test body, many inputs — adding a case is one line, not a new function.
- Each case has a readable id (valid_9_digit, too_short…) so a failure points straight at the bad input, unlike test[0].
- I added an empty-string case the originals missed.

The dataset should come from equivalence partitioning and boundary value analysis: one representative per valid/invalid partition (valid 9-digit, too short, non-numeric, empty), plus the length boundaries of the format. Parameterisation is simply how those design techniques become executable tests.
🏗️ Exercise 3 of 3 — Build: isolate and sync an API-seeded UI test

Design a test for a ListRight "My listings" page that proves a newly created listing appears. The catch: it must be isolated (creates its own data, runs in any order, cleans up) and synchronised (no fixed sleeps). Describe the setup, the UI action, the assertion, and the teardown — and say which steps should go through the API rather than the UI, and why.

Show model answer
Setup (Arrange):
- Create a unique test user (or use a per-worker user) via the API.
- Create a listing via POST /listings with a unique title, e.g. "QA-bike-{uuid}". Capture the returned listingId.
- Log in via an API-obtained session/token, not by driving the login form.

UI action (Act):
- Open the "My listings" page in the browser for that user.

Assertion (Assert):
- await expect(page.getByText('QA-bike-{uuid}')).toBeVisible();  // auto-waits, no sleep
- Optionally assert the listing count or that the row links to the captured listingId.

Teardown:
- DELETE /listings/{listingId} (and the test user if created) via the API, in a finally/afterEach so it runs even on failure.

Which steps use the API and why:
- User creation, listing creation, login, and cleanup all go through the API: it is far faster and less fragile than driving forms, and it keeps the test isolated — the unique title/uuid means parallel workers never collide.
- Only the one thing under test (does the listing render on My listings?) goes through the UI. Synchronisation is handled by Playwright's auto-waiting assertion on the unique title, so there is no fixed sleep and the test runs safely in any order.

Self-Check

Click each question to reveal the answer.

Q1: Why should a page object method that navigates (e.g. loginPage.submit()) return the next page object rather than void?

Returning the next page object makes navigation explicit in the test, enforces synchronisation (you only get the object once you’re on the next page), and enables method chaining. Page objects should expose actions and state — not contain assertions, and not leave the test guessing where it landed.

Q2: An API test asserts only that POST /orders returned 200. What is the biggest gap?

It never proves the order was actually created. A 200 only means the handler didn’t crash. Follow up with a GET /orders/{id} or a DB check to confirm persistence, and validate the response shape against a schema rather than a single field.

Q3: What turns equivalence partitioning and boundary value analysis into actual automated tests?

Data-driven parameterisation. One test body runs over a dataset — one representative per partition (EP) plus the edges (BVA). Name each case so a failure points at the offending input (login[user=alice, expect=success], not login[0]).

Q4: Why prefer API or DB setup over driving the UI to create a test’s preconditions?

It’s far faster and far less fragile, and it keeps the test isolated — each test creates its own data with unique identifiers so it can run in any order and in parallel. Reserve the UI for the one behaviour the test is actually asserting.

Q5: At mid-level, what’s the difference between quarantining a flaky test and retrying it?

A blanket retry hides the timing or isolation bug and erodes trust in the suite. Quarantining moves the flaky test to a separate lane so it doesn’t block merges, with a commitment to diagnose and fix the root cause (sync, not sleep; deterministic data) within a sprint — not to ignore it forever.