Skip to main content

Test Automation Milestones & Mini-Projects

In short: one framework, grown milestone by milestone. Each milestone lists tasks and a check command; the solution is folded away. Every solution on this page was run: the finished project passes 46 tests across the API, Chromium, Firefox and mobile Safari projects.

How to use this page

First read the milestone in the Roadmap and the cheat-sheet sections it links to. Then build it yourself and run the check. Open the solution only after your check passes (or after an honest hour stuck) — and compare designs, not just whether it passes.

Contents​

The targets are free practice sites: Sauce Demo (UI), Restful Booker (API) and Playwright's API-mocking demo.


Milestone 1: First tests​

Practises: set-up, locators, actions, web-first assertions, hooks.

mkdir qa-framework && cd qa-framework
npm init playwright@latest # TypeScript, "tests" folder, GitHub Actions: yes

Set use: { baseURL: 'https://www.saucedemo.com' } in playwright.config.ts so tests can call page.goto('/').

#TaskCheck
1Test: standard_user logs in and sees "Products"Passes
2Test: locked_out_user sees the exact locked-out messagePasses
3Test: clicking Login with an empty form asks for a usernamePasses
4Move the shared page.goto('/') into beforeEachTests still pass
5Break test 2's expected text on purposeIt fails and shows expected vs received — then fix it

Check: npx playwright test tests/m1 — all 3 tests pass in every browser project.

Solution
tests/m1/login.spec.ts
// tests/m1/login.spec.ts — Milestone 1: plain Playwright, no framework yet
import { test, expect } from '@playwright/test';

test.beforeEach(async ({ page }) => {
await page.goto('/');
});

test('valid user reaches the products page', async ({ page }) => {
await page.getByPlaceholder('Username').fill('standard_user');
await page.getByPlaceholder('Password').fill('secret_sauce');
await page.getByRole('button', { name: 'Login' }).click();

await expect(page).toHaveURL(/inventory\.html/);
await expect(page.getByText('Products')).toBeVisible();
});

test('locked-out user sees an error', async ({ page }) => {
await page.getByPlaceholder('Username').fill('locked_out_user');
await page.getByPlaceholder('Password').fill('secret_sauce');
await page.getByRole('button', { name: 'Login' }).click();

await expect(page.locator('[data-test="error"]')).toHaveText(
'Epic sadface: Sorry, this user has been locked out.',
);
});

test('empty form asks for a username', async ({ page }) => {
await page.getByRole('button', { name: 'Login' }).click();

await expect(page.locator('[data-test="error"]')).toHaveText('Epic sadface: Username is required');
});

Watch out for: asserting toBeVisible() on the error box instead of its text — it would pass with the wrong message. Step 5 proves your test can fail.

Try it: add a fourth test for "wrong password" using the exact message from the manual testing answer key.


Milestone 2: Page objects​

Practises: page objects, a users file, reading lists of values.

#TaskCheck
1Create src/data/users.ts with the users you needNo passwords typed in tests any more
2Create src/pages/LoginPage.ts (open, loginAs, expectError)M1 tests rewritten with it still pass
3Create src/pages/InventoryPage.ts with addToCart, removeFromCart, cartBadge, sortBy, pricesCompiles: npx tsc -p .
4Test: sorting "Price (low to high)" gives ascending prices, starting at $7.99Passes

Check: npx tsc -p . && npx playwright test tests/ui/sorting.spec.ts passes.

Solution

LoginPage.ts is shown in the cheat sheet.

src/data/users.ts
// src/data/users.ts — test users for the Sauce Demo practice site
export const users = {
standard: { username: 'standard_user', password: 'secret_sauce' },
locked: { username: 'locked_out_user', password: 'secret_sauce' },
problem: { username: 'problem_user', password: 'secret_sauce' }, // has deliberate bugs
glitch: { username: 'performance_glitch_user', password: 'secret_sauce' }, // slow login
} as const;

export type User = (typeof users)[keyof typeof users];
src/pages/InventoryPage.ts
// src/pages/InventoryPage.ts
import { type Page, expect } from '@playwright/test';

