Three teams need the same fixtures, the same API client and the same page objects. Each writes their own. Six months later there are three logins that behave differently and a bug fixed in one of them.
The answer is to publish the shared layer as a package. That converts your test code into a product with users, and the obligations that come with that are what this lecture is about.
What belongs in the package.
Framework-level things: the extended test object, authentication fixtures, API client base classes, data factories, shared page objects for screens every team touches, custom matchers, and reporting helpers.
What does not: anything specific to one team's features. If only one team will ever use it, shipping it centrally makes everyone else's dependency larger for no benefit.
The shape:
packages/
test-framework/
src/
fixtures/
clients/
data/
matchers/
package.json
CHANGELOG.md
Consumed as a normal dependency:
import { test, expect } from '@company/test-framework';
test('deletes a contact', async ({ contactsPage, contact }) => { /* ... */ });
Now the obligations, which are the real content here.
Version it properly. Semantic versioning, and the middle number is not decoration. Renaming a fixture is a breaking change. Adding a required dependency to a widely used fixture is a breaking change in effect, even if the type signature survives, because it changes what every consumer pays.
Write a changelog people can act on. Not "updated fixtures". Say what changed, what breaks, and what to do:
## 3.0.0
### Breaking
- `authedPage` no longer seeds the database. Use `seededPage` if you
relied on that. Migration: replace `authedPage` with `seededPage` in
tests that assume existing contacts.
Deprecate before removing. Keep the old name working for a release, logging a warning. Teams have their own schedules and cannot always upgrade the week you release.
Do not break everyone at once. The single most damaging thing a shared package can do is force eight teams into an unplanned migration. If a change is unavoidable, sequence it: release the new thing, let teams migrate, remove the old thing later.
Custom matchers, since they are the highest-value thing you can ship.
export const expect = baseExpect.extend({
async toHaveRowCount(locator: Locator, expected: number) {
const actual = await locator.getByRole('row').count();
return {
pass: actual === expected,
message: () => `Expected ${expected} rows, found ${actual}`,
};
},
});
await expect(table).toHaveRowCount(5) reads better than the equivalent, and more importantly its failure message is written by you. A good failure message saves more time across a large suite than almost any other change, because it is read every time something breaks.
Types are the documentation. In a typed package, autocomplete tells engineers what exists. This is the main reason people discover fixtures at all, and it is worth more than a wiki page nobody opens.
When not to do this. Below about three teams, the coordination cost exceeds the duplication cost. A shared folder in a monorepo is fine and much lighter. Publishing a package too early is a real failure mode: you get versioning ceremony without the benefit.
Remember this: shared code is a product. Version it, write a changelog people can act on, deprecate before removing, and never force an unplanned migration.
Reference: Test fixtures.