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.
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
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 |
|---|---|---|
retries | 2 in CI, 0 locally | Flakiness buffer for CI without masking real local failures |
trace | on-first-retry | Playwright trace viewer on retry — full step-by-step replay |
screenshot | only-on-failure | Keeps artefact storage small; screenshots exist when you need them |
video | retain-on-failure | Video for failed tests only — diagnose timing issues in CI |
locale | en-NZ | Date formats, currency symbols, and Accept-Language header match NZ users |
timezoneId | Pacific/Auckland | Prevents 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.
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; } }
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
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.
// 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 saving | NZDT | UTC+13 | Late Sep – early Apr |
| Standard time | NZST | UTC+12 | Early Apr – late Sep |
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.