export class InventoryPage {
constructor(private readonly page: Page) {}

async expectLoaded() {
await expect(this.page.getByText('Products')).toBeVisible();
}

async addToCart(productName: string) {
const card = this.page.locator('[data-test="inventory-item"]').filter({ hasText: productName });
await card.getByRole('button', { name: 'Add to cart' }).click();
}

async sortBy(option: 'az' | 'za' | 'lohi' | 'hilo') {
await this.page.locator('[data-test="product-sort-container"]').selectOption(option);
}

async prices(): Promise<number[]> {
const texts = await this.page.locator('[data-test="inventory-item-price"]').allTextContents();
return texts.map(t => Number(t.replace('$', ''))); // "$29.99" -> 29.99
}

async removeFromCart(productName: string) {
const card = this.page.locator('[data-test="inventory-item"]').filter({ hasText: productName });
await card.getByRole('button', { name: 'Remove' }).click();
}

cartBadge() {
return this.page.locator('[data-test="shopping-cart-badge"]');
}

async openCart() {
await this.page.locator('[data-test="shopping-cart-link"]').click();
}
}
tests/ui/sorting.spec.ts
// tests/ui/sorting.spec.ts — Milestone 2
import { test, expect } from '../../src/fixtures/test';
import { users } from '../../src/data/users';

test('price low to high sorts ascending @regression', async ({ loginPage, inventoryPage }) => {
await loginPage.open();
await loginPage.loginAs(users.standard);

await inventoryPage.sortBy('lohi');

const prices = await inventoryPage.prices();
expect(prices).toEqual([...prices].sort((a, b) => a - b)); // same list, sorted = already sorted
expect(prices[0]).toBe(7.99);
});

Watch out for: the product card contains more than one element with the role button, so card.getByRole('button') fails with a strict mode violation — give it a name.

Try it: run the sorting test as problem_user. It fails — that's the sorting bug from the manual testing bug hunt. You'll handle it properly in Milestone 6.


Milestone 3: Fixtures & saved logins​

Practises: custom fixtures, set-up projects, storageState, test independence.

#TaskCheck
1Create src/fixtures/test.ts that provides loginPage and inventoryPageTests import test from it
2Create a setup project that logs in once and saves .auth/standard_user.jsonFile appears after a run
3Make the browser projects depend on setupsetup runs first
4Test: add two items, remove both, badge goes 2 → 1 → hidden — with no login stepsPasses
5Add .auth/ to .gitignoregit status doesn't show it

Check: npx playwright test tests/ui/cart.spec.ts --workers=4 --repeat-each=3 passes.

Solution

The fixtures file is shown in the cheat sheet.

src/data/authState.ts
// src/data/authState.ts — where saved sessions live (test files may not import each other)
export const STANDARD_USER_STATE = '.auth/standard_user.json';
tests/auth.setup.ts
// tests/auth.setup.ts — Milestone 3: log in once, save the session for every UI test
import { test as setup, expect } from '@playwright/test';
import { users } from '../src/data/users';
import { STANDARD_USER_STATE } from '../src/data/authState';

setup('log in as standard_user', async ({ page }) => {
await page.goto('/');
await page.getByPlaceholder('Username').fill(users.standard.username);
await page.getByPlaceholder('Password').fill(users.standard.password);
await page.getByRole('button', { name: 'Login' }).click();
await expect(page).toHaveURL(/inventory\.html/);

await page.context().storageState({ path: STANDARD_USER_STATE }); // cookies + localStorage
});
tests/ui/cart.spec.ts
// tests/ui/cart.spec.ts — Milestone 3: starts already logged in, thanks to the saved session
import { test, expect } from '../../src/fixtures/test';
import { STANDARD_USER_STATE } from '../../src/data/authState';

test.use({ storageState: STANDARD_USER_STATE });

test('adding and removing updates the cart badge @regression', async ({ page, inventoryPage }) => {
await page.goto('/inventory.html'); // no login steps needed

await inventoryPage.addToCart('Sauce Labs Backpack');
await inventoryPage.addToCart('Sauce Labs Bike Light');
await expect(inventoryPage.cartBadge()).toHaveText('2');

await inventoryPage.removeFromCart('Sauce Labs Backpack');
await expect(inventoryPage.cartBadge()).toHaveText('1');

await inventoryPage.removeFromCart('Sauce Labs Bike Light');
await expect(inventoryPage.cartBadge()).toBeHidden(); // badge disappears at zero
});
// playwright.config.ts (projects excerpt)
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{ name: 'chromium', testIgnore: /api\//, dependencies: ['setup'], use: { ...devices['Desktop Chrome'] } },
],

Watch out for: exporting the path from auth.setup.ts and importing it in the cart test. Playwright stops with test file "ui/cart.spec.ts" should not import test file "auth.setup.ts" — shared values belong in src/.

Try it: add a second saved login for problem_user and a test that uses it.


Milestone 4: API layer​

Practises: an API client, data builders, positive and negative API tests.

