lecture 15 of 170 completed

Shipping your framework as a package

When three teams need the same helpers, the code becomes a product with users. Versioning and migration become your problem.

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.