Merging Multiple Fixtures in Playwright with mergeTests
As a Playwright suite matures, fixtures multiply. One file teaches the framework about your authenticated user, another spins up seeded test data, a third exposes a typed API client, and a fourth carries Page Objects. The moment a single test needs two of these, you hit a wall: a test file can only call base.extend() from one parent, so you cannot simply import two separate test objects and use both. In this guide you will learn how Playwright mergeTests merge fixtures from independent files into one combined test object, why it beats one giant fixtures file, and how to keep the whole thing type-safe in TypeScript.
🎭 Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.
Contents
The Problem mergeTests Solves
Playwright fixtures are created by chaining test.extend() off a base test. Each call returns a brand-new test object that knows about its own fixtures plus everything it inherited. That chaining works beautifully inside one module, but it does not compose across modules. If your authentication fixtures live in auth.fixtures.ts and your database fixtures live in db.fixtures.ts, each exports its own extended test. A spec cannot import both and expect a single test() call to provide fixtures from both worlds.
The naive fix is to dump every fixture into one massive fixtures.ts and chain them all in a single base.extend() call. That works for ten fixtures and becomes unmanageable at fifty. Teams that own different domains step on each other in one file, merge conflicts spike, and the types of unrelated fixtures bleed together. Playwright’s answer is mergeTests: a helper that takes multiple independently-built test objects and folds them into a single combined one whose fixtures are the union of all inputs.
// This does NOT work — you cannot use two separate test objects together.
import { test as authTest } from './auth.fixtures';
import { test as dbTest } from './db.fixtures';
// A spec must pick ONE. There is no way to get fixtures from both
// authTest and dbTest in the same test() callback without merging.
authTest('checkout', async ({ page /*, seededOrder?? */ }) => {
// `seededOrder` from dbTest is simply not available here.
});
What Playwright mergeTests Does
The Playwright mergeTests merge fixtures helper is exported directly from @playwright/test. You pass it two or more test objects — each one already extended with its own fixtures — and it returns a single test whose fixture set is the combined union. The returned object is a normal Playwright test: you can call it, chain .describe, attach hooks, and even extend it further. A sibling helper, mergeExpects, does the same for custom matchers built with expect.extend.
Crucially, mergeTests preserves the full TypeScript types of every input. The fixtures from each parent remain individually typed in the destructured argument, so autocomplete and compile-time checking keep working. This is the feature that makes a modular fixtures architecture practical rather than a maintenance liability.
import { mergeTests, mergeExpects } from '@playwright/test';
import { test as authTest } from './auth.fixtures';
import { test as dbTest } from './db.fixtures';
// One combined test object with fixtures from BOTH files.
export const test = mergeTests(authTest, dbTest);
export { expect } from '@playwright/test';
// Now a spec can use fixtures from either parent simultaneously.
test('authenticated user sees their seeded order', async ({ user, seededOrder, page }) => {
await page.goto(`/orders/${seededOrder.id}`);
await expect(page.getByRole('heading')).toContainText(user.name);
});
Building Two Independent Fixture Files
To see the value, build two genuinely separate fixture modules, each owned by a different concern. The first provides an authenticated user fixture plus an apiContext backed by Playwright’s request API. Notice that this file imports straight from @playwright/test and knows nothing about the database file.
// auth.fixtures.ts
import { test as base, type APIRequestContext, request } from '@playwright/test';
type AuthUser = { id: string; name: string; token: string };
export const test = base.extend<{
user: AuthUser;
apiContext: APIRequestContext;
}>({
user: async ({}, use) => {
// In real life you would hit a login endpoint here.
const user: AuthUser = { id: 'u_42', name: 'Ada Lovelace', token: 'tkn_abc' };
await use(user);
},
apiContext: async ({ user }, use) => {
const ctx = await request.newContext({
baseURL: 'https://api.example.com',
extraHTTPHeaders: { Authorization: `Bearer ${user.token}` },
});
await use(ctx);
await ctx.dispose();
},
});
The second module owns test data. Its seededOrder fixture creates a record before the test and tears it down afterwards, the classic setup/teardown pattern around the use() call. It is completely independent of authentication and could be developed, reviewed, and versioned by a different team.
// db.fixtures.ts
import { test as base } from '@playwright/test';
type Order = { id: string; total: number };
export const test = base.extend<{ seededOrder: Order }>({
seededOrder: async ({}, use) => {
// Setup: insert a row (pseudo-code for your DB layer).
const order: Order = { id: `o_${Date.now()}`, total: 129.99 };
await db.orders.insert(order);
await use(order); // test runs here
// Teardown: always clean up, even if the test failed.
await db.orders.delete(order.id);
},
});
Each file is small, focused, and exports its own test. Neither knows the other exists. That isolation is exactly what makes them safe to merge later without coupling.
Merging the Fixtures Into One test Object
Create a single entry point — conventionally test-base.ts or fixtures.ts — that merges the modules and re-exports the result. Your specs import test and expect from this file instead of from @playwright/test directly. Adding a new fixture domain later means writing one more module and adding one argument to mergeTests.
// test-base.ts — the single import surface for all specs
import { mergeTests } from '@playwright/test';
import { test as authTest } from './auth.fixtures';
import { test as dbTest } from './db.fixtures';
import { test as posTest } from './pageobjects.fixtures';
// Order does not matter for distinct fixture names; it does for overrides.
export const test = mergeTests(authTest, dbTest, posTest);
export { expect } from '@playwright/test';
// any-feature.spec.ts
import { test, expect } from './test-base';
test('full stack flow', async ({ user, seededOrder, checkoutPage }) => {
await checkoutPage.open(seededOrder.id);
await expect(checkoutPage.greeting).toHaveText(`Hi, ${user.name}`);
});
Because mergeTests returns a regular test, you can keep extending it. If a small group of specs needs an extra fixture that does not deserve its own file, call .extend() on the merged object right in the spec or in a feature-local base. The composition stays linear and predictable.
mergeExpects for Custom Matchers
Fixtures are only half the story. If different modules define their own custom matchers with expect.extend, you face the same composition problem on the assertion side. The mergeExpects helper combines multiple extended expect objects into one, mirroring mergeTests. Build each expect in its own module, then merge them alongside your tests.
// money.expect.ts
import { expect as baseExpect } from '@playwright/test';
export const expect = baseExpect.extend({
toBeWithinCents(received: number, target: number, cents = 1) {
const pass = Math.abs(received - target) * 100 <= cents;
return {
pass,
name: 'toBeWithinCents',
message: () => `expected ${received} ${pass ? 'not ' : ''}within ${cents}c of ${target}`,
};
},
});
// test-base.ts
import { mergeTests, mergeExpects } from '@playwright/test';
import { test as authTest } from './auth.fixtures';
import { test as dbTest } from './db.fixtures';
import { expect as moneyExpect } from './money.expect';
export const test = mergeTests(authTest, dbTest);
export const expect = mergeExpects(moneyExpect); // add more expects as args
Now every spec importing from test-base.ts gets the merged fixtures and the merged matchers from a single, consistent surface. There is no risk of one file importing the plain expect and silently missing a custom matcher.
mergeTests vs a Single Fixtures File
Both approaches end with one test object that specs import. The difference is how you get there and how it scales across a team. The table below contrasts the two strategies on the dimensions that matter as a suite grows.
| Dimension | Single mega fixtures file | mergeTests across modules |
|---|---|---|
| Code ownership | One file, many owners, frequent merge conflicts | One file per domain, clear ownership |
| Type isolation | All fixture types coexist in one scope | Each module typed independently, merged at the end |
| Reuse across projects | Hard to cherry-pick a subset | Import only the modules a project needs |
| Adding a domain | Edit the shared file | Add a module, add one mergeTests argument |
| Override risk | High — names collide silently | Explicit — last argument wins, easy to reason about |
🚀 Level Up Your Playwright
From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.
Override Order and Name Collisions
The one rule you must internalize is precedence. When two merged test objects define a fixture with the same name, the one passed later to mergeTests wins. This is powerful for intentional overrides — a project-specific module can replace a base fixture — but dangerous if a collision is accidental. Treat shared fixture names as a namespace your team agrees on, and prefix domain-specific fixtures to avoid surprises.
import { test as base, mergeTests } from '@playwright/test';
const slowTimeout = base.extend<{ apiTimeout: number }>({
apiTimeout: async ({}, use) => { await use(30_000); },
});
const fastTimeout = base.extend<{ apiTimeout: number }>({
apiTimeout: async ({}, use) => { await use(5_000); },
});
// fastTimeout is LAST, so apiTimeout resolves to 5000 everywhere.
export const test = mergeTests(slowTimeout, fastTimeout);
test('uses the overriding value', async ({ apiTimeout }) => {
console.log(apiTimeout); // 5000, not 30000
});
Two further notes. Fixtures with the same name should share the same type; merging conflicting types produces confusing compiler errors. And mergeTests does not deep-merge the bodies of same-named fixtures — it replaces, it does not combine. If you want layered behaviour, override explicitly with .extend() and call through to the previous implementation via the fixture’s value rather than relying on merge semantics.
Best Practices for Modular Fixtures
- One domain per fixture file — auth, data, page objects, API clients. Keep each module independent and importing only from
@playwright/test. - Expose a single merged entry point — a
test-base.tsthat runsmergeTestsand re-exportstestandexpect. Specs never import the individual fixture files directly. - Mind the order — put base/default modules first and override modules last, since later arguments win on name collisions.
- Prefix to avoid collisions — name fixtures like
authUserordbOrderrather than genericuserwhen several domains might clash. - Merge matchers too — use
mergeExpectsso custom assertions travel with the same import surface as your fixtures. - Keep teardown after
use()— each module owns the cleanup for the resources it creates, so merging never leaks state.
In short, Playwright mergeTests merge fixtures is the official, type-safe way to compose independently authored fixture modules into one cohesive test object. By giving each concern its own file and stitching them together with mergeTests (and mergeExpects for matchers), you get a fixtures architecture that scales with your team, isolates ownership, and stays fully autocompleted in TypeScript. Start by splitting your largest fixtures file along domain lines, merge the pieces, and point every spec at the single combined entry point.
FAQ
What happens when two merged fixtures have the same name?
The fixture from the test object passed later to mergeTests wins. mergeTests(a, b) resolves a same-named fixture to b‘s implementation. This makes intentional overrides easy — put your override module last — but accidental collisions are silent, so prefix domain-specific fixtures and keep their types identical to avoid surprises.
Can I still call test.extend() after using mergeTests?
Yes. mergeTests returns an ordinary Playwright test object, so you can chain .extend() on it to add feature-local fixtures, attach .describe blocks, hooks, and annotations. A common pattern is a global test-base.ts that merges all domains, then a feature folder that imports it and extends it once more for fixtures only that feature needs.
Is mergeTests only for fixtures, or does it work with custom matchers too?
mergeTests handles fixtures. For custom matchers created with expect.extend, use its companion mergeExpects, which combines multiple extended expect objects into one. Export the merged test and merged expect from the same entry file so every spec gets both your composed fixtures and your composed assertions from a single import.
🎓 Master Playwright End to End
Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.
