The Power of Playwright

Supercharging test automation with AI agents, a clean project structure, and disciplined test data.

Playwright has become my default choice for browser automation. It is fast, reliable, and has first-class TypeScript support. Recent versions also ship with AI-powered test agents that can explore an application, write a test plan, and generate tests from it. This article covers why Playwright is worth adopting, how to use the Planner agent, and how to structure a project so the AI-generated code lands in a maintainable framework.

The Application Under Test

The examples use Summit Trust Bank, a sample banking application with a web UI, a REST API, and a Postgres database (source). Customers can view accounts, transfer money, deposit and withdraw cash, pay bills, manage payees, and trade stocks from a brokerage account. It makes a good practice target because one user action touches every layer:

  • UI: the transfer form, confirmation messages, and account balances.
  • API: the endpoints behind the UI, such as /api/transfers and /api/accounts.
  • Database: the Postgres tables that hold balances and the transaction ledger.

Two demo users are seeded: demo (checking, savings, and brokerage accounts) and jane (checking and savings). Their credentials are published for this sample app, so nothing here is a real secret.

Why Playwright?

These are the features that matter most in day-to-day work.

Auto-waiting and web-first assertions

Playwright waits for elements to be actionable before it clicks or types, and its assertions retry until they pass or time out. That removes most of the sleeps and explicit waits that make UI tests flaky.

import { test, expect } from '@playwright/test';

test('logs in and lands on the accounts page', async ({ page }) => {
  await page.goto('/login');

  await page.getByTestId('login-username').fill('demo');
  await page.getByTestId('login-password').fill('Demo@123');
  await page.getByTestId('login-submit').click();

  // Retries until the URL and message are correct or the timeout is reached
  await expect(page).toHaveURL(/\/accounts$/);
  await expect(page.getByTestId('welcome-message')).toBeVisible();
});

User-facing locators

Locators such as getByRole, getByLabel, and getByTestId target the page the way a user or assistive technology sees it. They survive markup refactors far better than CSS or XPath selectors.

Summit Trust Bank exposes data-testid attributes, so getByTestId is the primary choice there. Prefer getByRole and getByLabel when the markup is semantic and no test IDs exist.

await page.getByTestId('transfer-amount').fill('250.75');
await page.getByTestId('transfer-submit').click();

Isolation and parallelism

Every test gets a fresh browser context, which behaves like a new browser profile. Tests do not share cookies or storage, so they can run in parallel across workers without interfering with each other.

UI and API in one tool

Playwright can make API calls with the same test runner. In a banking app that matters: after moving money in the UI, you want to confirm the balances are right, not just that a confirmation message appeared. The api fixture used below is defined later in this article.

test('a UI transfer updates balances in the API', async ({ api, transferPayPage }) => {
  await transferPayPage.goto('transfer');
  await transferPayPage.transfer('Everyday Checking', 'High-Yield Savings', '250.75');
  await expect(transferPayPage.transferSuccess).toContainText('Transfer of $250.75 completed.');

  const checking = await api.accountByType('CHECKING');
  expect(checking.balance).toBe(8742.22); // 8,992.97 seeded - 250.75
});

Debugging tools that make failures obvious

The trace viewer records a timeline of actions, DOM snapshots, network calls, and console output. Run npx playwright show-trace on a failed run and you can step through exactly what happened. The codegen recorder (npx playwright codegen) is also handy for exploring locators.

Cross-browser and CI-friendly

One configuration runs the same tests in Chromium, Firefox, and WebKit. The test runner has built-in retries, sharding, and HTML and JUnit reporters, which makes CI integration straightforward.

Playwright Test Agents

Playwright includes three agents that can be used on their own or chained together:

  • Planner explores the app and produces a Markdown test plan.
  • Generator turns that plan into executable Playwright tests, verifying locators and assertions against the live app.
  • Healer runs the suite and repairs failing tests.

The agents run inside an AI tool such as VS Code with GitHub Copilot, Claude Code, or OpenCode. Treat their output like any other contribution: review it, run it, and keep it consistent with your framework.

Setting up the agents

From an existing Playwright project, generate the agent definitions for your AI tool. For VS Code, that is:

npx playwright init-agents --loop=vscode

