Level 2 · Mid-Level Automation Engineer

Mid-Level Automation Answer Key

Challenge solutions, expected output, and the common mistakes that trip mid-level engineers on each exercise.

Use this after attempting the practice. The goal isn't to copy — it's to check your work and understand why each solution looks the way it does. Every snippet was tested against the live targets at the time of writing.

1 Practice 01 · Page Object Model Refactor (open practice)

Collapse three flat tests against saucedemo.com into a reusable LoginPage class.

Expected pass output

Running 3 tests using 3 workers

  3 passed (4.1s)

To open last HTML report run:

  npx playwright show-report

Three passes. If you see 9 passed, your playwright.config.js kept the default multi-browser matrix — not wrong, just means you're running each test against Chromium, Firefox, and WebKit. The practice asked for Chromium only (--browser=chromium); either is fine for the answer-key check.

Challenge solution — easy: add the problem_user test

One new line inside the describe block. That's the whole point of POM.

test('problem user can still log in', async ({ page }) => {
  await loginPage.login('problem_user', 'secret_sauce');
  await expect(page).toHaveURL(/inventory\.html/);
});

No new locator, no new URL, no new plumbing. If the login flow changes, you edit LoginPage.js once and this test keeps working.

Challenge solution — harder: InventoryPage + chained return

Two new files and a small change to LoginPage. First the new page object:

// tests/pages/InventoryPage.js
class InventoryPage {
  constructor(page) {
    this.page = page;
    this.items = page.locator('[data-test="inventory-item"]');
  }

  async itemCount() {
    return this.items.count();
  }
}

module.exports = { InventoryPage };

Then make LoginPage.login() return the next page:

// tests/pages/LoginPage.js (updated)
const { InventoryPage } = require('./InventoryPage');

class LoginPage {
  constructor(page) {
    this.page = page;
    this.usernameInput = page.locator('[data-test="username"]');
    this.passwordInput = page.locator('[data-test="password"]');
    this.submitButton  = page.locator('[data-test="login-button"]');
    this.errorBanner   = page.locator('[data-test="error"]');
  }

  async goto() {
    await this.page.goto('https://www.saucedemo.com/');
  }

  async login(username, password) {
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
    return new InventoryPage(this.page);
  }

  async errorText() {
    return this.errorBanner.textContent();
  }
}

module.exports = { LoginPage };

Now the test reads like prose:

test('standard user sees exactly 6 products', async () => {
  const inventory = await loginPage.login('standard_user', 'secret_sauce');
  expect(await inventory.itemCount()).toBe(6);
});

On returning the next page: this is the "chained navigation" pattern. Each page object's action-method returns an instance of the next page. The test no longer needs to know which page comes after login. If saucedemo ever adds a consent screen between login and inventory, you handle it inside login() — the test is unchanged.

Common mistakes

  • Page object exposes locators instead of behaviour

    If your LoginPage has a public usernameInput and tests do loginPage.usernameInput.fill(...), you've built a thin wrapper, not a page object. The test still knows about fields. Push the action into the class: loginPage.login(user, pass).

  • Instantiating the page object inside every test

    You repeat new LoginPage(page); await loginPage.goto(); three times. Use test.beforeEach (as the solution does) so every test starts from the same state with one line of setup.

  • Using CSS classes instead of data-test selectors

    Saucedemo ships stable data-test attributes because it's a practice site. Use them. Tying your locators to .btn_primary.submit-button--v3 is how test suites break on every CSS refactor.

  • Naming methods after UI widgets, not user actions

    clickLoginButton() and fillUsernameField() tell the reader nothing. login(user, pass) tells them everything. Page object methods are verbs for what a user does, not what the DOM is.

  • Shared page-object state between tests

    Declaring let loginPage; at the describe scope is fine — but only if you reassign it in beforeEach. If you instantiate it at module load, every test shares the same page reference from the first run, and Playwright will throw "Target page has been closed" on test 2.