#TaskCheck
1Add an api project with baseURL = Restful Booker--project=api runs only API tests
2Create src/data/bookingBuilder.ts (aBooking(overrides))Unique first name each call
3Create src/api/BookingClient.ts with token, create, get, update, deleteCompiles
4Test: create, then read back the same bookingPasses
5Test: update changes only what we sent, and it's really savedPasses
6Test: update and delete without a valid token are refused (403)Passes

Check: npx playwright test --project=api — all API tests pass.

Solution

bookingBuilder.ts is in the cheat sheet; the create/read/delete tests are in the framework guide.

src/api/BookingClient.ts
// src/api/BookingClient.ts — the API layer: tests call methods, never raw URLs
import { type APIRequestContext, expect } from '@playwright/test';
import type { Booking } from '../data/bookingBuilder';

export class BookingClient {
constructor(private readonly request: APIRequestContext) {}

async token(username: string, password: string): Promise<string> {
const res = await this.request.post('/auth', { data: { username, password } });
expect(res.status()).toBe(200);
return (await res.json()).token;
}

async create(booking: Booking): Promise<{ bookingid: number; booking: Booking }> {
const res = await this.request.post('/booking', {
data: booking,
headers: { Accept: 'application/json' },
});
expect(res.status()).toBe(200);
return res.json();
}

async get(id: number) {
return this.request.get(`/booking/${id}`, { headers: { Accept: 'application/json' } });
}

async update(id: number, booking: Booking, token: string) {
return this.request.put(`/booking/${id}`, {
data: booking,
headers: { Accept: 'application/json', Cookie: `token=${token}` },
});
}

async delete(id: number, token: string) {
return this.request.delete(`/booking/${id}`, { headers: { Cookie: `token=${token}` } });
}
}
tests/api/booking.update.spec.ts
// tests/api/booking.update.spec.ts — Milestone 4
import { test, expect } from '../../src/fixtures/test';
import { aBooking } from '../../src/data/bookingBuilder';
import { env } from '../../src/config/env';

test('updating a booking changes only what we sent @regression', async ({ bookingClient }) => {
const { bookingid, booking } = await bookingClient.create(aBooking());
const token = await bookingClient.token(env.apiUser, env.apiPassword);

const changed = { ...booking, totalprice: 999, additionalneeds: 'Late checkout' };
const res = await bookingClient.update(bookingid, changed, token);

expect(res.status()).toBe(200);
expect(await res.json()).toEqual(changed);
expect(await (await bookingClient.get(bookingid)).json()).toEqual(changed); // really saved
});

test('update without a token is refused @regression', async ({ bookingClient }) => {
const { bookingid, booking } = await bookingClient.create(aBooking());

const res = await bookingClient.update(bookingid, booking, 'not-a-real-token');

expect(res.status()).toBe(403);
});

Watch out for: the practice API returns 201 for a successful delete and 200 for a wrong password — your tests must assert what the API does, and your bug reports must say what it should do. See the API testing milestones.

Try it: add a test that reads a booking id that doesn't exist and expects 404.


Milestone 5: Config, tags & CI​

Practises: projects per browser, retries, reporters, tiers, a pipeline.

#TaskCheck
1Projects: setup, api, chromium, firefox, mobile-safari--list shows all five
2Retries 2 and forbidOnly in CI only; traces on first retryCI=1 npx playwright test --list works
3HTML + JUnit reportersresults/junit.xml exists after a run
4Tag tests @smoke / @regression; skip @quarantine in CI--grep @smoke --list shows only smoke
5GitHub Actions: smoke on pull requests, everything nightly, 2 shards, report as artifactWorkflow passes actionlint; a PR runs smoke only

Check: npx playwright test --grep @smoke passes locally, and the pipeline is green on a pull request.

Solution
playwright.config.ts
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import { env } from './src/config/env';

export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI, // fail CI if someone left test.only
retries: process.env.CI ? 2 : 0, // retry only in CI, and report it as "flaky"
grepInvert: process.env.CI ? /@quarantine/ : undefined, // quarantined tests never block CI
workers: process.env.CI ? 4 : undefined,
reporter: [['list'], ['html', { open: 'never' }], ['junit', { outputFile: 'results/junit.xml' }]],
use: {
baseURL: env.uiBaseUrl,
trace: 'on-first-retry', // full trace only when a test is retried
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{ name: 'api', testDir: './tests/api', use: { baseURL: env.apiBaseUrl } },
{ name: 'chromium', testIgnore: /api\//, dependencies: ['setup'], use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', testIgnore: /api\/|visual\//, dependencies: ['setup'], use: { ...devices['Desktop Firefox'] } },
{ name: 'mobile-safari', testIgnore: /api\/|visual\//, dependencies: ['setup'], use: { ...devices['iPhone 15'] } },
],
});