This writes the agent definitions (instructions plus Playwright MCP tools) into your repository, along with a starter seed test. Regenerate the definitions whenever you upgrade Playwright so the agents pick up new tools. The agentic experience in VS Code requires version 1.105 or later.

The seed test

The Planner needs a seed test, an ordinary test that gets the app into a ready-to-use state. The Planner runs it to perform all the initialization, including global setup, project dependencies, fixtures, and hooks. It also uses the seed as an example of how generated tests should look. That makes the seed the place where your framework and the agents meet. If it uses your fixtures, the generated tests will too.

// tests/ui/seed.spec.ts
import { expect, test } from '../../src/fixtures';

test('seed', async ({ accountsPage }) => {
  await accountsPage.goto();
  await expect(accountsPage.welcomeMessage).toBeVisible();
});

In Summit Trust Bank, the seed lives in the ui project. Before it runs, a setup project logs in as the demo user and saves the session, and an automatic fixture resets the database to its seed data. The Planner therefore starts logged in, with known balances, which makes its plans repeatable.

Generating a test plan with the Planner

  1. Open your AI chat in the project and select the planner agent.
  2. Give it a clear request and point it at the seed test. A product requirements document is optional but useful context.
  3. The Planner launches a browser, explores the application, and saves a plan under specs/.

A prompt might look like this:

Generate a plan for transferring funds between accounts. Cover the
happy path, validation errors, and balance updates. Use
tests/ui/seed.spec.ts as the seed.

The result is a human-readable Markdown file such as specs/transfer-funds.md. This is an abbreviated, illustrative example:

# Summit Trust Bank - Transfer Funds Test Plan

## Starting State
- Demo user is logged in with the seeded data
- Everyday Checking: $8,992.97, High-Yield Savings: $18,044.42

## 1. Transfers Between Own Accounts

**Seed:** `tests/ui/seed.spec.ts`

### 1.1 Transfer from checking to savings

**Steps:**
1. Open Transfer & Pay and stay on the Transfer Funds tab
2. Select "Everyday Checking" as the From account
3. Select "High-Yield Savings" as the To account
4. Enter 250.75 as the amount
5. Submit the form

**Expected Results:**
- "Transfer of $250.75 completed." is shown
- A confirmation number starting with "TRF-" is displayed
- The form is cleared
- Checking shows $8,742.22 and Savings shows $18,295.17

### 1.2 Reject an amount over the balance

**Steps:**
1. Select two different accounts
2. Enter 999999 as the amount and submit

**Expected Results:**
- "Amount exceeds the available balance." is shown
- No success message appears

### 1.3 Require both accounts
...

This is the most valuable checkpoint in the workflow. The plan is plain English, so you, a product owner, or a developer can review it before any code exists. Trim scenarios that do not matter, add edge cases the Planner missed, and correct anything that misunderstands the business rules.

The Planner drives the browser, so the plan only sees what the UI shows. For a three-layer app, add the other layers during review. For example, add expected results such as "GET /api/accounts returns the new balances" and "the transfer appears in the checking ledger with category TRANSFER". The generator can then produce tests that verify through the API, not just the screen.

Generating and healing tests

Next, select the generator agent and point it at the approved plan. It performs each scenario in a real browser, verifies locators and assertions as it goes, and writes spec files under tests/. If some of the generated tests fail, run the healer agent with the name of the failing test. It replays the steps, inspects the current UI, proposes a fix such as an updated locator or wait, and re-runs the test. If it concludes the feature itself is broken, it skips the test instead of papering over a real defect.

Always read the diffs. The healer in particular can make a test pass by weakening it, so check that assertions still verify the behavior described in the plan.

Separating the Framework from the Tests

AI makes it easy to produce a lot of test code quickly. Without structure, that becomes a lot of duplicated selectors and setup. I keep the reusable framework code apart from the tests that use it:

summit-trust-bank-tests/
  docker-compose.yml         # Postgres + the app
  playwright.config.ts       # projects: setup, ui, api
  src/                       # the framework
    global-setup.ts          # waits for /api/health
    fixtures.ts              # custom test, page objects, API clients
    api/BankApi.ts           # typed REST client
    pages/                   # page objects (Login, Accounts, TransferPay, ...)
    data/testData.ts         # seed users, accounts, quotes
    utils/format.ts          # currency helpers matching the UI
  specs/                     # human-readable test plans (Planner)
  tests/                     # the tests
    auth.setup.ts            # logs in once, saves the session
    ui/                      # browser tests (and seed.spec.ts)
    api/                     # REST API tests