Self-check: you should be able to change one selector in LoginPage.js and have every test pick up the change automatically. That's the POM payoff — one edit, not three (or thirty).

2 Practice 02 · API Testing with Postman + Newman (open practice)

Four-request JSONPlaceholder collection, exported, run headless via Newman.

Expected pass output

executed    failed
iterations         1         0
  requests         4         0
test-scripts         8         0
prerequest-scripts   4         0
assertions        11         0

11 assertions, 0 failures. The run order is deterministic — requests fire in the order they appear in the collection.

Challenge solution — easy: add a DELETE

Right-click collection → Add request → Method DELETE, URL {{baseUrl}}/posts/1, no body.

Tests tab:

pm.test("Status code is 200", function () {
    pm.response.to.have.status(200);
});

pm.test("Response body is empty JSON object", function () {
    const body = pm.response.json();
    pm.expect(Object.keys(body)).to.have.lengthOf(0);
});

Re-export, re-run:

newman run jsonplaceholder.postman_collection.json

executed    failed
iterations         1         0
  requests         5         0
test-scripts        10         0
assertions        13         0

JSONPlaceholder's DELETE is a fake operation — it returns 200 and an empty object {}, but nothing actually deletes. For a real API you'd also add a GET afterward to assert the resource is gone.

Challenge solution — harder: environments + data-driven

Step 1 — create the reqres environment. Postman → Environments → + → name it reqres, add a variable:

VARIABLE     VALUE
baseUrl      https://reqres.in

Export via the three-dot menu → Export → save as reqres.postman_environment.json.

Step 2 — adjust one request. Duplicate Request 2, change its URL to {{baseUrl}}/api/users/2, update the test to match reqres's shape:

pm.test("User has an email", function () {
    const body = pm.response.json();
    pm.expect(body.data).to.have.property("email").that.matches(/@/);
});

Step 3 — run with the environment:

newman run collection.json -e reqres.postman_environment.json

Step 4 — data-driven CSV. Create users.csv:

userId
1
2
3

In the request URL use {{baseUrl}}/api/users/{{userId}}. Then:

newman run collection.json -e reqres.postman_environment.json -d users.csv

You'll see iterations 3 0 — the same collection ran three times, once per CSV row. The URL resolved differently each time because {{userId}} was sourced from the data file.

Why this pattern matters: same collection, different environments (dev / staging / prod) and different datasets (small smoke / full regression). Your CI config becomes a matrix of -e env.json and -d data.csv combinations. No collection duplication.

Common mistakes

  • Hardcoding the base URL in every request

    If every request reads https://jsonplaceholder.typicode.com/..., swapping environments means editing every request. Collection-level {{baseUrl}} is the Postman equivalent of a page object's goto().

  • Environment vs collection variables

    Two different scopes. pm.environment.set writes to the active environment; pm.collectionVariables.set writes to the collection. If you set with one and read with the other's templating (e.g. {{x}} resolves collection-first, then environment), you can end up with stale values. Pick one scope per variable and stick to it.

  • Not asserting response time

    A 200 that takes 9 seconds is almost always a bug, but without pm.response.responseTime you'll never catch it. Add pm.expect(pm.response.responseTime).to.be.below(2000) to anything user-facing.

  • Assuming JSONPlaceholder persists data

    It doesn't — POST returns 201 and an id, but that id isn't real. Your "created resource" GET will 404. The practice handles this with pm.expect([200, 404]).to.include(pm.response.code). Against a real API, never tolerate both — expect 200 exactly.

  • Running Newman inside an interactive shell for CI

    CI agents run non-interactive. newman run collection.json exits 0 on pass, non-zero on fail — that's the entire integration. Don't add --disable-unicode or pipe through anything; let the exit code speak.

Self-check: the Postman collection should work identically in the app (hit Run) and on the command line (newman run). If it passes in one but fails in the other, you're reading OS proxy settings, keychain tokens, or some other app-only state — move whatever it is into an environment variable.

3 Practice 03 · Data-Driven Testing (open practice)

Read login scenarios from JSON and loop over them with Playwright.

