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: 14The 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 barrelfixtures/index.ts. Keeps cognitive load manageable. - Storing storage state in git.
playwright/.auth/admin.jsoncontains a real session cookie. Anyone who clones the repo can log in as that user. Always.gitignorethe auth folder.
🎯 Practice task
This walkthrough is the practice task — implement everything above against your chosen backend. 8-12 hours total over a week.
- Set up the project per Step 1 and commit.
- Write
auth.setup.tsper Step 2 — confirm storage states are produced. - Build the page objects in Step 4 —
LoginPage,DashboardPage,TransferPage. AddBillPaymentPageandSettingsPageon your own. - Write 30 tests across the six categories in the brief.
- Add the visual regression and a11y suites.
- Wire up the GitHub Actions workflow. Push and watch the matrix run.
- Open the merged HTML report from a CI run. Confirm trace, screenshot, and video are attached for any failure.
- Run
--repeat-each=5locally, fix any flake that appears. - 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.