Guided Walkthrough — Fixtures, Page Objects, API Mocking, Visual Testing, CI/CD

12 min read

This walkthrough is the implementation companion to the capstone brief. It takes you through building the SecureBank suite step by step — project setup, authentication, fixtures, page objects, sample tests in three categories (auth, transfer, visual), an accessibility scan, and a sharded GitHub Actions workflow. Every code block is real and runnable. The point isn't to copy-paste a finished project — the point is to internalise the shape of a senior-level Playwright suite so you can reproduce the patterns on a real engagement.

Step 1 — project setup

Initialise the project, install Playwright with TypeScript support, and configure for three browsers with sane defaults:

npm init -y
npm install --save-dev @playwright/test typescript @types/node
npx playwright install --with-deps
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
 
export default defineConfig({
  testDir: "./tests",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 2 : undefined,
  reporter: process.env.CI
    ? [["blob"], ["github"]]
    : [["html", { open: "never" }], ["list"]],
  use: {
    baseURL: process.env.BASE_URL ?? "https://app.securebank.local",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
    video: "retain-on-failure"
  },
  projects: [
    { name: "setup", testMatch: /.*\.setup\.ts/ },
    { name: "chromium", use: { ...devices["Desktop Chrome"] }, dependencies: ["setup"] },
    { name: "firefox", use: { ...devices["Desktop Firefox"] }, dependencies: ["setup"] },
    { name: "webkit", use: { ...devices["Desktop Safari"] }, dependencies: ["setup"] }
  ]
});

The setup project runs first (logs users in and saves storage state); the three browser projects depend on it.

Step 2 — authentication setup

A .setup.ts file produces storage-state files for each user role. Tests then load that state and skip the login UI entirely:

// tests/auth.setup.ts
import { test as setup, expect } from "@playwright/test";
 
const adminFile = "playwright/.auth/admin.json";
const standardFile = "playwright/.auth/standard.json";
 
setup("authenticate as admin", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill(process.env.ADMIN_EMAIL!);
  await page.getByLabel("Password").fill(process.env.ADMIN_PASSWORD!);
  await page.getByRole("button", { name: "Sign in" }).click();
  await page.getByLabel("MFA code").fill("000000");
  await page.getByRole("button", { name: "Verify" }).click();
 
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
  await page.context().storageState({ path: adminFile });
});
 
setup("authenticate as standard", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill(process.env.USER_EMAIL!);
  await page.getByLabel("Password").fill(process.env.USER_PASSWORD!);
  await page.getByRole("button", { name: "Sign in" }).click();
  await page.getByLabel("MFA code").fill("000000");
  await page.getByRole("button", { name: "Verify" }).click();
 
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
  await page.context().storageState({ path: standardFile });
});

Add playwright/.auth/ to .gitignore — the storage state contains session cookies.

Step 3 — custom fixtures

Wrap the storage states in fixtures so tests just say "give me an admin page":

// tests/fixtures.ts
import { test as base, expect, Page } from "@playwright/test";
import { DashboardPage } from "../pages/DashboardPage";
import { TransferPage } from "../pages/TransferPage";
 
type AppFixtures = {
  adminPage: Page;
  standardPage: Page;
  dashboard: DashboardPage;
  transferPage: TransferPage;
};
 
export const test = base.extend<AppFixtures>({
  adminPage: async ({ browser }, use) => {
    const context = await browser.newContext({ storageState: "playwright/.auth/admin.json" });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },
 
  standardPage: async ({ browser }, use) => {
    const context = await browser.newContext({ storageState: "playwright/.auth/standard.json" });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },
 
  dashboard: async ({ standardPage }, use) => {
    await use(new DashboardPage(standardPage));
  },
 
  transferPage: async ({ standardPage }, use) => {
    await use(new TransferPage(standardPage));
  }
});
 
export { expect };

Tests import { test, expect } from "../tests/fixtures" and pull whichever fixtures they need.

Step 4 — page objects

Page objects own locators and actions. Tests own assertions and orchestration:

// pages/DashboardPage.ts
import { Page, Locator, expect } from "@playwright/test";
 
export class DashboardPage {
  readonly page: Page;
  readonly heading: Locator;
  readonly totalBalance: Locator;
  readonly transferQuickAction: Locator;
  readonly notifications: Locator;
  readonly recentTransactions: Locator;
 
  constructor(page: Page) {
    this.page = page;
    this.heading = page.getByRole("heading", { name: "Dashboard" });
    this.totalBalance = page.getByTestId("total-balance");
    this.transferQuickAction = page.getByRole("button", { name: "Transfer" });
    this.notifications = page.getByRole("button", { name: /notifications/i });
    this.recentTransactions = page.getByTestId("recent-transactions");
  }
 
  async goto() {
    await this.page.goto("/dashboard");
    await expect(this.heading).toBeVisible();
  }
 
  async startTransfer() {
    await this.transferQuickAction.click();
  }
 
  async openNotifications() {
    await this.notifications.click();
  }
}
// pages/TransferPage.ts
import { Page, Locator, expect } from "@playwright/test";
 
