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#

text
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#

typescript
// 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#

typescript
// 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 };
typescript
// 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.

typescript
// 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#

typescript
// 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#

typescript
// 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.

Full explanation →

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.

Full explanation →

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.

Full explanation →

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.

Full explanation →

Official references and related topics#

Optional online further reading; all core study material is included above.

Back to library index

Prepared for both Senior automation roles. Hypothetical sandbox contracts; no employer systems or credentials. Content: 16 October 2026.