When every test imports page objects directly and handles its own login setup, a single fixture change requires updating dozens of files. The fix is a fixture index: one file that merges all fixture definitions, so tests import from ../../fixtures and nothing else. This article covers the folder layout that makes this work, the BasePage class for shared navigation, the mergeTests pattern for composing fixture files, environment config with a requireEnv() wrapper that fails at startup on missing variables, and the strangler fig migration pattern for introducing structure into an existing suite without stopping to rewrite everything.
The folder structure that actually scales
Architecture decisions made at the folder level propagate everywhere. A structure that reflects the framework's purpose (separation between test logic, page interaction, data setup, and utilities) keeps complexity manageable as the suite grows.
my-app-tests/
tests/
auth/
login.spec.ts
logout.spec.ts
items/
items-crud.spec.ts
items-search.spec.ts
pages/
BasePage.ts
LoginPage.ts
DashboardPage.ts
fixtures/
index.ts
auth.fixture.ts
pages.fixture.ts
data.fixture.ts
data/
factories/
userFactory.ts
itemFactory.ts
seeds/
seedItems.ts
helpers/
waitHelpers.ts
apiHelpers.ts
utils/
envConfig.ts
logger.ts
playwright.config.ts
tsconfig.json
.eslintrc.json
.env.exampleThe key rules that make this work: tests/ contains only spec files and no shared logic. pages/ contains only page object classes. fixtures/ is the glue layer that wires everything together. data/ owns all test data creation and seeding. helpers/ holds reusable functions that don't belong to a specific page. utils/ holds infrastructure: config, logging, anything framework-level.
Tests import from fixtures/index.ts and nothing else. That single constraint keeps the dependency graph clean.
The base page class
Every page object in a growing suite needs the same set of capabilities: navigation, waiting for the page to reach a known state, and a consistent way to handle common UI patterns. Without a base class, these patterns get copy-pasted across pages and drift apart over time.
// pages/BasePage.ts
import { Page, Locator, expect } from '@playwright/test';
import { envConfig } from '../utils/envConfig';
export abstract class BasePage {
protected readonly page: Page;
abstract readonly path: string;
constructor(page: Page) {
this.page = page;
}
async navigate(params?: Record<string, string>): Promise<void> {
const url = new URL(this.path, envConfig.baseURL);
if (params) {
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
}
await this.page.goto(url.toString());
await this.waitForPageLoad();
}
protected async waitForPageLoad(): Promise<void> {
await this.page.waitForLoadState('domcontentloaded');
}
async waitForVisible(locator: Locator, timeout = 10_000): Promise<void> {
await locator.waitFor({ state: 'visible', timeout });
}
async waitForHidden(locator: Locator, timeout = 10_000): Promise<void> {
await locator.waitFor({ state: 'hidden', timeout });
}
async assertHeading(text: string): Promise<void> {
await expect(this.page.getByRole('heading', { name: text })).toBeVisible();
}
async assertURL(expectedPath: string): Promise<void> {
await expect(this.page).toHaveURL(new RegExp(expectedPath));
}
async dismissModal(): Promise<void> {
const overlay = this.page.locator('[data-testid="modal-overlay"]');
if (await overlay.isVisible()) {
await this.page.keyboard.press('Escape');
await this.waitForHidden(overlay);
}
}
}Page objects extend the base and only define what's specific to that page:
// pages/DashboardPage.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class DashboardPage extends BasePage {
readonly path = '/dashboard';
readonly addItemButton: Locator;
readonly itemsTable: Locator;
readonly searchInput: Locator;
constructor(page: Page) {
super(page);
this.addItemButton = page.getByRole('button', { name: 'Add Item' });
this.itemsTable = page.getByRole('table', { name: 'Travel items' });
this.searchInput = page.getByRole('searchbox');
}
protected override async waitForPageLoad(): Promise<void> {
await super.waitForPageLoad();
await this.itemsTable.waitFor({ state: 'visible' });
}
async getRowCount(): Promise<number> {
const rows = this.itemsTable.getByRole('row');
return (await rows.count()) - 1;
}
async searchFor(term: string): Promise<void> {
await this.searchInput.fill(term);
await this.page.waitForResponse('**/api/items?search=**');
}
}The waitForPageLoad override in DashboardPage is the pattern that eliminates flaky tests at scale. Each page defines its own "ready" condition, and navigation waits for that condition before returning. Tests never need to add manual waits.
The fixture layer
Fixtures are the framework's dependency injection system. One file exports everything tests need: the extended test object, all page objects, data helpers, and expect. Test files import from exactly one place.
// fixtures/auth.fixture.ts
import { test as base, Page } from '@playwright/test';
import { envConfig } from '../utils/envConfig';
type AuthFixtures = {
authenticatedPage: Page;
};
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ page }, use) => {
await page.goto(envConfig.baseURL);
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill(envConfig.testUser.email);
await page.getByLabel('Password').fill(envConfig.testUser.password);
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('heading', { name: 'Dashboard' }).waitFor();
await use(page);
},
});// fixtures/pages.fixture.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
type PageFixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
};
export const test = base.extend<PageFixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
});// fixtures/index.ts
import { mergeTests, mergeExpects } from '@playwright/test';
import { test as authTest } from './auth.fixture';
import { test as pagesTest } from './pages.fixture';
import { test as dataTest } from './data.fixture';
export const test = mergeTests(authTest, pagesTest, dataTest);
export { expect } from '@playwright/test';mergeTests is the right tool here: it composes fixtures from multiple files without losing type safety. Every test in the project now has the same import:
import { test, expect } from '../../fixtures';That single import gives every test access to authenticatedPage, dashboardPage, loginPage, and all data fixtures. Adding a new fixture means editing one file in fixtures/ and it's immediately available everywhere.
mergeTests calls and re-exports. The moment you put fixture logic directly in index.ts, it becomes harder to locate where a specific fixture is defined. One fixture file per domain (auth, pages, data) keeps things navigable.Config management across environments
Hard-coded URLs are the fastest way to make a test suite unmaintainable. Environment-specific config needs a single source of truth that the rest of the framework reads from.
// utils/envConfig.ts
import * as dotenv from 'dotenv';
import * as path from 'path';
const envFile = process.env.TEST_ENV
? `.env.${process.env.TEST_ENV}`
: '.env';
dotenv.config({ path: path.resolve(process.cwd(), envFile) });
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(
`Missing required environment variable: ${name}. ` +
`Did you copy .env.example to ${envFile}?`
);
}
return value;
}
export const envConfig = {
baseURL: requireEnv('BASE_URL'),
apiBaseURL: requireEnv('API_BASE_URL'),
testUser: {
email: requireEnv('TEST_USER_EMAIL'),
password: requireEnv('TEST_USER_PASSWORD'),
},
apiToken: requireEnv('API_TOKEN'),
environment: (process.env.TEST_ENV ?? 'local') as 'local' | 'staging' | 'prod',
} as const;Three .env files sit at the project root and are committed to the repository (secrets go in CI variables, not here):
# .env.example
BASE_URL=http://localhost:3000
API_BASE_URL=http://localhost:3001/api
TEST_USER_EMAIL=test@example.com
TEST_USER_PASSWORD=replace_me
API_TOKEN=replace_me
# .env.staging
BASE_URL=https://staging.myapp.com
API_BASE_URL=https://staging.myapp.com/api
TEST_USER_EMAIL=staging-test@myapp.com
TEST_USER_PASSWORD=
API_TOKEN=playwright.config.ts reads from envConfig rather than from process.env directly:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import { envConfig } from './utils/envConfig';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: envConfig.environment !== 'local',
retries: envConfig.environment === 'local' ? 0 : 2,
workers: envConfig.environment === 'local' ? undefined : 4,
reporter: [
['html', { outputFolder: 'playwright-report' }],
['./utils/slackReporter.ts'],
],
use: {
baseURL: envConfig.baseURL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
],
});Running against staging is now one environment variable: TEST_ENV=staging npx playwright test.
Test data strategy
Tests that depend on data created by a previous test are the most fragile tests in any suite. Every test must own its data from creation to cleanup. Three patterns handle different scenarios: factories for in-test data, builders for complex objects, and API seeding for expensive pre-conditions.
Factories generate valid objects with sensible defaults and let tests override only what matters for the specific scenario:
// data/factories/itemFactory.ts
import { faker } from '@faker-js/faker';
export interface ItemData {
name: string;
category: 'Documents' | 'Electronics' | 'Clothing' | 'Other';
quantity: number;
notes?: string;
}
export function buildItem(overrides: Partial<ItemData> = {}): ItemData {
return {
name: faker.commerce.productName(),
category: 'Documents',
quantity: faker.number.int({ min: 1, max: 10 }),
...overrides,
};
}
export function buildItems(count: number, overrides: Partial<ItemData> = {}): ItemData[] {
return Array.from({ length: count }, () => buildItem(overrides));
}For complex objects with many dependencies, the builder pattern gives tests a fluent API:
// data/factories/userFactory.ts
import { faker } from '@faker-js/faker';
export interface UserData {
email: string;
password: string;
firstName: string;
lastName: string;
role: 'admin' | 'member' | 'viewer';
}
export class UserBuilder {
private data: UserData = {
email: faker.internet.email(),
password: 'TestPass123!',
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
role: 'member',
};
withRole(role: UserData['role']): this {
this.data.role = role;
return this;
}
withEmail(email: string): this {
this.data.email = email;
return this;
}
asAdmin(): this {
this.data.role = 'admin';
return this;
}
build(): UserData {
return { ...this.data };
}
}API seeding handles the case where UI creation is too slow or creates unreliable state. The data fixture wires it together and handles cleanup:
// fixtures/data.fixture.ts
import { test as base, APIRequestContext } from '@playwright/test';
import { buildItem, ItemData } from '../data/factories/itemFactory';
import { envConfig } from '../utils/envConfig';
type DataFixtures = {
apiRequest: APIRequestContext;
seededItem: ItemData & { id: string };
};
export const test = base.extend<DataFixtures>({
apiRequest: async ({ playwright }, use) => {
const context = await playwright.request.newContext({
baseURL: envConfig.apiBaseURL,
extraHTTPHeaders: {
Authorization: `Bearer ${envConfig.apiToken}`,
'Content-Type': 'application/json',
},
});
await use(context);
await context.dispose();
},
seededItem: async ({ apiRequest }, use) => {
const itemData = buildItem();
const response = await apiRequest.post('/items', { data: itemData });
const created = await response.json() as ItemData & { id: string };
await use(created);
// Cleanup runs whether test passes or fails
await apiRequest.delete(`/items/${created.id}`).catch(() => {
console.warn(`Cleanup failed for item ${created.id} — may need manual removal`);
});
},
});The .catch() in teardown is intentional. If cleanup throws, the test result should not be affected. Log the warning and move on.
Reporters: HTML and Slack notifications
The built-in HTML reporter is sufficient for local development. CI pipelines need something that delivers results where the team actually looks. In most cases, Slack.
A custom reporter implements Playwright's Reporter interface:
// utils/slackReporter.ts
import type {
Reporter,
FullConfig,
Suite,
TestCase,
TestResult,
FullResult,
} from '@playwright/test/reporter';
import * as https from 'https';
export default class SlackReporter implements Reporter {
private passed = 0;
private failed = 0;
private skipped = 0;
private failedTests: string[] = [];
private startTime = Date.now();
onBegin(_config: FullConfig, _suite: Suite): void {
this.startTime = Date.now();
}
onTestEnd(test: TestCase, result: TestResult): void {
if (result.status === 'passed') this.passed++;
else if (result.status === 'skipped') this.skipped++;
else {
this.failed++;
this.failedTests.push(test.titlePath().join(' > '));
}
}
async onEnd(result: FullResult): Promise<void> {
const webhookUrl = process.env.SLACK_WEBHOOK_URL;
if (!webhookUrl) return;
const duration = ((Date.now() - this.startTime) / 1000).toFixed(1);
const status = result.status === 'passed' ? ':white_check_mark:' : ':x:';
const total = this.passed + this.failed + this.skipped;
const blocks = [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `${status} *Playwright Tests — ${process.env.TEST_ENV ?? 'local'}*\n${this.passed}/${total} passed in ${duration}s`,
},
},
];
if (this.failedTests.length > 0) {
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: `*Failed tests:*\n${this.failedTests.map(t => `• ${t}`).join('\n')}`,
},
});
}
const payload = JSON.stringify({ blocks });
await this.postToSlack(webhookUrl, payload);
}
private postToSlack(webhookUrl: string, payload: string): Promise<void> {
return new Promise((resolve, reject) => {
const url = new URL(webhookUrl);
const req = https.request(
{ hostname: url.hostname, path: url.pathname, method: 'POST',
headers: { 'Content-Type': 'application/json' } },
() => resolve()
);
req.on('error', reject);
req.write(payload);
req.end();
});
}
}Register the reporter in playwright.config.ts:
reporter: [
['html', { outputFolder: 'playwright-report', open: 'never' }],
envConfig.environment !== 'local' ? ['./utils/slackReporter.ts'] : ['list'],
],The Slack reporter only activates on non-local environments. No noise during local development.
TypeScript strict mode and linting
Test code is production code. It runs in CI, it affects release decisions, and bugs in tests are harder to catch than bugs in application code because there's nothing testing the tests. TypeScript strict mode and ESLint catch entire categories of issues before they reach a team member.
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"exactOptionalPropertyTypes": true,
"moduleResolution": "node",
"esModuleInterop": true,
"outDir": "./dist",
"baseUrl": ".",
"paths": {
"@fixtures": ["./fixtures/index.ts"],
"@pages/*": ["./pages/*"],
"@data/*": ["./data/*"],
"@helpers/*": ["./helpers/*"],
"@utils/*": ["./utils/*"]
}
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}The paths configuration means test files can use clean imports:
import { test, expect } from '@fixtures';
import { buildItem } from '@data/factories/itemFactory';For ESLint, the key rules for test quality are the ones that prevent common Playwright-specific mistakes:
// .eslintrc.json
{
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "playwright"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/strict-type-checked",
"plugin:playwright/recommended"
],
"parserOptions": {
"project": "./tsconfig.json"
},
"rules": {
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/await-thenable": "error",
"playwright/no-wait-for-timeout": "error",
"playwright/no-conditional-in-test": "warn",
"playwright/prefer-web-first-assertions": "error",
"playwright/no-networkidle": "warn"
}
}no-floating-promises is the most important rule in a Playwright test suite. Missing an await before a Playwright call is a common source of false positives: the assertion runs before the action completes, the test passes, and then the UI is in an unexpected state for the next step. TypeScript alone won't catch this; the linting rule will.
Growing alongside an existing suite: the strangler fig pattern
The strangler fig pattern describes incrementally replacing an old system by growing a new one around it, gradually routing traffic from old to new until nothing touches the old system and it can be removed. The same approach applies to test frameworks.
Starting a "framework rewrite" as a parallel effort always fails. The new framework lives in a separate branch, the old suite keeps changing, the branch never merges. The strangler fig approach keeps the team shipping tests in the old structure while the new structure gradually absorbs them.
The practical steps:
Step 1: Create the new folder structure alongside the existing tests. Don't move anything yet.tests/ ← existing flat structure, untouched
framework/ ← new structure, starts empty
tests/
pages/
fixtures/
...
playwright.config.ts ← updated to run bothUpdate playwright.config.ts to include both test directories:
export default defineConfig({
projects: [
{
name: 'legacy',
testDir: './tests',
use: { baseURL: envConfig.baseURL },
},
{
name: 'framework',
testDir: './framework/tests',
use: { baseURL: envConfig.baseURL },
},
],
});// framework/tests/items/items-search.spec.ts
// Migrated from tests/items-search.spec.ts
// Migration: extracted LoginPage, wired to fixtures, removed hardcoded URL
import { test, expect } from '../../fixtures';
test('search filters items by name', async ({ authenticatedPage, dashboardPage }) => {
await dashboardPage.navigate();
await dashboardPage.searchFor('Passport');
await expect(dashboardPage.itemsTable.getByRole('row')).toHaveCount(2); // header + 1 result
});tests/ directory gains new files:
// package.json
"scripts": {
"check:no-new-legacy-tests": "node scripts/checkLegacyTests.js",
"test": "playwright test",
"test:framework": "playwright test --project=framework",
"test:legacy": "playwright test --project=legacy",
"lint": "eslint . --ext .ts",
"typecheck": "tsc --noEmit"
}The check script reads the git diff and fails if any new .spec.ts files appear in tests/. Teams stop adding to the old structure not because of a rule but because the new structure is clearly better, and the check provides a safety net for anyone who hasn't noticed the pattern yet.
After a few months of this, the legacy directory contains only old tests that nobody has touched. At that point, a dedicated migration sprint converts the remainder, and the legacy directory is deleted. The migration happened incrementally, the team shipped features the whole time, and the framework is in production from day one.
FAQ
How many page objects should one class cover?One page per class, one modal per class. If a page has two completely separate sections (a sidebar and a main panel with different concerns), split them into two classes and compose them in the fixture. A class that covers two pages is a sign that the boundary was drawn in the wrong place.
Should fixtures ever contain assertions?No. Fixtures set up and tear down state. An assertion in a fixture makes it impossible to tell whether a test failure came from the test logic or the setup. If you need to verify that setup completed successfully, use Playwright's waitFor with a condition rather than an assertion. Assertions belong exclusively in test files.
Create separate auth fixtures, one per role: adminPage, memberPage, viewerPage. Each fixture logs in as a different user and hands the authenticated page to the test. If the number of roles grows, consider a factory pattern: authenticatedAs('admin') returns the right fixture based on a parameter.
Start with workers: '50%' in playwright.config.ts (half the available CPU cores). Monitor your CI runner's resource usage over several runs. If tests start flaking due to resource contention, reduce workers. If the runner has headroom, increase them. The right number depends on the runner spec and how resource-intensive each test is, not on a universal formula.
test.describe vs separate spec files?
Separate spec files for separate features. test.describe for logical groupings within a feature: happy path vs edge cases, or read operations vs write operations. The rule of thumb: if two groups of tests need different test.use() configuration (different fixture overrides), they belong in separate describe blocks or separate files. If they use the same setup, grouping them is a style choice.