export class TransferPage {
  readonly page: Page;
  readonly sourceAccount: Locator;
  readonly destinationAccount: Locator;
  readonly amount: Locator;
  readonly memo: Locator;
  readonly continueButton: Locator;
  readonly confirmButton: Locator;
  readonly successMessage: Locator;
  readonly errorMessage: Locator;
 
  constructor(page: Page) {
    this.page = page;
    this.sourceAccount = page.getByLabel("From account");
    this.destinationAccount = page.getByLabel("To account");
    this.amount = page.getByLabel("Amount");
    this.memo = page.getByLabel("Memo (optional)");
    this.continueButton = page.getByRole("button", { name: "Continue" });
    this.confirmButton = page.getByRole("button", { name: "Confirm transfer" });
    this.successMessage = page.getByRole("status", { name: /transfer complete/i });
    this.errorMessage = page.getByRole("alert");
  }
 
  async submitTransfer(opts: { from: string; to: string; amount: string; memo?: string }) {
    await this.sourceAccount.selectOption(opts.from);
    await this.destinationAccount.selectOption(opts.to);
    await this.amount.fill(opts.amount);
    if (opts.memo) await this.memo.fill(opts.memo);
    await this.continueButton.click();
    await this.confirmButton.click();
  }
}

Step 5 — sample tests

Three categories, three real test files:

Auth tests. No fixtures — these tests use the login UI:

// tests/auth.spec.ts
import { test, expect } from "@playwright/test";
 
test.describe("Authentication", () => {
  test("rejects invalid credentials", async ({ page }) => {
    await page.goto("/login");
    await page.getByLabel("Email").fill("nope@example.com");
    await page.getByLabel("Password").fill("wrong");
    await page.getByRole("button", { name: "Sign in" }).click();
 
    await expect(page.getByRole("alert")).toHaveText(/invalid email or password/i);
  });
 
  test("session timeout redirects to login", async ({ page, context }) => {
    await context.addCookies([{ name: "session", value: "expired", domain: "app.securebank.local", path: "/" }]);
    await page.goto("/dashboard");
 
    await expect(page).toHaveURL(/\/login/);
    await expect(page.getByText(/session expired/i)).toBeVisible();
  });
 
  test("user can log out", async ({ browser }) => {
    const context = await browser.newContext({ storageState: "playwright/.auth/standard.json" });
    const page = await context.newPage();
    await page.goto("/dashboard");
    await page.getByRole("button", { name: "Account menu" }).click();
    await page.getByRole("menuitem", { name: "Log out" }).click();
 
    await expect(page).toHaveURL(/\/login/);
    await context.close();
  });
});

Transfer tests with API verification and mocking:

// tests/transfer.spec.ts
import { test, expect } from "./fixtures";
 
test.describe("Transfers", () => {
  test("valid transfer creates a transaction", async ({ dashboard, transferPage, standardPage, request }) => {
    await dashboard.goto();
    await dashboard.startTransfer();
 
    await transferPage.submitTransfer({
      from: "checking-001",
      to: "savings-001",
      amount: "100.00",
      memo: "rent split"
    });
 
    await expect(transferPage.successMessage).toBeVisible();
 
    const apiResp = await request.get("/api/transactions?account=checking-001&limit=1", {
      headers: { Authorization: `Bearer ${process.env.USER_API_TOKEN}` }
    });
    expect(apiResp.ok()).toBeTruthy();
    const txns = (await apiResp.json()).items;
    expect(txns[0].amount).toBe(-100.00);
    expect(txns[0].memo).toBe("rent split");
  });
 
  test("insufficient funds shows error (mocked 422)", async ({ dashboard, transferPage, standardPage }) => {
    await standardPage.route("**/api/transfers", async route => {
      await route.fulfill({
        status: 422,
        contentType: "application/json",
        body: JSON.stringify({ error: "insufficient_funds", message: "Insufficient funds in source account" })
      });
    });
 
    await dashboard.goto();
    await dashboard.startTransfer();
    await transferPage.submitTransfer({ from: "checking-001", to: "savings-001", amount: "999999.00" });
 
    await expect(transferPage.errorMessage).toContainText(/insufficient funds/i);
  });
 
  test("exceeds daily limit (mocked 422)", async ({ dashboard, transferPage, standardPage }) => {
    await standardPage.route("**/api/transfers", async route => {
      await route.fulfill({
        status: 422,
        contentType: "application/json",
        body: JSON.stringify({ error: "limit_exceeded", message: "Daily transfer limit of $10,000 exceeded" })
      });
    });
 
    await dashboard.goto();
    await dashboard.startTransfer();
    await transferPage.submitTransfer({ from: "checking-001", to: "savings-001", amount: "20000.00" });
 
    await expect(transferPage.errorMessage).toContainText(/daily.*limit/i);
  });
});

Visual regression test:

// tests/visual.spec.ts
import { test, expect } from "./fixtures";
 