The tested workflow is in the framework guide.

Watch out for: visual tests in every browser project. Baselines differ per browser, so this config runs tests/visual in Chromium only (testIgnore on the others).

Try it: add a webkit desktop project and compare suite time with and without it.


Milestone 6: Resilience​

Practises: network mocking, visual comparison, slow steps, known bugs.

#TaskCheck
1Mock the fruits API: return only Mango and JackfruitThe page shows them; Strawberry is hidden
2Change the real response: add TamarindReal fruits and Tamarind show
3Simulate a 500 from the APIThe page shows no fruits and doesn't crash
4Visual test of the login page; create the baselineSecond run passes
5performance_glitch_user login test without any sleepPasses (takes ~5 s)
6Known-bug test: problem_user sorting, marked with test.failReported as expected to fail

Check: npx playwright test — the whole suite passes (46 tests in the finished project).

Solution
tests/mock/fruits.spec.ts
// tests/mock/fruits.spec.ts — Milestone 6: control what the back end returns
import { test, expect } from '@playwright/test';

const APP = 'https://demo.playwright.dev/api-mocking/';

test('shows exactly the fruits the API returns @regression', async ({ page }) => {
await page.route('**/api/v1/fruits', route =>
route.fulfill({ json: [{ name: 'Mango', id: 1 }, { name: 'Jackfruit', id: 2 }] }),
);

await page.goto(APP);

await expect(page.getByText('Mango')).toBeVisible();
await expect(page.getByText('Jackfruit')).toBeVisible();
await expect(page.getByText('Strawberry')).toBeHidden(); // a real fruit, not in our mock
});

test('keeps working when we add to the real response @regression', async ({ page }) => {
await page.route('**/api/v1/fruits', async route => {
const response = await route.fetch(); // call the real API
const fruits = await response.json();
fruits.push({ name: 'Tamarind', id: 999 });
await route.fulfill({ response, json: fruits });
});

await page.goto(APP);

await expect(page.getByText('Tamarind')).toBeVisible();
await expect(page.getByText('Strawberry')).toBeVisible();
});

test('shows no fruits when the API fails @regression', async ({ page }) => {
await page.route('**/api/v1/fruits', route => route.fulfill({ status: 500, body: 'boom' }));

await page.goto(APP);

await expect(page.getByText('Strawberry')).toBeHidden();
});
tests/visual/login.visual.spec.ts
// tests/visual/login.visual.spec.ts — Milestone 6: compare against an approved screenshot
import { test, expect } from '@playwright/test';

test('login page looks the same as the approved baseline @visual', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('login.png', { maxDiffPixelRatio: 0.01 });
});
tests/ui/known-bugs.spec.ts
// tests/ui/known-bugs.spec.ts — Milestone 6: document known bugs without breaking the build
import { test, expect } from '../../src/fixtures/test';
import { users } from '../../src/data/users';

test('problem_user can sort by price @known-bug', async ({ loginPage, inventoryPage }) => {
test.fail(true, 'BUG-101: sorting does nothing for problem_user'); // expected to fail for now

await loginPage.open();
await loginPage.loginAs(users.problem);
await inventoryPage.sortBy('lohi');

expect((await inventoryPage.prices())[0]).toBe(7.99);
});

test('performance_glitch_user still reaches products @regression', async ({ loginPage, page }) => {
await loginPage.open();
await loginPage.loginAs(users.glitch);

// this login takes about 5 s; give this one check more time instead of adding a sleep
await expect(page.getByText('Products')).toBeVisible({ timeout: 15_000 });
});

Watch out for: creating visual baselines on your Mac and running them in Linux CI — fonts render differently and the test fails. Create CI baselines in CI (or in the Playwright Docker image).

Try it: mock the fruits API to return 500 fruits. Does the page stay usable?


Final project​

Build a framework from scratch for a new target, without looking at your solutions. Suggested target: Playwright's TodoMVC demo.

Done when:

  • Page object for the to-do page; fixtures provide it
  • Tests: add, complete, edit, delete, filter (All/Active/Completed), clear completed, counter text
  • Every test independent and parallel-safe (--repeat-each=5 --workers=4 passes)
  • Smoke and regression tags; smoke runs in under 30 seconds
  • Runs in 3 browsers and a phone viewport
  • GitHub Actions pipeline with report artifact
  • README: set-up, how to run each tier, how to add a test
  • Public repo — add it to your portfolio as proof you can deliver the test automation service