SoftwareTestPilot
Module 07 · Lesson 1advanced 16 min read Architecture

Playwright Framework Architecture

Every senior SDET assembles the same five building blocks — folders, config, fixtures, env, reporting. Copy this blueprint and skip years of trial and error.

Quick answer

A production-grade Playwright framework has five layers: folder layout, config with projects, typed fixtures, storageState auth reuse, and tiered reporters. Get these right and adding the 500th test is as fast as adding the first.

1. Folder layout that scales

repo/
  playwright.config.ts
  global-setup.ts
  fixtures/
    index.ts             # merged typed fixtures
    auth.ts              # storageState per role
  pages/                 # POM classes
    LoginPage.ts
    DashboardPage.ts
    components/
      Header.ts
      DataTable.ts
  lib/
    api.ts               # API request helpers
    data.ts              # generators, faker wrappers
    env.ts               # typed env loader
  data/                  # static datasets
    users.json
    products.csv
  tests/
    auth/
    checkout/
    smoke/
  .env.example
  package.json

2. One config, many projects

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

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  reporter: process.env.CI
    ? [['github'], ['html', { open: 'never' }], ['blob']]
    : [['list'], ['html', { open: 'on-failure' }]],
  use: {
    baseURL: process.env.BASE_URL ?? 'https://staging.acme.co',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  projects: [
    { name: 'setup', testMatch: /global\.setup\.ts/ },
    { name: 'chromium', use: { ...devices['Desktop Chrome'], storageState: 'state/user.json' }, dependencies: ['setup'] },
    { name: 'mobile',   use: { ...devices['iPhone 14'],       storageState: 'state/user.json' }, dependencies: ['setup'] },
    { name: 'api',      testMatch: /.*\.api\.spec\.ts/ },
  ],
});

3. Typed fixtures = zero test boilerplate

fixtures/index.ts
ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
import { Api } from '../lib/api';

type Fx = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
  api: Api;
};

export const test = base.extend<Fx>({
  loginPage:     async ({ page },   use) => use(new LoginPage(page)),
  dashboardPage: async ({ page },   use) => use(new DashboardPage(page)),
  api:           async ({ request }, use) => use(new Api(request)),
});
export { expect } from '@playwright/test';

4. Auth once — storageState

tests/global.setup.ts
ts
import { test as setup } from '@playwright/test';

setup('authenticate as user', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.USER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.USER_PWD!);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.waitForURL('**/dashboard');
  await page.context().storageState({ path: 'state/user.json' });
});

5. Typed environment loader

lib/env.ts
ts
import { z } from 'zod';
import 'dotenv/config';

const Env = z.object({
  BASE_URL: z.string().url(),
  USER_EMAIL: z.string().email(),
  USER_PWD: z.string().min(6),
  API_TOKEN: z.string().optional(),
});

export const env = Env.parse(process.env);
Fail loud, fail early
Parsing env with zod at boot means a missing variable crashes the first test with a clear message — not a mysterious 401 five minutes into CI.

6. Reporting that helps humans

ReporterPurposeWhen
listTerminal progressLocal dev
htmlRich trace / screenshotsAlways — upload as CI artifact
githubAnnotations on PRGitHub Actions
junitExternal CI consumersJenkins/CircleCI when needed
blobSharded runsMerge across matrix jobs

7. Production readiness checklist

  • Every test isolated — no shared mutable state
  • storageState per role, generated in global setup
  • API helpers for data seeding, not UI flows
  • trace: on-first-retry, video: retain-on-failure
  • Retries in CI only (2), never locally
  • HTML report uploaded as artifact on every PR
  • Env parsed with zod at boot
  • No spec file over 200 lines

8. Hands-on task (45 minutes)

  1. 1

    Refactor your repo

    Adopt the folder layout above. Move POM, data and helpers to their new homes.

  2. 2

    Add typed fixtures

    Create fixtures/index.ts and update your specs to import from it.

  3. 3

    Introduce storageState

    Add a setup project that logs in once and saves state. Watch your CI time drop by 30–60%.

Ship it: CI/CD with GitHub Actions.

Frequently asked questions

1.What does a good Playwright folder structure look like?
Split by concern: `tests/` for specs, `pages/` for POM, `fixtures/` for typed fixtures, `data/` for datasets, `lib/` for API + helpers, `config/` for env configs. Keep spec files under 200 lines each.
2.One config file or many?
One `playwright.config.ts` with a `projects` array. Each project = one env or one browser. Never duplicate the whole config just to swap a base URL.
3.How do I share auth between tests?
Log in once in a global setup, save `storageState` to a JSON file, then set `use: { storageState: 'state.json' }` in a project. All tests reuse the session — no repeated logins.
4.Where should test data go?
Static fixtures in `data/*.json`. Generated data in helper functions in `lib/data.ts`. Secrets in env vars (`.env` locally, secrets manager in CI). Never commit real user data.
5.Which reporter should I use in CI?
Combine `html` (for the HTML report artifact), `github` (for annotations on PRs), and `junit` (if your CI has a JUnit consumer). Set `blob` reporter when sharding across machines and merge afterwards.
6.How do I handle multiple environments (dev/stage/prod)?
Use `projects` per environment with different `baseURL` and `storageState`. Pick with `--project=stage`. Keep secrets in env vars, never in the config file itself.

Related lessons