test.describe("Visual regression", () => {
  test("dashboard matches baseline", async ({ dashboard, standardPage }) => {
    await dashboard.goto();
    await standardPage.evaluate(() => {
      document.querySelectorAll("[data-dynamic]").forEach(el => el.remove());
    });
    await expect(standardPage).toHaveScreenshot("dashboard.png", {
      fullPage: true,
      maxDiffPixels: 100,
      mask: [standardPage.getByTestId("recent-transactions")]
    });
  });
 
  test("transfer empty state matches baseline", async ({ dashboard, standardPage }) => {
    await dashboard.goto();
    await dashboard.startTransfer();
    await expect(standardPage).toHaveScreenshot("transfer-empty.png", { fullPage: true });
  });
});

Step 6 — accessibility scan

Drop in @axe-core/playwright and fail on serious or critical violations:

npm install --save-dev @axe-core/playwright
// tests/a11y.spec.ts
import { test, expect } from "./fixtures";
import AxeBuilder from "@axe-core/playwright";
 
test.describe("Accessibility", () => {
  test("dashboard has no critical or serious violations", async ({ dashboard, standardPage }) => {
    await dashboard.goto();
    const results = await new AxeBuilder({ page: standardPage })
      .withTags(["wcag2a", "wcag2aa", "wcag21aa"])
      .analyze();
 
    const blocking = results.violations.filter(v => v.impact === "critical" || v.impact === "serious");
    expect(blocking, JSON.stringify(blocking, null, 2)).toEqual([]);
  });
 
  test("transfer page has no critical or serious violations", async ({ dashboard, transferPage, standardPage }) => {
    await dashboard.goto();
    await dashboard.startTransfer();
    const results = await new AxeBuilder({ page: standardPage })
      .withTags(["wcag2a", "wcag2aa", "wcag21aa"])
      .analyze();
 
    const blocking = results.violations.filter(v => v.impact === "critical" || v.impact === "serious");
    expect(blocking).toEqual([]);
  });
});

Step 7 — CI/CD with sharding and merging

GitHub Actions workflow with 4 shards × 3 browsers = 12 parallel jobs, blob reporter, merge job:

# .github/workflows/playwright.yml
name: Playwright Tests
on: [push, pull_request]
 
jobs:
  test:
    timeout-minutes: 30
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        project: [chromium, firefox, webkit]
        shardIndex: [1, 2, 3, 4]
        shardTotal: [4]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: lts/* }
      - run: npm ci
      - run: npx playwright install --with-deps ${{ matrix.project }}
      - run: npx playwright test --project=${{ matrix.project }} --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
        env:
          ADMIN_EMAIL: ${{ secrets.ADMIN_EMAIL }}
          ADMIN_PASSWORD: ${{ secrets.ADMIN_PASSWORD }}
          USER_EMAIL: ${{ secrets.USER_EMAIL }}
          USER_PASSWORD: ${{ secrets.USER_PASSWORD }}
          USER_API_TOKEN: ${{ secrets.USER_API_TOKEN }}
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: blob-${{ matrix.project }}-${{ matrix.shardIndex }}
          path: blob-report
          retention-days: 1
 
  merge-reports:
    needs: test
    if: always()
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: lts/* }
      - run: npm ci
      - uses: actions/download-artifact@v4
        with:
          path: all-blob-reports
          pattern: blob-*
          merge-multiple: true
      - run: npx playwright merge-reports --reporter html ./all-blob-reports
      - uses: actions/upload-artifact@v4
        with:
          name: html-report
          path: playwright-report
          retention-days: 14

The build timeline

Step 1 of 10

Day 1 — Scaffold

Init repo, install Playwright, configure 3 browsers + tracing, commit

⚠️ Common mistakes

  • Reaching into page DOM from tests. A test that calls page.locator(".some-class") directly defeats the page-object boundary. Tests should only ever talk to page objects and fixtures. If a locator is missing, add it to the page object — don't reach around it.
  • Single huge fixtures file. As fixtures grow, split them by domain — auth.fixtures.ts, pages.fixtures.ts, mocks.fixtures.ts. Tests import from a single barrel fixtures/index.ts. Keeps cognitive load manageable.
  • Storing storage state in git. playwright/.auth/admin.json contains a real session cookie. Anyone who clones the repo can log in as that user. Always .gitignore the auth folder.

🎯 Practice task

This walkthrough is the practice task — implement everything above against your chosen backend. 8-12 hours total over a week.

  1. Set up the project per Step 1 and commit.
  2. Write auth.setup.ts per Step 2 — confirm storage states are produced.
  3. Build the page objects in Step 4 — LoginPage, DashboardPage, TransferPage. Add BillPaymentPage and SettingsPage on your own.
  4. Write 30 tests across the six categories in the brief.
  5. Add the visual regression and a11y suites.
  6. Wire up the GitHub Actions workflow. Push and watch the matrix run.
  7. Open the merged HTML report from a CI run. Confirm trace, screenshot, and video are attached for any failure.
  8. Run --repeat-each=5 locally, fix any flake that appears.
  9. Write the README per the brief's submission checklist.

The next lesson is the review — a self-assessment checklist, reflection questions on architecture decisions, and the stretch goals plus next-steps roadmap so this capstone becomes the start of your Playwright career, not the end of the course.

// tip to track lessons you complete and pick up where you left off across devices.