Framework · Page objects, fixtures and isolated payment data

The 60-second architecture#

Tests express business intent; page objects expose cohesive UI actions; clients implement service contracts; fixtures own resource lifetime. Start with a small readable structure, then abstract repeated stable concepts. A framework is useful when a teammate can understand a failure and run one test independently.

text
playwright.config.ts
pages/PaymentPage.ts
fixtures/test.ts
clients/PaymentClient.ts
data/payment-builder.ts
tests/ui/payment.spec.ts
tests/api/refund.spec.ts

Complete matching POM, fixture and test#

typescript
// pages/PaymentPage.ts
import type { Page, Locator } from '@playwright/test';
export class PaymentPage {
  readonly status: Locator;
  constructor(private readonly page: Page) {
    this.status = page.getByTestId('payment-status');
  }
  async open(id: string): Promise<void> {
    await this.page.goto(`/payments/${encodeURIComponent(id)}`);
  }
  async requestRefund(amount: string): Promise<void> {
    await this.page.getByLabel('Refund amount').fill(amount);
    await this.page.getByRole('button', { name: 'Request refund', exact: true }).click();
  }
}
typescript
// fixtures/test.ts. Test-support reset is hypothetical and sandbox-only.
import { test as base, expect } from '@playwright/test';
import { randomUUID } from 'node:crypto';
import { PaymentPage } from '../pages/PaymentPage';
type OwnedPayment = { id: string };
export const test = base.extend<{ payment: OwnedPayment; paymentPage: PaymentPage }>({
  payment: async ({ request }, use) => {
    const created = await request.post('/test-support/payments', {
      data: { correlation: randomUUID(), amountMinor: 1250, currency: 'GEL', state: 'CAPTURED' },
    });
    expect(created.status()).toBe(201);
    const value: unknown = await created.json();
    if (!value || typeof value !== 'object' || !('id' in value) || typeof value.id !== 'string') {
      throw new Error('Invalid fixture payment id');
    }
    try { await use({ id: value.id }); }
    finally {
      const reset = await request.delete(`/test-support/payments/${encodeURIComponent(value.id)}`);
      expect(reset.status()).toBe(204);
    }
  },
  paymentPage: async ({ page, payment }, use) => {
    const paymentPage = new PaymentPage(page);
    await paymentPage.open(payment.id);
    await use(paymentPage);
  },
});
export { expect };
typescript
// tests/ui/payment.spec.ts
import { test, expect } from '../../fixtures/test';
test('owned captured payment is displayed', async ({ paymentPage }) => {
  await expect(paymentPage.status).toHaveText('CAPTURED');
});

The test does not prove real provider capture: the fixture deliberately seeded state. A real capture integration test must exercise the actual transition. Teardown failure is reported; production reporting should preserve it alongside any original test failure. If creation response/parse is lost after commit, recover through correlation/TTL rather than a guessed ID.

Fixture lifecycle and alternatives#

Choice Use when Risk
beforeEach hook Simple local common navigation Setup/cleanup ownership can become scattered
Test fixture Mutable owned payment/account per test Provisioning cost and cleanup recovery
Worker fixture Reusable nonconflicting resource per worker lifetime Restarts and jobs need distinct ownership
Small helper function Stateless repeated operation Avoid hiding too much business intent
POM Related page interactions/locators recur Avoid giant objects mixing UI/SQL/CI logic
API factory vs UI setup Fast preconditions vs testing creation/login itself Don't remove all real setup-flow coverage

Fixtures normally set up requested dependencies lazily. Code before await use is setup; the dependent test runs at that boundary; teardown follows. Dependencies set up first and tear down after dependents. test.extend is typed dependency/lifecycle composition, not class inheritance. A longer-lived worker fixture cannot depend on test-scoped page/request.

