Files
rust_browser/_bmad/tea/testarch/knowledge/fixtures-composition.md
Zachary D. Rowitsch 931f17b70e
All checks were successful
ci / fast (linux) (push) Successful in 6m46s
Add BMAD framework, planning artifacts, and architecture decision document
Install BMAD workflow framework with agent commands and templates.
Create product brief, PRD, project context, and architecture decision
document covering networking/persistence strategy, JS engine evolution
path, threading model, web_api scaling, system integration, and
tab/process model. Add generated project documentation (architecture
overview, component inventory, development guide, source tree analysis).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:13:06 -04:00

11 KiB

Fixtures Composition with mergeTests

Principle

Combine multiple Playwright fixtures using mergeTests to create a unified test object with all capabilities. Build composable test infrastructure by merging playwright-utils fixtures with custom project fixtures.

Rationale

Using fixtures from multiple sources requires combining them:

  • Importing from multiple fixture files is verbose
  • Name conflicts between fixtures
  • Duplicate fixture definitions
  • No clear single test object

Playwright's mergeTests provides:

  • Single test object: All fixtures in one import
  • Conflict resolution: Handles name collisions automatically
  • Composition pattern: Mix utilities, custom fixtures, third-party fixtures
  • Type safety: Full TypeScript support for merged fixtures
  • Maintainability: One place to manage all fixtures

Pattern Examples

Example 1: Basic Fixture Merging

Context: Combine multiple playwright-utils fixtures into single test object.

Implementation:

// playwright/support/merged-fixtures.ts
import { mergeTests } from '@playwright/test';
import { test as apiRequestFixture } from '@seontechnologies/playwright-utils/api-request/fixtures';
import { test as authFixture } from '@seontechnologies/playwright-utils/auth-session/fixtures';
import { test as recurseFixture } from '@seontechnologies/playwright-utils/recurse/fixtures';

// Merge all fixtures
export const test = mergeTests(apiRequestFixture, authFixture, recurseFixture);

export { expect } from '@playwright/test';
// In your tests - import from merged fixtures
import { test, expect } from '../support/merged-fixtures';

test('all utilities available', async ({
  apiRequest, // From api-request fixture
  authToken, // From auth fixture
  recurse, // From recurse fixture
}) => {
  // All fixtures available in single test signature
  const { body } = await apiRequest({
    method: 'GET',
    path: '/api/protected',
    headers: { Authorization: `Bearer ${authToken}` },
  });

  await recurse(
    () => apiRequest({ method: 'GET', path: `/status/${body.id}` }),
    (res) => res.body.ready === true,
  );
});

Key Points:

  • Create one merged-fixtures.ts per project
  • Import test object from merged fixtures in all test files
  • All utilities available without multiple imports
  • Type-safe access to all fixtures

Example 2: Combining with Custom Fixtures

Context: Add project-specific fixtures alongside playwright-utils.

Implementation:

// playwright/support/custom-fixtures.ts - Your project fixtures
import { test as base } from '@playwright/test';
import { createUser } from './factories/user-factory';
import { seedDatabase } from './helpers/db-seeder';

export const test = base.extend({
  // Custom fixture 1: Auto-seeded user
  testUser: async ({ request }, use) => {
    const user = await createUser({ role: 'admin' });
    await seedDatabase('users', [user]);
    await use(user);
    // Cleanup happens automatically
  },

  // Custom fixture 2: Database helpers
  db: async ({}, use) => {
    await use({
      seed: seedDatabase,
      clear: () => seedDatabase.truncate(),
    });
  },
});

// playwright/support/merged-fixtures.ts - Combine everything
import { mergeTests } from '@playwright/test';
import { test as apiRequestFixture } from '@seontechnologies/playwright-utils/api-request/fixtures';
import { test as authFixture } from '@seontechnologies/playwright-utils/auth-session/fixtures';
import { test as customFixtures } from './custom-fixtures';

export const test = mergeTests(
  apiRequestFixture,
  authFixture,
  customFixtures, // Your project fixtures
);

export { expect } from '@playwright/test';
// In tests - all fixtures available
import { test, expect } from '../support/merged-fixtures';

test('using mixed fixtures', async ({
  apiRequest, // playwright-utils
  authToken, // playwright-utils
  testUser, // custom
  db, // custom
}) => {
  // Use playwright-utils
  const { body } = await apiRequest({
    method: 'GET',
    path: `/api/users/${testUser.id}`,
    headers: { Authorization: `Bearer ${authToken}` },
  });

  // Use custom fixture
  await db.clear();
});

Key Points:

  • Custom fixtures extend base test
  • Merge custom with playwright-utils fixtures
  • All available in one test signature
  • Maintainable separation of concerns

Example 3: Full Utility Suite Integration

Context: Production setup with all core playwright-utils and custom fixtures.

Implementation:

// playwright/support/merged-fixtures.ts
import { mergeTests } from '@playwright/test';

// Playwright utils fixtures
import { test as apiRequestFixture } from '@seontechnologies/playwright-utils/api-request/fixtures';
import { test as authFixture } from '@seontechnologies/playwright-utils/auth-session/fixtures';
import { test as interceptFixture } from '@seontechnologies/playwright-utils/intercept-network-call/fixtures';
import { test as recurseFixture } from '@seontechnologies/playwright-utils/recurse/fixtures';
import { test as networkRecorderFixture } from '@seontechnologies/playwright-utils/network-recorder/fixtures';

// Custom project fixtures
import { test as customFixtures } from './custom-fixtures';

