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.
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#
// 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();
}
}
// 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 };
// 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.
// 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.
// 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#
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.
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.
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.
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.