The rule is simple: tests describe behavior, the framework knows how to drive the app. Specs should contain no raw selectors, URLs, or setup logic.

Page objects

Each page object owns its locators and exposes actions in business terms. A small BasePage holds shared helpers, such as picking a dropdown option by its visible text (the app renders options like "Everyday Checking (••••4567) — $8,992.97", so tests can pass just the nickname).

// src/pages/TransferPayPage.ts (trimmed)
import type { Locator, Page } from '@playwright/test';
import { BasePage } from './BasePage';

export class TransferPayPage extends BasePage {
  readonly transferFrom: Locator;
  readonly transferTo: Locator;
  readonly transferAmount: Locator;
  readonly transferMemo: Locator;
  readonly transferSubmit: Locator;
  readonly transferSuccess: Locator;
  readonly transferError: Locator;

  constructor(page: Page) {
    super(page);
    this.transferFrom = page.getByTestId('transfer-from');
    this.transferTo = page.getByTestId('transfer-to');
    this.transferAmount = page.getByTestId('transfer-amount');
    this.transferMemo = page.getByTestId('transfer-memo');
    this.transferSubmit = page.getByTestId('transfer-submit');
    this.transferSuccess = page.getByTestId('transfer-success');
    this.transferError = page.getByTestId('transfer-error');
  }

  async goto(tab = 'transfer') {
    await this.page.goto(`/transfer-pay?tab=${tab}`);
    await this.page.getByTestId(`${tab}-tab`).waitFor();
  }

  async transfer(from: string, to: string, amount: string, memo?: string) {
    await this.selectOptionContaining(this.transferFrom, from);
    await this.selectOptionContaining(this.transferTo, to);
    await this.transferAmount.fill(amount);
    if (memo) await this.transferMemo.fill(memo);
    await this.transferSubmit.click();
  }
}

A typed API client

Because the app has a REST API, the framework also includes a thin client. It wraps Playwright's request context, adds the auth token, and returns typed results, so API tests and UI tests share one way to talk to the backend.

// src/api/BankApi.ts (trimmed)
import { expect, type APIRequestContext, type APIResponse } from '@playwright/test';

export class BankApi {
  private token: string | null = null;

  constructor(private readonly request: APIRequestContext) {}

  private headers(): Record<string, string> {
    return this.token ? { Authorization: `Bearer ${this.token}` } : {};
  }

  get(path: string, params?: Record<string, string | number>) {
    return this.request.get(`/api${path}`, { headers: this.headers(), params });
  }

  post(path: string, data?: unknown) {
    return this.request.post(`/api${path}`, { headers: this.headers(), data });
  }

  async login(username: string, password: string) {
    const res = await this.post('/auth/login', { username, password });
    expect(res.status()).toBe(200);
    this.token = (await res.json()).access_token;
  }

  async accountByType(type: 'CHECKING' | 'SAVINGS' | 'BROKERAGE') {
    const res = await this.get('/accounts');
    const account = (await res.json()).find((a: { account_type: string }) => a.account_type === type);
    if (!account) throw new Error(`No ${type} account for the current user`);
    return account;
  }
}

Fixtures

Fixtures hand page objects and API clients to tests, so tests never construct them. Export your own test and re-export expect so every spec imports from one place. Fixtures are also the right home for setup and teardown, such as the automatic database reset shown here.

// src/fixtures.ts (trimmed)
import { test as base, expect } from '@playwright/test';
import { BankApi } from './api/BankApi';
import { users } from './data/testData';
import { TransferPayPage } from './pages/TransferPayPage';

interface Fixtures {
  api: BankApi;
  transferPayPage: TransferPayPage;
}

interface AutoFixtures {
  resetData: void;
}