// Merge everything
export const test = mergeTests(apiRequestFixture, authFixture, interceptFixture, recurseFixture, networkRecorderFixture, customFixtures);

export { expect } from '@playwright/test';
// In tests
import { test, expect } from '../support/merged-fixtures';

test('full integration', async ({
  page,
  context,
  apiRequest,
  authToken,
  interceptNetworkCall,
  recurse,
  networkRecorder,
  testUser, // custom
}) => {
  // All utilities + custom fixtures available
  await networkRecorder.setup(context);

  const usersCall = interceptNetworkCall({ url: '**/api/users' });

  await page.goto('/users');
  const { responseJson } = await usersCall;

  expect(responseJson).toContainEqual(expect.objectContaining({ id: testUser.id }));
});

Key Points:

  • One merged-fixtures.ts for entire project
  • Combine all playwright-utils you use
  • Add custom project fixtures
  • Single import in all test files

Example 4: Fixture Override Pattern

Context: Override default options for specific test files or describes.

Implementation:

import { test, expect } from '../support/merged-fixtures';

// Override auth options for entire file
test.use({
  authOptions: {
    userIdentifier: 'admin',
    environment: 'staging',
  },
});

test('uses admin on staging', async ({ authToken }) => {
  // Token is for admin user on staging environment
});

// Override for specific describe block
test.describe('manager tests', () => {
  test.use({
    authOptions: {
      userIdentifier: 'manager',
    },
  });

  test('manager can access reports', async ({ page }) => {
    // Uses manager token
    await page.goto('/reports');
  });
});

Key Points:

  • test.use() overrides fixture options
  • Can override at file or describe level
  • Options merge with defaults
  • Type-safe overrides

Example 5: Avoiding Fixture Conflicts

Context: Handle name collisions when merging fixtures with same names.

Implementation:

// If two fixtures have same name, last one wins
import { test as fixture1 } from './fixture1'; // has 'user' fixture
import { test as fixture2 } from './fixture2'; // also has 'user' fixture

const test = mergeTests(fixture1, fixture2);
// fixture2's 'user' overrides fixture1's 'user'

// Better: Rename fixtures before merging
import { test as base } from '@playwright/test';
import { test as fixture1 } from './fixture1';

const fixture1Renamed = base.extend({
  user1: fixture1._extend.user, // Rename to avoid conflict
});

const test = mergeTests(fixture1Renamed, fixture2);
// Now both 'user1' and 'user' available

// Best: Design fixtures without conflicts
// - Prefix custom fixtures: 'myAppUser', 'myAppDb'
// - Playwright-utils uses descriptive names: 'apiRequest', 'authToken'

Key Points:

  • Last fixture wins in conflicts
  • Rename fixtures to avoid collisions
  • Design fixtures with unique names
  • Playwright-utils uses descriptive names (no conflicts)
playwright/
├── support/
│   ├── merged-fixtures.ts        # ⭐ Single test object for project
│   ├── custom-fixtures.ts        # Your project-specific fixtures
│   ├── auth/
│   │   ├── auth-fixture.ts       # Auth wrapper (if needed)
│   │   └── custom-auth-provider.ts
│   ├── fixtures/
│   │   ├── user-fixture.ts
│   │   ├── db-fixture.ts
│   │   └── api-fixture.ts
│   └── utils/
│       └── factories/
└── tests/
    ├── api/
    │   └── users.spec.ts          # import { test } from '../../support/merged-fixtures'
    ├── e2e/
    │   └── login.spec.ts          # import { test } from '../../support/merged-fixtures'
    └── component/
        └── button.spec.ts         # import { test } from '../../support/merged-fixtures'

Benefits of Fixture Composition

Compared to direct imports:

// ❌ Without mergeTests (verbose)
import { test as base } from '@playwright/test';
import { apiRequest } from '@seontechnologies/playwright-utils/api-request';
import { getAuthToken } from './auth';
import { createUser } from './factories';

test('verbose', async ({ request }) => {
  const token = await getAuthToken();
  const user = await createUser();
  const response = await apiRequest({ request, method: 'GET', path: '/api/users' });
  // Manual wiring everywhere
});

// ✅ With mergeTests (clean)
import { test } from '../support/merged-fixtures';

test('clean', async ({ apiRequest, authToken, testUser }) => {
  const { body } = await apiRequest({ method: 'GET', path: '/api/users' });
  // All fixtures auto-wired
});

Reduction: ~10 lines per test → ~2 lines

  • overview.md - Installation and design principles
  • api-request.md, auth-session.md, recurse.md - Utilities to merge
  • network-recorder.md, intercept-network-call.md, log.md - Additional utilities

Anti-Patterns

Importing test from multiple fixture files:

import { test } from '@seontechnologies/playwright-utils/api-request/fixtures';
// Also need auth...
import { test as authTest } from '@seontechnologies/playwright-utils/auth-session/fixtures';
// Name conflict! Which test to use?

Use merged fixtures:

import { test } from '../support/merged-fixtures';
// All utilities available, no conflicts

Merging too many fixtures (kitchen sink):

// Merging 20+ fixtures makes test signature huge
const test = mergeTests(...20 different fixtures)

test('my test', async ({ fixture1, fixture2, ..., fixture20 }) => {
  // Cognitive overload
})

Merge only what you actually use:

// Merge the 4-6 fixtures your project actually needs
const test = mergeTests(apiRequestFixture, authFixture, recurseFixture, customFixtures);