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.json2. 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
| Reporter | Purpose | When |
|---|---|---|
| list | Terminal progress | Local dev |
| html | Rich trace / screenshots | Always — upload as CI artifact |
| github | Annotations on PR | GitHub Actions |
| junit | External CI consumers | Jenkins/CircleCI when needed |
| blob | Sharded runs | Merge 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
Refactor your repo
Adopt the folder layout above. Move POM, data and helpers to their new homes.
- 2
Add typed fixtures
Create
fixtures/index.tsand update your specs to import from it. - 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.