export const test = base.extend<Fixtures & AutoFixtures>({
  // Runs before every test and restores the seed data
  resetData: [
    async ({ request }, use) => {
      await request.post('/api/test/reset');
      await use();
    },
    { auto: true },
  ],

  // An API client logged in as the demo user, with its own request context
  api: async ({ playwright, baseURL }, use) => {
    const ctx = await playwright.request.newContext({ baseURL });
    const api = new BankApi(ctx);
    await api.login(users.demo.username, users.demo.password);
    await use(api);
    await ctx.dispose();
  },

  transferPayPage: async ({ page }, use) => use(new TransferPayPage(page)),
});

export { expect };

The resulting test

// tests/ui/transfer.spec.ts (trimmed)
import { expect, test } from '../../src/fixtures';
import { users } from '../../src/data/testData';
import { cents } from '../../src/utils/format';

const { checking, savings } = users.demo.accounts;

test.describe('Transfer funds', () => {
  test.beforeEach(async ({ transferPayPage }) => {
    await transferPayPage.goto('transfer');
  });

  test('transfers money between own accounts', async ({ api, transferPayPage }) => {
    await transferPayPage.transfer(checking.nickname, savings.nickname, '250.75');

    await expect(transferPayPage.transferSuccess).toContainText('Transfer of $250.75 completed.');

    // The API confirms what the UI reported
    const from = await api.accountByType('CHECKING');
    const to = await api.accountByType('SAVINGS');
    expect(from.balance).toBe(cents(checking.balance - 250.75));
    expect(to.balance).toBe(cents(savings.balance + 250.75));
  });

  test('rejects an amount over the balance', async ({ transferPayPage }) => {
    await transferPayPage.transfer(checking.nickname, savings.nickname, '999999');

    await expect(transferPayPage.transferError).toHaveText('Amount exceeds the available balance.');
    await expect(transferPayPage.transferSuccess).toBeHidden();
  });
});

This structure pays off with agents. When the generator reads your seed test and existing specs, it imitates this style. When the UI changes, a locator fix lands in one page object instead of dozens of tests.

Managing Test Data

Test data is where many suites become fragile, and in a banking app correct balances are the whole point. A few habits help.

Start every test from known seed data

The Postgres database is created and seeded when the app first starts. For tests, Summit Trust Bank can also expose POST /api/test/reset, which restores the seed data. It is switched on with ENABLE_TEST_ENDPOINTS=true in the test environment only. An automatic fixture (shown earlier) calls it before every test, so each test begins with the same balances and no test depends on another.

The trade-off is that the reset wipes the whole database, so this suite runs with workers: 1. If you need parallelism, give each worker its own users or its own database schema instead of a global reset.

Mirror the seed data in typed code

Keep the expected seed values in one typed module, so assertions refer to checking.balance instead of magic numbers.

// src/data/testData.ts (trimmed)
export const users = {
  demo: {
    username: 'demo',
    password: 'Demo@123',
    accounts: {
      checking: { number: '1000234567', type: 'CHECKING', nickname: 'Everyday Checking', balance: 8992.97 },
      savings: { number: '2000238812', type: 'SAVINGS', nickname: 'High-Yield Savings', balance: 18044.42 },
      brokerage: { number: '3000231190', type: 'BROKERAGE', nickname: 'Self-Directed Brokerage', balance: 10000.0 },
    },
  },
  jane: {
    username: 'jane',
    password: 'Jane@123',
    // checking and savings only, no brokerage
  },
};

Having a second user pays off for authorization tests. For example, requesting another user's account should return 404.

Make unique values and money math safe

Use a unique suffix for values you need to find again, such as memos and payee names. Round money to cents before comparing, because floating-point arithmetic can drift.

// src/utils/format.ts (trimmed)
/** Rounds to cents to avoid floating point drift in balance assertions. */
export const cents = (n: number) => Math.round(n * 100) / 100;

