Templates · Automation

Playwright TypeScript Boilerplate

A production-ready Playwright TypeScript project with Page Object Model, full CI integration, and NZ locale/timezone config. Copy it, rename, and write tests — the scaffolding is already done.

Senior Playwright v1.45+ TypeScript 5

1 Directory Structure

Organise your project so every concern lives in its own folder. Shared config at the root, tests split by layer, Page Objects separate from tests, and fixtures in one place.

# Playwright TypeScript project layout
my-project/
├── playwright.config.ts          # Central config — base URL, retries, reporters, projects
├── package.json
├── tsconfig.json
├── .env                          # Local secrets — never commit
├── .env.example                  # Template — commit this
│
├── tests/
│   ├── e2e/                      # End-to-end browser flows
│   │   ├── login.spec.ts
│   │   ├── checkout.spec.ts
│   │   └── ...
│   └── api/                      # API-layer tests (no browser)
│       ├── auth.api.spec.ts
│       └── products.api.spec.ts
│
├── pages/                        # Page Object Models (POMs)
│   ├── LoginPage.ts
│   ├── DashboardPage.ts
│   └── ...
│
├── fixtures/                     # Custom test fixtures (extend base test)
│   └── auth.fixture.ts
│
├── test-data/                    # Static test data (JSON / CSV)
│   ├── users.json
│   └── products.json
│
└── .github/
    └── workflows/
        └── playwright.yml        # CI pipeline
Pro tip: Keep pages/ and fixtures/ outside tests/ so Playwright's test runner doesn't try to execute them as specs.

2 playwright.config.ts

The config file controls everything: which browsers run, how many retries, what reporters produce, and where screenshots land. Keep all environment-specific values in environment variables so the same config works locally and in CI without changes.

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import * as dotenv from 'dotenv';

dotenv.config();

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,   // fail build if test.only is left in
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 2 : undefined,

  reporter: [
    ['list'],
    ['html', { outputFolder: 'playwright-report', open: 'never' }],
  ],

  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:3000',

    // Capture artefacts only when they add diagnostic value
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',

    // NZ locale and timezone — see Section 8 for why this matters
    locale: 'en-NZ',
    timezoneId: 'Pacific/Auckland',
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },

    /* Mobile viewports — uncomment when needed
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 7'] },
    },
    {
      name: 'mobile-safari',
      use: { ...devices['iPhone 15'] },
    },
    */
  ],

  // Spin up your dev server before the test run (optional)
  // webServer: {
  //   command: 'npm run start',
  //   url: 'http://localhost:3000',
  //   reuseExistingServer: !process.env.CI,
  // },
});
Option Value used here Why
retries2 in CI, 0 locallyFlakiness buffer for CI without masking real local failures
traceon-first-retryPlaywright trace viewer on retry — full step-by-step replay
screenshotonly-on-failureKeeps artefact storage small; screenshots exist when you need them
videoretain-on-failureVideo for failed tests only — diagnose timing issues in CI
localeen-NZDate formats, currency symbols, and Accept-Language header match NZ users
timezoneIdPacific/AucklandPrevents DST-related test failures — see Section 8

3 Page Object Model

A Page Object wraps one page (or component) of your app. Tests call its methods — the POM handles all the raw locator and click calls. When the UI changes, you fix one file, not fifty tests.

Design principle

Think of a POM as the remote control for a TV. You press Play — you don't care which internal components fire. Tests press loginPage.login() — they don't need to know which input has which selector.

// pages/LoginPage.ts
import { type Locator, type Page } from '@playwright/test';

export class LoginPage {
  readonly page: Page;

  // Declare all locators as readonly class properties
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;
  readonly forgotPasswordLink: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput       = page.getByLabel('Email address');
    this.passwordInput    = page.getByLabel('Password');
    this.submitButton     = page.getByRole('button', { name: 'Sign in' });
    this.errorMessage     = page.getByRole('alert');
    this.forgotPasswordLink = page.getByRole('link', { name: /forgot/i });
  }

  /** Navigate to the login page */
  async goto(): Promise<void> {
    await this.page.goto('/login');
  }

  /**
    * Fill credentials and submit the form.
    * Resolves once navigation after login is complete.
    */
  async login(email: string, password: string): Promise<void> {
    await this.goto();
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  /** Returns the visible error text, or null if no error shown */
  async getErrorText(): Promise<string | null> {
    const visible = await this.errorMessage.isVisible();
    return visible ? this.errorMessage.textContent() : null;
  }
}
Rule of thumb: One class per page or major UI component. Never put assertions inside POMs — keep assertion logic in the test itself so failures point to the right place.

4 Example Test Using the POM

Tests stay thin. They describe intent ("a valid user can log in") and delegate mechanics to the POM. This keeps tests readable months later when someone who didn't write them needs to understand a failure.

// tests/e2e/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../../pages/LoginPage';

test.describe('Login', () => {
  let loginPage: LoginPage;

  test.beforeEach(async ({ page }) => {
    loginPage = new LoginPage(page);
  });

  test('valid credentials redirect to dashboard', async ({ page }) => {
    await loginPage.login(
      process.env.TEST_USER_EMAIL    ?? 'qa@example.co.nz',
      process.env.TEST_USER_PASSWORD ?? 'Test@1234'
    );

    // Assert on the outcome, not the mechanics
    await expect(page).toHaveURL(/\/dashboard/);
    await expect(page.getByRole('heading', { name: /welcome/i })).toBeVisible();
  });

  test('wrong password shows error message', async () => {
    await loginPage.login('qa@example.co.nz', 'wrong-password');

    const error = await loginPage.getErrorText();
    expect(error).toContain('Invalid email or password');
  });

  test('empty email shows validation error', async () => {
    await loginPage.goto();
    await loginPage.submitButton.click();

    await expect(loginPage.errorMessage).toBeVisible();
    await expect(loginPage.emailInput).toBeFocused();
  });

  test('forgot-password link is present', async () => {
    await loginPage.goto();
    await expect(loginPage.forgotPasswordLink).toBeVisible();
  });
});

