Skip to content
🤖 Consolidated, AI-optimized BMAD docs: llms-full.txt. Fetch this plain text file for complete context.

Fixture Architecture Explained

Fixture architecture is TEA’s pattern for building reusable, testable, and composable test utilities. The core principle: build pure functions first, wrap in framework fixtures second.

The Pattern:

  1. Write utility as pure function (unit-testable)
  2. Wrap in framework fixture (Playwright, Cypress)
  3. Compose fixtures with mergeTests (combine capabilities)
  4. Package for reuse across projects

Why this order?

  • Pure functions are easier to test
  • Fixtures depend on framework (less portable)
  • Composition happens at fixture level
  • Reusability maximized
%%{init: {'theme':'base', 'themeVariables': { 'fontSize':'14px'}}}%%
flowchart TD
Start([Testing Need]) --> Pure[Step 1: Pure Function<br/>helpers/api-request.ts]
Pure -->|Unit testable<br/>Framework agnostic| Fixture[Step 2: Fixture Wrapper<br/>fixtures/api-request.ts]
Fixture -->|Injects framework<br/>dependencies| Compose[Step 3: Composition<br/>fixtures/index.ts]
Compose -->|mergeTests| Use[Step 4: Use in Tests<br/>tests/**.spec.ts]
Pure -.->|Can test in isolation| UnitTest[Unit Tests<br/>No framework needed]
Fixture -.->|Reusable pattern| Other[Other Projects<br/>Package export]
Compose -.->|Combine utilities| Multi[Multiple Fixtures<br/>One test]
style Pure fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
style Fixture fill:#fff3e0,stroke:#e65100,stroke-width:2px
style Compose fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px
style Use fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
style UnitTest fill:#c8e6c9,stroke:#2e7d32,stroke-width:1px
style Other fill:#c8e6c9,stroke:#2e7d32,stroke-width:1px
style Multi fill:#c8e6c9,stroke:#2e7d32,stroke-width:1px

Benefits at Each Step:

  1. Pure Function: Testable, portable, reusable
  2. Fixture: Framework integration, clean API
  3. Composition: Combine capabilities, flexible
  4. Usage: Simple imports, type-safe

Framework-First Approach (Common Anti-Pattern)

Section titled “Framework-First Approach (Common Anti-Pattern)”
// ❌ Bad: Built as fixture from the start
export const test = base.extend({
apiRequest: async ({ request }, use) => {
await use(async (options) => {
const response = await request.fetch(options.url, {
method: options.method,
data: options.data
});
if (!response.ok()) {
throw new Error(`API request failed: ${response.status()}`);
}
return response.json();
});
}
});

Problems:

  • Cannot unit test (requires Playwright context)
  • Tied to framework (not reusable in other tools)
  • Hard to compose with other fixtures
  • Difficult to mock for testing the utility itself
test-1.spec.ts
test('test 1', async ({ request }) => {
const response = await request.post('/api/users', { data: {...} });
const body = await response.json();
if (!response.ok()) throw new Error('Failed');
// ... repeated in every test
});
// test-2.spec.ts
test('test 2', async ({ request }) => {
const response = await request.post('/api/users', { data: {...} });
const body = await response.json();
if (!response.ok()) throw new Error('Failed');
// ... same code repeated
});

Problems:

  • Code duplication (violates DRY)
  • Inconsistent error handling
  • Hard to update (change 50 tests)
  • No shared behavior
helpers/api-request.ts
/**
* Make API request with automatic error handling
* Pure function - no framework dependencies
*/
export async function apiRequest({
request, // Passed in (dependency injection)
method,
url,
data,
headers = {}
}: ApiRequestParams): Promise<ApiResponse> {
const response = await request.fetch(url, {
method,
data,
headers
});
if (!response.ok()) {
throw new Error(`API request failed: ${response.status()}`);
}
return {
status: response.status(),
body: await response.json()
};
}
// ✅ Can unit test this function!
describe('apiRequest', () => {
it('should throw on non-OK response', async () => {
const mockRequest = {
fetch: vi.fn().mockResolvedValue({ ok: () => false, status: () => 500 })
};
await expect(apiRequest({
request: mockRequest,
method: 'GET',
url: '/api/test'
})).rejects.toThrow('API request failed: 500');
});
});

Benefits:

  • Unit testable (mock dependencies)
  • Framework-agnostic (works with any HTTP client)
  • Easy to reason about (pure function)
  • Portable (can use in Node scripts, CLI tools)
fixtures/api-request.ts
import { test as base } from '@playwright/test';
import { apiRequest as apiRequestFn } from '../helpers/api-request';
/**
* Playwright fixture wrapping the pure function
*/
export const test = base.extend<{ apiRequest: typeof apiRequestFn }>({
apiRequest: async ({ request }, use) => {
// Inject framework dependency (request)
await use((params) => apiRequestFn({ request, ...params }));
}
});
export { expect } from '@playwright/test';

Benefits:

  • Fixture provides framework context (request)
  • Pure function handles logic
  • Clean separation of concerns
  • Can swap frameworks (Cypress, etc.) by changing wrapper only
fixtures/index.ts
import { mergeTests } from '@playwright/test';
import { test as apiRequestTest } from './api-request';
import { test as authSessionTest } from './auth-session';
import { test as logTest } from './log';
/**
* Compose all fixtures into one test
*/
export const test = mergeTests(
apiRequestTest,
authSessionTest,
logTest
);
export { expect } from '@playwright/test';

Usage:

tests/profile.spec.ts
import { test, expect } from '../support/fixtures';
test('should update profile', async ({ apiRequest, authToken, log }) => {
log.info('Starting profile update test');
// Use API request fixture (matches pure function signature)
const { status, body } = await apiRequest({
method: 'PATCH',
url: '/api/profile',
data: { name: 'New Name' },
headers: { Authorization: `Bearer ${authToken}` }
});
expect(status).toBe(200);
expect(body.name).toBe('New Name');
log.info('Profile updated successfully');
});

Note: This example uses the vanilla pure function signature (url, data). Playwright Utils uses different parameter names (path, body). See Integrate Playwright Utils for the utilities API.

Note: authToken requires auth-session fixture setup with provider configuration. See auth-session documentation.

Benefits:

  • Use multiple fixtures in one test
  • No manual composition needed
  • Type-safe (TypeScript knows all fixture types)
  • Clean imports

When you run framework with tea_use_playwright_utils: true:

TEA scaffolds:

tests/
├── support/
│ ├── helpers/ # Pure functions
│ │ ├── api-request.ts
│ │ └── auth-session.ts
│ └── fixtures/ # Framework wrappers
│ ├── api-request.ts
│ ├── auth-session.ts
│ └── index.ts # Composition
└── e2e/
└── example.spec.ts # Uses composed fixtures

When you run test-review:

TEA checks:

  • Are utilities pure functions? ✓
  • Are fixtures minimal wrappers? ✓
  • Is composition used? ✓
  • Can utilities be unit tested? ✓

Option 1: Build Your Own (Vanilla)

package.json
{
"name": "@company/test-utils",
"exports": {
"./api-request": "./fixtures/api-request.ts",
"./auth-session": "./fixtures/auth-session.ts",
"./log": "./fixtures/log.ts"
}
}

Usage:

import { test as apiTest } from '@company/test-utils/api-request';
import { test as authTest } from '@company/test-utils/auth-session';
import { mergeTests } from '@playwright/test';
export const test = mergeTests(apiTest, authTest);

Option 2: Use Playwright Utils (Recommended)

Terminal window
npm install -D @seontechnologies/playwright-utils

Usage:

import { test as base } from '@playwright/test';
import { mergeTests } from '@playwright/test';
import { test as apiRequestFixture } from '@seontechnologies/playwright-utils/api-request/fixtures';
import { createAuthFixtures } from '@seontechnologies/playwright-utils/auth-session';
const authFixtureTest = base.extend(createAuthFixtures());
export const test = mergeTests(apiRequestFixture, authFixtureTest);
// Production-ready utilities, battle-tested!

Note: Auth-session requires provider configuration. See auth-session setup guide.

Why Playwright Utils:

  • Already built, tested, and maintained
  • Consistent patterns across projects
  • 11 utilities available (API, auth, network, logging, files)
  • Community support and documentation
  • Regular updates and improvements

When to Build Your Own:

  • Company-specific patterns
  • Custom authentication systems
  • Unique requirements not covered by utilities
// ❌ Bad: Everything in one fixture
export const test = base.extend({
testUtils: async ({ page, request, context }, use) => {
await use({
// 50 different methods crammed into one fixture
apiRequest: async (...) => { },
login: async (...) => { },
createUser: async (...) => { },
deleteUser: async (...) => { },
uploadFile: async (...) => { },
// ... 45 more methods
});
}
});

Problems:

  • Cannot test individual utilities
  • Cannot compose (all-or-nothing)
  • Cannot reuse specific utilities
  • Hard to maintain (1000+ line file)
api-request.ts
// ✅ Good: One concern per fixture
export const test = base.extend({ apiRequest });
// auth-session.ts
export const test = base.extend({ authSession });
// log.ts
export const test = base.extend({ log });
// Compose as needed
import { mergeTests } from '@playwright/test';
export const test = mergeTests(apiRequestTest, authSessionTest, logTest);

Benefits:

  • Each fixture is unit-testable
  • Compose only what you need
  • Reuse individual fixtures
  • Easy to maintain (small files)

For detailed fixture architecture patterns, see the knowledge base:

Reusable utilities:

  • API request helpers
  • Authentication handlers
  • File operations
  • Network mocking

Test infrastructure:

  • Shared fixtures across teams
  • Packaged utilities (playwright-utils)
  • Company-wide test standards

One-off test setup:

// Simple one-time setup - inline is fine
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.click('#accept-cookies');
});

Test-specific helpers:

// Used in one test file only - keep local
function createTestUser(name: string) {
return { name, email: `${name}@test.com` };
}

Core TEA Concepts:

Technical Patterns:

Overview:

Setup Guides:

Workflow Guides:


Generated with BMad Method - TEA (Test Architect)