Expected pass output

Running 5 tests using 5 workers

  5 passed (5.3s)

One pass line per JSON row. If you added a row but the count didn't change, Node's require cache is holding the old file — stop the watcher (npx playwright test re-runs cleanly) or restart your terminal.

Challenge solution — easy: three edge-case rows

Append to data/logins.json:

{
  "name": "leading/trailing whitespace in username",
  "username": "  standard_user  ",
  "password": "secret_sauce",
  "expect": "bad_credentials"
},
{
  "name": "absurdly long password is rejected",
  "username": "standard_user",
  "password": "x".repeat(10000),
  "expect": "bad_credentials"
},
{
  "name": "SQL-injection-looking username is treated as literal",
  "username": "' OR 1=1 --",
  "password": "secret_sauce",
  "expect": "bad_credentials"
}

Gotcha: "x".repeat(10000) is JavaScript syntax, not valid JSON. Pre-expand it in a small script or paste the literal 10,000 x's. A cleaner fix: parse the JSON in JS and expand there (move data loading into a small helper module).

What you'll learn by running it: saucedemo trims whitespace silently, so the first row may pass with expect: "success" instead. That's a finding worth writing down — the page silently modifies user input, which can mask bugs for non-trimming backends.

Challenge solution — harder: combine POM with data-driven

Import the LoginPage from Practice 01, introduce a thin assertOutcome helper, shrink the test body to the shape the practice hinted at:

// tests/login.spec.js
const { test, expect } = require('@playwright/test');
const path = require('path');
const { LoginPage } = require('./pages/LoginPage');
const scenarios = require(path.join(__dirname, '..', 'data', 'logins.json'));

const expectedError = {
  locked: /locked out/i,
  bad_credentials: /Username and password do not match/i,
  username_required: /Username is required/i,
};

async function assertOutcome(page, login, kind) {
  if (kind === 'success') {
    await expect(page).toHaveURL(/inventory\.html/);
    return;
  }
  const errorText = await login.errorText();
  expect(errorText).toMatch(expectedError[kind]);
}

test.describe('Saucedemo login — POM + data-driven', () => {
  for (const row of scenarios) {
    test(row.name, async ({ page }) => {
      const login = new LoginPage(page);
      await login.goto();
      await login.login(row.username, row.password);
      await assertOutcome(page, login, row.expect);
    });
  }
});

What this buys you: adding a new scenario is now a single JSON row. No code change. A product manager could theoretically maintain the test data without touching JavaScript, which is what "living documentation" is supposed to mean.

Where this breaks down: once scenarios need per-row setup (pre-seed a specific cart, stub an API response, navigate through two pages before asserting), the flat JSON model strains. That's when you promote the data structure to scenario objects with richer fields — or switch from JSON to a real DSL like Gherkin/Cucumber, which is a whole other decision.

Common mistakes

  • Defining tests inside an async function or a callback

    Playwright collects test() calls synchronously at module load. Wrapping your loop in async or fs.readFile means the registrations happen after collection closes. Use synchronous require() or fs.readFileSync().

  • Using var in the loop, all tests share the last row

    var is function-scoped, so every closure captures the same reference. By the time the tests run, the loop is done and row points to the final row. Use const inside for...of (as shown).

  • Inlining the data array in the spec file

    You lose the ability to edit data without touching code. Ten-row arrays grow into fifty-row arrays grow into unreadable spec files. Always a JSON (or CSV) file in a sibling folder.

  • Assertion depends on row order

    Playwright parallelises tests by default. If row 3 relies on row 1 having logged in, you have a sequencing bug. Each test() must be independent — or promote the sequence into a test.describe.serial block.

  • No name field — reporter shows test 1, test 2

    When row 17 of 40 fails, you want to see its name, not an index. Always give each row a descriptive name. Match it to the test case id from your test management tool if you have one.

Self-check: you should be able to delete or add a JSON row and see the test count change accordingly, with no spec edit. If you can't, something is still hardcoded.