5 Custom Fixture

Fixtures extend Playwright's built-in test object so every test in a suite automatically gets a pre-logged-in page (or any other state) without repeating setup code. The fixture handles teardown too.

// fixtures/auth.fixture.ts
import { test as base, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';

// Define the shape of your custom fixtures
type AuthFixtures = {
  loginPage: LoginPage;
  authenticatedPage: DashboardPage;
};

// Extend the base test with your fixtures
export const test = base.extend<AuthFixtures>({
  loginPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await use(loginPage);
    // teardown (runs after each test that uses this fixture)
    await page.close();
  },

  authenticatedPage: async ({ page }, use) => {
    // Perform login once, then yield the page to the test
    const loginPage = new LoginPage(page);
    await loginPage.login(
      process.env.TEST_USER_EMAIL    ?? 'qa@example.co.nz',
      process.env.TEST_USER_PASSWORD ?? 'Test@1234'
    );
    await expect(page).toHaveURL(/\/dashboard/);

    const dashboard = new DashboardPage(page);
    await use(dashboard);
  },
});

export { expect } from '@playwright/test';

In a test file that needs an already-authenticated user, import your custom test instead of Playwright's:

// tests/e2e/dashboard.spec.ts
import { test, expect } from '../../fixtures/auth.fixture';

test('dashboard shows account summary', async ({ authenticatedPage }) => {
  await expect(authenticatedPage.accountSummaryPanel).toBeVisible();
});

6 GitHub Actions Workflow

Run the full cross-browser suite on every push and pull request. Artefacts — HTML report, traces, screenshots, videos — are uploaded so failures are diagnosable without re-running locally.

# .github/workflows/playwright.yml
name: Playwright Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    name: Playwright (${{ matrix.project }})
    runs-on: ubuntu-latest
    timeout-minutes: 30

    strategy:
      fail-fast: false          # run all browsers even if one fails
      matrix:
        project: [chromium, firefox, webkit]

    env:
      CI: true
      BASE_URL:            ${{ secrets.BASE_URL }}
      TEST_USER_EMAIL:     ${{ secrets.TEST_USER_EMAIL }}
      TEST_USER_PASSWORD:  ${{ secrets.TEST_USER_PASSWORD }}

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps ${{ matrix.project }}

      - name: Run Playwright tests
        run: npx playwright test --project=${{ matrix.project }}

      - name: Upload test report
        uses: actions/upload-artifact@v4
        if: always()             # upload even if tests fail
        with:
          name: playwright-report-${{ matrix.project }}
          path: playwright-report/
          retention-days: 14

      - name: Upload test artefacts
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-artefacts-${{ matrix.project }}
          path: |
            test-results/
          retention-days: 7
Secrets: Add BASE_URL, TEST_USER_EMAIL, and TEST_USER_PASSWORD under Repository → Settings → Secrets and variables → Actions. Never hard-code credentials in the YAML.

7 .env.example

Commit .env.example with placeholder values so any new team member knows exactly which variables to set. Add .env to .gitignore so real credentials are never committed.

# .env.example — copy to .env and fill in real values
# .env is in .gitignore — never commit secrets

# Target environment URL
BASE_URL=https://staging.example.co.nz

# Credentials for the test user account
TEST_USER_EMAIL=qa-automation@example.co.nz
TEST_USER_PASSWORD=replace-me

# Admin user (for tests that require elevated permissions)
ADMIN_USER_EMAIL=qa-admin@example.co.nz
ADMIN_USER_PASSWORD=replace-me

# API base URL (for API-layer tests)
API_BASE_URL=https://api.staging.example.co.nz

# Optional: Playwright trace/report settings
PWDEBUG=0

Load it in playwright.config.ts with dotenv.config() (already included in the config above). In CI, set the same variable names as repository secrets — the .env file is not present in CI runners.

8 NZ Timezone Note

New Zealand observes daylight saving time (NZDT, UTC+13) from late September to early April, and standard time (NZST, UTC+12) the rest of the year. If your Playwright config does not set timezoneId, the test runner uses the UTC timezone of the CI server — which differs from production and from real NZ users by up to 13 hours.

Common DST failure pattern: A test checks that "today's orders" are shown in a date-filtered table. Locally it passes. In CI at midnight NZ time, the CI server is still on yesterday's UTC date — the filter returns an empty set, the test fails. The root cause is never the test logic; it is always the missing timezone.
// Always set both locale and timezoneId together
use: {
  locale: 'en-NZ',
  timezoneId: 'Pacific/Auckland',   // Handles NZST/NZDT automatically
}
Period Timezone name UTC offset Active
Daylight savingNZDTUTC+13Late Sep – early Apr
Standard timeNZSTUTC+12Early Apr – late Sep
Why Pacific/Auckland and not UTC+13?

Hard-coding a numeric offset like UTC+13 locks you to one DST state year-round. The named zone Pacific/Auckland is a IANA timezone identifier — the OS automatically switches between NZST and NZDT at the correct switchover dates each year. Your tests stay accurate in all seasons without touching the config.

Apply the same setting to any date-formatting utility used in your test helpers, and ensure your test data JSON stores dates in ISO 8601 format with the timezone offset explicitly included (2026-06-27T14:30:00+12:00) rather than as bare date strings.