/** Unique suffix for test data such as memos and payee names. */
export const uniqueId = () => `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;

Verify data across the layers

A UI test proves the user saw the right thing. API tests prove the data behind it is consistent. This API test checks that each account's transaction ledger reconciles with its balance, which would catch a bad update in the database layer no matter which screen caused it.

// tests/api/accounts.api.spec.ts (trimmed)
test('transaction balances reconcile with the account balance', async ({ api }) => {
  for (const account of await api.accounts()) {
    const txns = await api.transactions(account.id);
    expect(txns[0].balance_after).toBe(account.balance);

    const sum = txns.reduce((total, t) => total + t.amount, 0);
    expect(Math.round(sum * 100) / 100).toBe(account.balance);
  }
});

The database is also published on a host port in the Compose file, so you can query Postgres directly when you need to inspect rows that the API does not expose.

Keep configuration and secrets out of the code

Read the target URL from an environment variable (BASE_URL), and keep local overrides in a .env file that is excluded from source control. The demo credentials above are published for the sample app. For a real system, supply credentials through your CI system's protected variables.

Other habits

  • Reuse authentication. A setup project logs in through the UI once and saves the session to .auth/demo.json (git-ignored). UI tests load it, rather than logging in again in every test. Tests that need to start logged out clear the storage state.
  • Use deterministic dependencies. Summit Trust Bank serves fixed stock quotes, so trade totals can be asserted exactly.
  • Mark known bugs. Use test.fixme with a comment explaining the defect, so a known failure is visible without blocking the pipeline.

Tying It Together with Configuration and CI

The Playwright config declares three projects: setup (log in once), ui (depends on setup and loads the saved session), and api (no browser needed). A global setup step waits for the app's health endpoint and fails with a clear message if the app is not running.

// playwright.config.ts (trimmed)
import { defineConfig, devices } from '@playwright/test';

export const BASE_URL = process.env.BASE_URL ?? 'http://localhost:8000';
export const DEMO_STORAGE_STATE = '.auth/demo.json';

export default defineConfig({
  testDir: './tests',
  globalSetup: require.resolve('./src/global-setup'),
  // Every test resets the database, so tests run one at a time
  fullyParallel: false,
  workers: 1,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  reporter: process.env.CI
    ? [['list'], ['html', { open: 'never' }], ['junit', { outputFile: 'test-results/junit.xml' }]]
    : [['list'], ['html', { open: 'on-failure' }]],
  use: {
    baseURL: BASE_URL,
    trace: 'retain-on-failure',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'setup', testMatch: /auth\.setup\.ts/ },
    {
      name: 'ui',
      testDir: './tests/ui',
      dependencies: ['setup'],
      use: { ...devices['Desktop Chrome'], storageState: DEMO_STORAGE_STATE },
    },
    { name: 'api', testDir: './tests/api' },
  ],
});

Locally, Docker Compose starts the whole stack, Postgres and the app, with one command (docker compose up -d --wait).

# docker-compose.yml (trimmed)
services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: bank
      POSTGRES_PASSWORD: bank
      POSTGRES_DB: bank
    ports:
      - "${DB_PORT:-5433}:5432"

  app:
    image: cfhayes/summit-trust-bank:latest
    depends_on:
      db:
        condition: service_healthy
    environment:
      DATABASE_URL: postgresql+psycopg://bank:bank@db:5432/bank
      # Exposes POST /api/test/reset for the test suite
      ENABLE_TEST_ENDPOINTS: "true"
    ports:
      - "${APP_PORT:-8000}:8000"

In CI, you can run the same two containers as GitLab services next to the official Playwright image, which already includes the browsers. This is a sketch, so adjust it for your runners and match the image tag to the Playwright version in your package.json.

playwright:
  stage: test
  image: mcr.microsoft.com/playwright:v1.63.0-noble
  services:
    - name: postgres:17-alpine
      alias: db
    - name: cfhayes/summit-trust-bank:latest
      alias: app
  variables:
    POSTGRES_USER: bank
    POSTGRES_PASSWORD: bank
    POSTGRES_DB: bank
    DATABASE_URL: postgresql+psycopg://bank:bank@db:5432/bank
    ENABLE_TEST_ENDPOINTS: "true"
    BASE_URL: http://app:8000
  script:
    - npm ci
    - npx playwright test
  artifacts:
    when: always
    paths:
      - playwright-report/
      - test-results/

Takeaways

  • Auto-waiting, user-facing locators, isolated contexts, and the trace viewer make Playwright reliable and easy to debug.
  • The Planner turns a request and a seed test into a reviewable Markdown plan. Review the plan before generating code.
  • Keep the framework (page objects, fixtures, API client, test data) separate from the tests so AI-generated specs stay small and consistent.
  • Treat test data as code: reset to a known seed, mirror it in typed modules, and verify across the UI, API, and database layers.

For the full reference, see the official Playwright Test Agents documentation.

Back to Articles