P0 · Chapter 03 / 15
Framework / POM / Fixtures
The 60-second cheat sheet#
A framework organizes tests, resource lifecycles, service clients, data factories, configuration and evidence. POM encapsulates UI interaction; fixtures inject typed dependencies and manage setup/teardown. test.extend() creates a test API with fixtures; it is not classical class inheritance.
Interviewers want maintainable boundaries: tests explain business intent, page objects encapsulate page mechanics, service clients implement transport contracts, and fixtures own resource lifecycle. Avoid a universal base class or a helper for every single line.
Project boundaries#
playwright.config.ts
pages/LoginPage.ts
fixtures/test.ts
fixtures/worker.ts
clients/VerificationClient.ts
data/user-builder.ts
utils/correlation.ts
tests/ui/login.spec.ts
tests/api/verification.spec.ts
Keep transport details in clients, factories in data, and truly shared mechanics in utils. Do not create a utils dumping ground or hide assertions so deeply that failures lose meaning. An API client can own bounded polling if every caller needs the same completion contract; keep poll in the test when timing/status is the behavior under test.
Complete small Page Object#
// pages/LoginPage.ts: complete module; illustrative UI contract.
import type { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly email: Locator;
readonly password: Locator;
readonly submit: Locator;
readonly errorMessage: Locator;
constructor(private readonly page: Page) {
this.email = page.getByLabel('Email');
this.password = page.getByLabel('Password');
this.submit = page.getByRole('button', { name: 'Sign in', exact: true });
this.errorMessage = page.getByRole('alert');
}
async goto(): Promise<void> { await this.page.goto('/login'); }
async login(email: string, password: string): Promise<void> {
await this.email.fill(email);
await this.password.fill(password);
await this.submit.click();
}
}
Locators are readonly bindings, not cached DOM nodes. A semantic login method describes an action; the test decides whether login should succeed or fail. A reusable page-specific assertion helper can be useful, but avoid assuming every login must navigate successfully (negative tests need the error state).
Test fixtures, hooks and lifecycle#
// fixtures/test.ts: complete module, matches the project tree.
import { test as base, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
export const test = base.extend<{ loginPage: LoginPage }>({
loginPage: async ({ page }, use) => {
const login = new LoginPage(page);
await login.goto();
await use(login); // test runs here; teardown would follow
},
});
export { expect };
// tests/ui/login.spec.ts: complete module.
import { test, expect } from '../../fixtures/test';
test('authorized user reaches dashboard', async ({ loginPage, page }) => {
const email = process.env.QA_EMAIL;
const password = process.env.QA_PASSWORD;
if (!email || !password) throw new Error('QA credentials are required');
await loginPage.login(email, password);
await expect(page).toHaveURL(/\/dashboard(?:\?|$)/);
});
Fixtures are generally lazy: requested dependencies set up first; dependent resources tear down before dependencies. Automatic fixtures ({ auto: true }) run for their scope even without explicit test use. Code before await use(resource) is setup; afterward is teardown. Wrap use in try/finally for resource cleanup. If setup allocates several things then fails before use, already allocated resources still need finally cleanup. beforeEach is clearer for simple repeated navigation in one suite; a fixture is better for reusable owned resources and dependency composition.
// Complete local-suite alternative: simple navigation needs no custom fixture.
import { test, expect } from '@playwright/test';
test.describe('public help', () => {
test.beforeEach(async ({ page }) => { await page.goto('/help'); });
test('shows verification guidance', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Verification help' })).toBeVisible();
});
});
Advanced design exercise: resource ownership and partial setup
Suppose a fixture allocates a user, opens a service context and then seeds a verification record, but seeding fails before use. Write the acquired resources down in order. Each acquisition needs a corresponding teardown that remains reachable if a later step fails. A single finally only after use is too late if allocation occurs outside its protected region. Prefer nested ownership or a cleanup stack with independent failure reporting. Releasing one resource must not prevent attempts to release the others.
For a parallel suite, decide separately which data is shared, who may mutate it, what happens when a worker restarts and how orphan records expire. Then show how to run just one test from an empty environment. If it depends on another test to create a record, the framework has an implicit dependency. Replace that with an explicit fixture or a setup project for a real shared prerequisite. Review whether the abstraction exposes useful domain operations or merely wraps every Playwright method.
Worker fixtures and process boundaries#
// fixtures/worker.ts: complete module. Hypothetical test-support lease API.
import { test as base, request, expect, type APIRequestContext } from '@playwright/test';
import { randomUUID } from 'node:crypto';
type WorkerResources = { service: APIRequestContext; lease: { id: string } };
export const test = base.extend<{}, WorkerResources>({
service: [async ({}, use) => {
const baseURL = process.env.BASE_URL;
if (!baseURL) throw new Error('BASE_URL is required');
const api = await request.newContext({ baseURL });
try { await use(api); } finally { await api.dispose(); }
}, { scope: 'worker' }],
lease: [async ({ service }, use, info) => {
const scope = `${process.env.CI_PIPELINE_ID ?? 'local'}-${process.env.CI_NODE_INDEX ?? '1'}-${info.parallelIndex}-${randomUUID()}`;
const response = await service.post('/test-support/leases', { data: { scope } });
expect(response.status()).toBe(201);
const lease: { id: string } = await response.json();
try { await use(lease); }
finally {
const released = await service.delete(`/test-support/leases/${lease.id}`);
expect(released.status()).toBe(204);
}
}, { scope: 'worker' }],
});
The test-scoped request/page fixtures cannot be dependencies of a longer-lived worker fixture. Create a separate API context as above. A worker fixture runs once per worker lifetime, not once globally. Failed workers may restart; therefore provisioning must tolerate restarts and orphan leases. A lease service needs expiry and ownership checks. The example reports teardown failure, which can accompany a test failure; robust reporting should retain both diagnostics.
Data and configuration#
// playwright.config.ts: explicit diagnostic budgets, illustrative local app.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests', testMatch: /.*\.spec\.ts/,
timeout: 45_000, expect: { timeout: 8_000 },
retries: process.env.CI ? 1 : 0, workers: process.env.CI ? 2 : undefined,
reporter: [['html', { open: 'never' }], ['junit', { outputFile: 'reports/junit.xml' }]],
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
actionTimeout: 10_000, navigationTimeout: 20_000,
trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'off',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
Factories should generate a unique correlation UUID plus meaningful defaults; each test overrides only its relevant fields. Use test-scoped data for writes, narrow worker sharing to nonconflicting state, and use API setup when UI setup is not the behavior being tested. Cleanup must respect ownership; never delete another run's records. Persistent cleanup failures require a janitor with TTL and audit logging, not broad database wipes.
Architecture decision table#
| Decision | Choose first option when | Choose alternative when |
|---|---|---|
| POM vs functions | Stateful page interactions recur and need discoverable API | Small stateless tasks do not justify classes |
| Fixture vs hooks | Resource has reusable setup, teardown and dependencies | A local beforeEach navigation is enough |
| API factory vs UI setup | Need fast isolated state, backend supports safe provisioning | Login/create interaction itself needs UI coverage |
| Client polling vs test polling | Completion is universal service contract | Test specifically checks transition/deadline |
| Shared worker account vs test account | Read-only or conflict-free operations | Password/profile/payment mutations can interfere |
Wrong: giant Page Object with SQL, Jira updates and global account state. Right: cohesive page interactions, separate clients/data/reporting, typed fixture composition. Judge architecture by whether a teammate can understand a failed test, change a locator in one place, run a single test and clean its resources independently.
Interview response#
30 seconds: “I keep test intent in tests, UI mechanics in small page objects, and lifecycle in typed fixtures. A fixture sets up dependencies, yields at use, and tears down afterward. Worker resources must be safe to share and cannot depend on test-scoped page/request. I start simple and abstract only recurring stable concepts.”
90-second senior answer: framework whiteboard
I begin with business risks and test layers, then define tests/ui and tests/api, page objects, service clients and test-data factories. Test configuration selects environments and projects; CI secrets supply credentials. Test fixtures own mutable records and cleanup, worker fixtures own reusable isolated resources, and every run carries correlation identity. I use API setup to keep UI tests focused, except when setup itself is the requirement. Reports retain traces and sanitized logs. I avoid exposing low-level selector strings to tests and avoid putting business assertions inside generic helpers. I demonstrate maintenance by showing how a page change, API schema change and failed cleanup would each be handled. Typechecking, smoke gates, parallel-isolation checks and code review provide objective evidence of framework quality.
Memory hook: tests state intent; pages act; clients talk; fixtures own lifetime.
Quick practice · reveal answers#
What belongs in a Page Object?
- Stable locators and semantic page actions.
- Keep business intent/outcome assertions visible in tests unless a cohesive page-specific helper helps.
Pitfall: A giant page class owning DB, Jira and every workflow.
Fixtures versus beforeEach?
- Hooks suit local common setup.
- Fixtures compose typed lazy dependencies with reusable lifecycle and teardown.
Pitfall: Abstracting one trivial navigation into many layers.
What does await use mean?
- Setup before it; dependent test/fixtures execute at the yield boundary; teardown afterward.
- Dependencies set up first and tear down last.
Pitfall: Placing cleanup only on the happy path.
Can a worker fixture use request fixture?
- Not the test-scoped request fixture.
- Create and dispose a separate APIRequestContext for worker lifetime.
Pitfall: A long-lived fixture depending on shorter-lived page/request.
Official references and related topics#
Optional online further reading; all core study material is included above.