402 lines
12 KiB
Markdown
402 lines
12 KiB
Markdown
# Fixture Architecture Playbook
|
|
|
|
## Principle
|
|
|
|
Build test helpers as pure functions first, then wrap them in framework-specific fixtures. Compose capabilities using `mergeTests` (Playwright) or layered commands (Cypress) instead of inheritance. Each fixture should solve one isolated concern (auth, API, logs, network).
|
|
|
|
## Rationale
|
|
|
|
Traditional Page Object Models create tight coupling through inheritance chains (`BasePage → LoginPage → AdminPage`). When base classes change, all descendants break. Pure functions with fixture wrappers provide:
|
|
|
|
- **Testability**: Pure functions run in unit tests without framework overhead
|
|
- **Composability**: Mix capabilities freely via `mergeTests`, no inheritance constraints
|
|
- **Reusability**: Export fixtures via package subpaths for cross-project sharing
|
|
- **Maintainability**: One concern per fixture = clear responsibility boundaries
|
|
|
|
## Pattern Examples
|
|
|
|
### Example 1: Pure Function → Fixture Pattern
|
|
|
|
**Context**: When building any test helper, always start with a pure function that accepts all dependencies explicitly. Then wrap it in a Playwright fixture or Cypress command.
|
|
|
|
**Implementation**:
|
|
|
|
```typescript
|
|
// playwright/support/helpers/api-request.ts
|
|
// Step 1: Pure function (ALWAYS FIRST!)
|
|
type ApiRequestParams = {
|
|
request: APIRequestContext;
|
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
url: string;
|
|
data?: unknown;
|
|
headers?: Record<string, string>;
|
|
};
|
|
|
|
export async function apiRequest({
|
|
request,
|
|
method,
|
|
url,
|
|
data,
|
|
headers = {}
|
|
}: ApiRequestParams) {
|
|
const response = await request.fetch(url, {
|
|
method,
|
|
data,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...headers
|
|
}
|
|
});
|
|
|
|
if (!response.ok()) {
|
|
throw new Error(`API request failed: ${response.status()} ${await response.text()}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
// Step 2: Fixture wrapper
|
|
// playwright/support/fixtures/api-request-fixture.ts
|
|
import { test as base } from '@playwright/test';
|
|
import { apiRequest } from '../helpers/api-request';
|
|
|
|
export const test = base.extend<{ apiRequest: typeof apiRequest }>({
|
|
apiRequest: async ({ request }, use) => {
|
|
// Inject framework dependency, expose pure function
|
|
await use((params) => apiRequest({ request, ...params }));
|
|
}
|
|
});
|
|
|
|
// Step 3: Package exports for reusability
|
|
// package.json
|
|
{
|
|
"exports": {
|
|
"./api-request": "./playwright/support/helpers/api-request.ts",
|
|
"./api-request/fixtures": "./playwright/support/fixtures/api-request-fixture.ts"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Key Points**:
|
|
|
|
- Pure function is unit-testable without Playwright running
|
|
- Framework dependency (`request`) injected at fixture boundary
|
|
- Fixture exposes the pure function to test context
|
|
- Package subpath exports enable `import { apiRequest } from 'my-fixtures/api-request'`
|
|
|
|
### Example 2: Composable Fixture System with mergeTests
|
|
|
|
**Context**: When building comprehensive test capabilities, compose multiple focused fixtures instead of creating monolithic helper classes. Each fixture provides one capability.
|
|
|
|
**Implementation**:
|
|
|
|
```typescript
|
|
// playwright/support/fixtures/merged-fixtures.ts
|
|
import { test as base, mergeTests } from '@playwright/test';
|
|
import { test as apiRequestFixture } from './api-request-fixture';
|
|
import { test as networkFixture } from './network-fixture';
|
|
import { test as authFixture } from './auth-fixture';
|
|
import { test as logFixture } from './log-fixture';
|
|
|
|
// Compose all fixtures for comprehensive capabilities
|
|
export const test = mergeTests(base, apiRequestFixture, networkFixture, authFixture, logFixture);
|
|
|
|
export { expect } from '@playwright/test';
|
|
|
|
// Example usage in tests:
|
|
// import { test, expect } from './support/fixtures/merged-fixtures';
|
|
//
|
|
// test('user can create order', async ({ page, apiRequest, auth, network }) => {
|
|
// await auth.loginAs('customer@example.com');
|
|
// await network.interceptRoute('POST', '**/api/orders', { id: 123 });
|
|
// await page.goto('/checkout');
|
|
// await page.click('[data-testid="submit-order"]');
|
|
// await expect(page.getByText('Order #123')).toBeVisible();
|
|
// });
|
|
```
|
|
|
|
**Individual Fixture Examples**:
|
|
|
|
```typescript
|
|
// network-fixture.ts
|
|
export const test = base.extend({
|
|
network: async ({ page }, use) => {
|
|
const interceptedRoutes = new Map();
|
|
|
|
const interceptRoute = async (method: string, url: string, response: unknown) => {
|
|
await page.route(url, (route) => {
|
|
if (route.request().method() === method) {
|
|
route.fulfill({ body: JSON.stringify(response) });
|
|
}
|
|
});
|
|
interceptedRoutes.set(`${method}:${url}`, response);
|
|
};
|
|
|
|
await use({ interceptRoute });
|
|
|
|
// Cleanup
|
|
interceptedRoutes.clear();
|
|
},
|
|
});
|
|
|
|
// auth-fixture.ts
|
|
export const test = base.extend({
|
|
auth: async ({ page, context }, use) => {
|
|
const loginAs = async (email: string) => {
|
|
// Use API to setup auth (fast!)
|
|
const token = await getAuthToken(email);
|
|
await context.addCookies([
|
|
{
|
|
name: 'auth_token',
|
|
value: token,
|
|
domain: 'localhost',
|
|
path: '/',
|
|
},
|
|
]);
|
|
};
|
|
|
|
await use({ loginAs });
|
|
},
|
|
});
|
|
```
|
|
|
|
**Key Points**:
|
|
|
|
- `mergeTests` combines fixtures without inheritance
|
|
- Each fixture has single responsibility (network, auth, logs)
|
|
- Tests import merged fixture and access all capabilities
|
|
- No coupling between fixtures—add/remove freely
|
|
|
|
### Example 3: Framework-Agnostic HTTP Helper
|
|
|
|
**Context**: When building HTTP helpers, keep them framework-agnostic. Accept all params explicitly so they work in unit tests, Playwright, Cypress, or any context.
|
|
|
|
**Implementation**:
|
|
|
|
```typescript
|
|
// shared/helpers/http-helper.ts
|
|
// Pure, framework-agnostic function
|
|
type HttpHelperParams = {
|
|
baseUrl: string;
|
|
endpoint: string;
|
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
body?: unknown;
|
|
headers?: Record<string, string>;
|
|
token?: string;
|
|
};
|
|
|
|
export async function makeHttpRequest({ baseUrl, endpoint, method, body, headers = {}, token }: HttpHelperParams): Promise<unknown> {
|
|
const url = `${baseUrl}${endpoint}`;
|
|
const requestHeaders = {
|
|
'Content-Type': 'application/json',
|
|
...(token && { Authorization: `Bearer ${token}` }),
|
|
...headers,
|
|
};
|
|
|
|
const response = await fetch(url, {
|
|
method,
|
|
headers: requestHeaders,
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`HTTP ${method} ${url} failed: ${response.status} ${errorText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
// Playwright fixture wrapper
|
|
// playwright/support/fixtures/http-fixture.ts
|
|
import { test as base } from '@playwright/test';
|
|
import { makeHttpRequest } from '../../shared/helpers/http-helper';
|
|
|
|
export const test = base.extend({
|
|
httpHelper: async ({}, use) => {
|
|
const baseUrl = process.env.API_BASE_URL || 'http://localhost:3000';
|
|
|
|
await use((params) => makeHttpRequest({ baseUrl, ...params }));
|
|
},
|
|
});
|
|
|
|
// Cypress command wrapper
|
|
// cypress/support/commands.ts
|
|
import { makeHttpRequest } from '../../shared/helpers/http-helper';
|
|
|
|
Cypress.Commands.add('apiRequest', (params) => {
|
|
const baseUrl = Cypress.env('API_BASE_URL') || 'http://localhost:3000';
|
|
return cy.wrap(makeHttpRequest({ baseUrl, ...params }));
|
|
});
|
|
```
|
|
|
|
**Key Points**:
|
|
|
|
- Pure function uses only standard `fetch`, no framework dependencies
|
|
- Unit tests call `makeHttpRequest` directly with all params
|
|
- Playwright and Cypress wrappers inject framework-specific config
|
|
- Same logic runs everywhere—zero duplication
|
|
|
|
### Example 4: Fixture Cleanup Pattern
|
|
|
|
**Context**: When fixtures create resources (data, files, connections), ensure automatic cleanup in fixture teardown. Tests must not leak state.
|
|
|
|
**Implementation**:
|
|
|
|
```typescript
|
|
// playwright/support/fixtures/database-fixture.ts
|
|
import { test as base } from '@playwright/test';
|
|
import { seedDatabase, deleteRecord } from '../helpers/db-helpers';
|
|
|
|
type DatabaseFixture = {
|
|
seedUser: (userData: Partial<User>) => Promise<User>;
|
|
seedOrder: (orderData: Partial<Order>) => Promise<Order>;
|
|
};
|
|
|
|
export const test = base.extend<DatabaseFixture>({
|
|
seedUser: async ({}, use) => {
|
|
const createdUsers: string[] = [];
|
|
|
|
const seedUser = async (userData: Partial<User>) => {
|
|
const user = await seedDatabase('users', userData);
|
|
createdUsers.push(user.id);
|
|
return user;
|
|
};
|
|
|
|
await use(seedUser);
|
|
|
|
// Auto-cleanup: Delete all users created during test
|
|
for (const userId of createdUsers) {
|
|
await deleteRecord('users', userId);
|
|
}
|
|
createdUsers.length = 0;
|
|
},
|
|
|
|
seedOrder: async ({}, use) => {
|
|
const createdOrders: string[] = [];
|
|
|
|
const seedOrder = async (orderData: Partial<Order>) => {
|
|
const order = await seedDatabase('orders', orderData);
|
|
createdOrders.push(order.id);
|
|
return order;
|
|
};
|
|
|
|
await use(seedOrder);
|
|
|
|
// Auto-cleanup: Delete all orders
|
|
for (const orderId of createdOrders) {
|
|
await deleteRecord('orders', orderId);
|
|
}
|
|
createdOrders.length = 0;
|
|
},
|
|
});
|
|
|
|
// Example usage:
|
|
// test('user can place order', async ({ seedUser, seedOrder, page }) => {
|
|
// const user = await seedUser({ email: 'test@example.com' });
|
|
// const order = await seedOrder({ userId: user.id, total: 100 });
|
|
//
|
|
// await page.goto(`/orders/${order.id}`);
|
|
// await expect(page.getByText('Order Total: $100')).toBeVisible();
|
|
//
|
|
// // No manual cleanup needed—fixture handles it automatically
|
|
// });
|
|
```
|
|
|
|
**Key Points**:
|
|
|
|
- Track all created resources in array during test execution
|
|
- Teardown (after `use()`) deletes all tracked resources
|
|
- Tests don't manually clean up—happens automatically
|
|
- Prevents test pollution and flakiness from shared state
|
|
|
|
### Anti-Pattern: Inheritance-Based Page Objects
|
|
|
|
**Problem**:
|
|
|
|
```typescript
|
|
// ❌ BAD: Page Object Model with inheritance
|
|
class BasePage {
|
|
constructor(public page: Page) {}
|
|
|
|
async navigate(url: string) {
|
|
await this.page.goto(url);
|
|
}
|
|
|
|
async clickButton(selector: string) {
|
|
await this.page.click(selector);
|
|
}
|
|
}
|
|
|
|
class LoginPage extends BasePage {
|
|
async login(email: string, password: string) {
|
|
await this.navigate('/login');
|
|
await this.page.fill('#email', email);
|
|
await this.page.fill('#password', password);
|
|
await this.clickButton('#submit');
|
|
}
|
|
}
|
|
|
|
class AdminPage extends LoginPage {
|
|
async accessAdminPanel() {
|
|
await this.login('admin@example.com', 'admin123');
|
|
await this.navigate('/admin');
|
|
}
|
|
}
|
|
```
|
|
|
|
**Why It Fails**:
|
|
|
|
- Changes to `BasePage` break all descendants (`LoginPage`, `AdminPage`)
|
|
- `AdminPage` inherits unnecessary `login` details—tight coupling
|
|
- Cannot compose capabilities (e.g., admin + reporting features require multiple inheritance)
|
|
- Hard to test `BasePage` methods in isolation
|
|
- Hidden state in class instances leads to unpredictable behavior
|
|
|
|
**Better Approach**: Use pure functions + fixtures
|
|
|
|
```typescript
|
|
// ✅ GOOD: Pure functions with fixture composition
|
|
// helpers/navigation.ts
|
|
export async function navigate(page: Page, url: string) {
|
|
await page.goto(url);
|
|
}
|
|
|
|
// helpers/auth.ts
|
|
export async function login(page: Page, email: string, password: string) {
|
|
await page.fill('[data-testid="email"]', email);
|
|
await page.fill('[data-testid="password"]', password);
|
|
await page.click('[data-testid="submit"]');
|
|
}
|
|
|
|
// fixtures/admin-fixture.ts
|
|
export const test = base.extend({
|
|
adminPage: async ({ page }, use) => {
|
|
await login(page, 'admin@example.com', 'admin123');
|
|
await navigate(page, '/admin');
|
|
await use(page);
|
|
},
|
|
});
|
|
|
|
// Tests import exactly what they need—no inheritance
|
|
```
|
|
|
|
## Integration Points
|
|
|
|
- **Used in workflows**: `*atdd` (test generation), `*automate` (test expansion), `*framework` (initial setup)
|
|
- **Related fragments**:
|
|
- `data-factories.md` - Factory functions for test data
|
|
- `network-first.md` - Network interception patterns
|
|
- `test-quality.md` - Deterministic test design principles
|
|
|
|
## Helper Function Reuse Guidelines
|
|
|
|
When deciding whether to create a fixture, follow these rules:
|
|
|
|
- **3+ uses** → Create fixture with subpath export (shared across tests/projects)
|
|
- **2-3 uses** → Create utility module (shared within project)
|
|
- **1 use** → Keep inline (avoid premature abstraction)
|
|
- **Complex logic** → Factory function pattern (dynamic data generation)
|
|
|
|
_Source: Murat Testing Philosophy (lines 74-122), SEON production patterns, Playwright fixture docs._
|