typescript
// fixtures/worker-api.ts: separate worker-owned context.
import { test as base, request, type APIRequestContext } from '@playwright/test';
export const test = base.extend<{}, { api: APIRequestContext }>({
  api: [async ({}, use) => {
    const baseURL = process.env.BASE_URL;
    if (!baseURL) throw new Error('BASE_URL required');
    const api = await request.newContext({ baseURL });
    try { await use(api); } finally { await api.dispose(); }
  }, { scope: 'worker' }],
});

This context is not a unique merchant lease or authorization mechanism by itself. Workers can restart after failure; worker scope does not mean once globally across CI.

Authentication and backend isolation#

Saved storageState holds cookies/localStorage and optionally IndexedDB where supported; sessionStorage is not automatically persisted. It does not extend a backend token/session's lifetime. Fresh contexts can still mutate the same account, routing configuration or payment.

Use a setup project or supported API login for compatible tests, verify real authenticated success before saving, keep auth state ignored/private, and use independent accounts when session policy or mutations conflict. Never share a password-change/deactivation account with subsequent read tests. Single-active-session policies can make a later shard invalidate an earlier one.

typescript
// Scoped auth-setup test body, page/expect plus mkdirSync from node:fs imported.
const email = process.env.QA_EMAIL;
const password = process.env.QA_PASSWORD;
if (!email || !password) throw new Error('Sandbox QA credentials required');
await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/\/dashboard/);
mkdirSync('playwright/.auth', { recursive: true });
await page.context().storageState({ path: 'playwright/.auth/user.json' });

Configuration and parallel identity#

typescript
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
  testDir: './tests', timeout: 40_000, expect: { timeout: 7_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',
    trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'off' },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});

Separate CI shards are separate runs/jobs; workers execute within a run. Four shards × two workers gives up to eight slots. parallelIndex is a slot within a run, workerIndex identifies a worker process within a run; neither is global across jobs/pipelines. Include pipeline/shard/slot plus random identity or a central lease in allocation. A UUID helps unique data, but the backend must enforce ownership and cleanup.

typescript
import { randomUUID } from 'node:crypto';
export function paymentData() {
  const correlation = randomUUID();
  return { amountMinor: 1250, currency: 'GEL', correlation,
    note: `QA-${process.env.CI_PIPELINE_ID ?? 'local'}-${correlation}` };
}

Interview answer and references#

30 seconds: “I keep business intent in tests, UI mechanics in small page objects and owned data lifecycle in fixtures. Mutable payments usually belong to a test. Shared resources need explicit conflict/session rules and unique identities across jobs. I use API setup for speed while retaining separate coverage of real UI creation/login flows.”

90-second answer: building the framework without overengineering

I start with a test folder, a small page object, an API client and one owned-payment fixture. The test reads like a business scenario and can run alone. I add strict types/runtime parsing, correlation identity, cleanup and useful reports before introducing broad abstractions. Configuration chooses the sandbox and browser; secrets stay external. When parallelizing, I examine account sessions and routing mutation as well as browser contexts. Hooks are fine for local navigation; reusable resource setup/teardown belongs in fixtures. I judge the result by clarity of failures, maintenance effort and independence, not the number of classes. If a worker fails during setup, already acquired resources still need recovery/TTL.

Memory hook: tests explain; pages act; clients call; fixtures own. References: Fixtures, POM, authentication. Next: SQL/events.

Quick practice#

What belongs in a Page Object?
  • Cohesive locators and semantic page actions
  • Keep business intent/outcome visible and service/DB logic in suitable separate boundaries.

Pitfall: A giant page class owns the whole architecture.

Full explanation.

What does await use do in a fixture?
  • It yields the initialized resource to dependents/test and teardown follows
  • Dependencies set up first and tear down after dependents.

Pitfall: Cleanup exists only after passing test assertions.

Full explanation.

Can worker fixtures depend on test request/page?
  • Not a longer-lived worker resource on shorter-lived test fixtures
  • Create a separately owned API context when worker scope is necessary.

Pitfall: Assuming worker setup runs once globally across shards.

Full explanation.

Mid-Level QA Automation Engineer — Payment Orchestration · CV refreshed 2026-10-16. Prepared from reviewed role-specific sources.