Playwright Testing Framework Standards
Patterns and conventions for building scalable, maintainable test automation frameworks with Playwright + TypeScript.
Core Principles
Five non-negotiable rules that govern every test. These drive architecture decisions and reviewer expectations — all five must hold for every PR.
① Test Independence
Every test runs standalone in any order, in parallel with others. Each test creates its own data — no shared state, no inter-test dependencies.
② Leave No Trace
Tests clean up all data they create, even on failure. API-based cleanup preferred over UI — faster, more reliable, runs even when browser crashes.
③ Repeatability
Tests pass consistently across runs without modification. No hardcoded waitForTimeout — use waitForLoadState, waitForResponse, or expect assertions.
④ Maintainability
DRY code via shared helpers, Page Object fixtures, and consistent naming. Shared logic lives in functions/, not copy-pasted across specs.
⑤ Trustworthiness
Tests verify state changes, not just actions. Assertions cover both happy paths and edge cases — a passing test must mean something.
Project Structure
Every directory has a single responsibility. Files are domain-grouped, not type-grouped. Tests are separated by layer: api/ for request-level, ui/ for browser-level.
Convention: The setup project runs before all others and saves authenticated browser state. UI and API projects declare dependencies: ['setup'] and restore that state rather than logging in per test.
Code Standards
ESLint and Prettier are non-negotiable — zero warnings allowed in CI. TypeScript strict mode enforced via compiler options. All settings checked into version control.
TypeScript (tsconfig.json)
"module": "ESNext"— modern ESM"moduleResolution": "Bundler""paths": { "@/*": ["*"] }— path aliases"types": ["node"]— Node globals
Prettier (.prettierrc.json)
printWidth: 120,tabWidth: 4semi: false— no semicolonssingleQuote: truetrailingComma: "none"arrowParens: "avoid"
ESLint key rules
'curly': [2, 'all']— always use braces'eqeqeq': [2, 'always']— strict===only'import/order': 2— organized imports'object-shorthand': [2, 'always']'@typescript-eslint/consistent-type-imports': 2'@typescript-eslint/no-unused-vars': 2
Import convention
// ✅ Type imports separated import type { Page, APIRequestContext } from '@playwright/test' import { test, expect } from '@playwright/test' import { createUser } from '@/functions/users' // ❌ Mixed type + value imports import { Page, test } from '@playwright/test'
Custom Fixtures — BasePage
BasePage wraps Page via a Proxy, transparently forwarding all native page calls while adding an authenticated API context and session caching on top.
fixtures/BasePage.tsexport class BasePage { public page: Page private api: APIRequestContext | null = null constructor(page: Page) { this.page = page // Proxy delegates unknown props to the inner Page instance return new Proxy(this, { get(target, prop) { return prop in target ? target[prop] : target.page[prop] } }) } async getAPI(): Promise<APIRequestContext> { if (!this.api) { const cookies = await this.page.context().cookies() const token = cookies.find(c => c.name === 'auth_token')?.value this.api = await this.page.request.newContext({ extraHTTPHeaders: { 'Authorization': `Bearer ${token}` } }) } return this.api } }
Usage: Tests destructure customPage: page from the fixture. page.goto() and page.getByRole() hit the native Page; page.getAPI() returns the authenticated request context for direct API calls without a second fixture.
BaseAPI & Fixture Registration
BaseAPI is a standalone context for API-only tests — no browser overhead. Both fixtures are registered once in fixtures/index.ts via test.extend and re-exported.
fixtures/BaseAPI.tsexport class BaseAPI { public apiContext: APIRequestContext async init(token: string) { this.apiContext = await request.newContext({ baseURL: process.env.BASE_URL, extraHTTPHeaders: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }) } get(url: string, opts?: any) { return this.apiContext.get(url, opts) } post(url: string, opts?: any) { return this.apiContext.post(url, opts) } delete(url: string, opts?: any) { return this.apiContext.delete(url, opts) } }
fixtures/index.tsexport type CustomPage = BasePage & Page export const test = base.extend<MyFixtures>({ customPage: async ({ page }, use) => { await use(new BasePage(page) as CustomPage) }, apiContext: async ({}, use) => { const api = new BaseAPI() await api.init(process.env.API_TOKEN!) await use(api) await api.dispose() } }) export { expect } from '@playwright/test'
Helper Functions
Helpers in functions/ are domain-grouped and overloaded to accept either BasePage or BaseAPI — the same createUser call works from a UI test or an API test.
Organization principles
- Domain-based files:
users.ts,products.ts - Verb + noun naming:
createUser,deleteOrder - Each function does exactly one thing
- Overloaded for Page and API contexts
Unique test data generation
functions/common.tsimport { faker } from '@faker-js/faker' export function generateUserData() { return { email: `${faker.string.alphanumeric(10)}@test.com`, password: faker.internet.password({ length: 12 }) } }
Function overloading pattern
functions/users.ts// Overload signatures export async function createUser( page: BasePage, data: UserData ): Promise<string> export async function createUser( api: BaseAPI, data: UserData ): Promise<string> // Shared implementation export async function createUser( fixture: any, data: UserData ): Promise<string> { const api = await fixture.getAPI() const res = await api.post('/api/users', { data }) return (await res.json()).id }
Test Design Patterns
Every test follows Feature > Section > Action naming with a unique annotation ID. Arrange-Act-Assert structure with cleanup baked in as the final step.
tests/ui/users.spec.tstest('User Management > Profile > Edit first name', { annotation: { type: 'ID', description: 'USR-042' } }, async ({ customPage: page }) => { // Arrange const userId = await createUser(page, generateUserData()) // Act await page.goto(`/users/${userId}/edit`) await page.getByLabel('First Name').fill('Updated') await page.getByRole('button', { name: 'Save' }).click() // Assert await expect(page.getByText('Profile updated')).toBeVisible() // Cleanup — runs even when assertions fail via test.afterEach await deleteUser(page, userId) })
Wait strategies
- ✅
waitForLoadState('domcontentloaded') - ✅
waitForResponse(r => r.url().includes(...)) - ✅
expect(locator).toBeVisible({ timeout: 10000 }) - ❌
waitForTimeout(3000)— debug only, never commit
Selector priority
- ①
getByRole('button', { name: 'Submit' }) - ②
getByTestId('submit-btn') - ③
getByLabel('Email') - ❌
.nth(2), absolute XPath
Data Management
Tests create unique data to avoid parallel conflicts. API cleanup is faster and preferred over UI. All config lives in environment variables — no hardcoded URLs or credentials.
Unique data per test run
// ✅ Unique — safe for parallel runs const name = `Product-${Date.now()}` // ❌ Hardcoded — conflicts in parallel const name = 'Test Product'
Shared setup with beforeAll
let userId: string test.beforeAll(async ({ apiContext }) => { userId = await createUser(apiContext, generateUserData()) }) test.afterAll(async ({ apiContext }) => { await deleteUser(apiContext, userId) })
Environment variables
.env.exampleBASE_URL=https://app.example.com
API_TOKEN=your_token_here
TEST_USER_EMAIL=test@example.com
TEST_USER_PASSWORD=SecurePass123
Pre-test stale data cleanup
// Remove leftovers from interrupted runs test.beforeAll(async ({ apiContext }) => { const stale = await getUsers( apiContext, { email: 'test@example.com' } ) for (const u of stale) { await deleteUser(apiContext, u.id) } })
Custom Reporters
The custom reporter tracks per-test history (last 10 runs) and automatically marks flaky tests — passed on retry. Results persist to a JSON file scoped by ENV variable.
reporter/reporter.tsclass CustomReporter implements Reporter { private runId = Date.now().toString() onTestEnd(test: TestCase, result: TestResult): void { const annotation = result.annotations.find(a => a.type === 'ID') const entry: TestResultEntry = { time: new Date().toISOString(), result: result.status, file: path.basename(test.location?.file ?? 'unknown'), retried: result.retry > 0, flaky: result.retry > 0 && result.status === 'passed', testId: annotation?.description ?? 'NO-ID', runId: this.runId } const history = this.results.tests[test.title] ??= [] if (history.length >= 10) history.shift() history.push(entry) } }
Register in playwright.config.ts: reporter: [['./reporter/reporter.ts'], ['html'], ['list']]. A test that fails then passes on retry is marked flaky: true — investigate before merging.
CI/CD Integration
Tests run nightly and on every pull request. CI enforces stricter settings: forbidOnly blocks committed test.only calls, 1 retry, 2 workers. Artifacts retained 30 days.
.github/workflows/playwright.ymlon: schedule: - cron: '0 2 * * *' # nightly at 02:00 pull_request: workflow_dispatch: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 20 } - run: npm ci - run: npx playwright install --with-deps - run: npx playwright test - uses: actions/upload-artifact@v4 if: always() with: name: playwright-report path: playwright-report/ retention-days: 30
playwright.config.tsexport default defineConfig({ testDir: './tests', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, workers: process.env.CI ? 2 : undefined, use: { baseURL: process.env.BASE_URL, trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure' }, projects: [ { name: 'setup', testMatch: /.*\.setup\.ts/ }, { name: 'chromium', use: { ...devices['Desktop Chrome'] }, dependencies: ['setup'] }, { name: 'api', testMatch: /.*\.api\.test\.ts/ } ] })
PR Checklist & Best Practices
Every PR must pass all checklist items before requesting review. A test that passes once is not enough — run 3+ times locally without failure.
PR checklist
- ✅ Tests pass locally 3+ times in a row
- ✅ No ESLint errors or TypeScript compile warnings
- ✅ No
waitForTimeoutcalls checked in - ✅ No
test.onlyleft behind - ✅ All created data cleaned up in hooks
- ✅ Tests are independent (run in any order)
- ✅ Every test has a unique annotation ID
- ✅ New helpers documented;
.env.exampleupdated
Performance targets
- API tests: complete in under 10 seconds
- UI tests: complete in under 30 seconds
- API cleanup preferred over UI (faster, less flaky)
Do ✅
- Use custom fixtures for domain ops
- Use API for data setup/teardown
- Generate unique data per test
- Role-based selectors first
- Separate type imports
- One focused assertion per test
- Add unique test IDs for tracking
Don't ❌
- Share state across tests
- Hardcode conflicting data
- Commit
waitForTimeout - Use nth-child / XPath selectors
- Skip cleanup on failure
- Commit secrets or tokens
- Leave
test.onlyin code