lecture 4 of 170 completed

Fixture dependency graphs

Fixtures depend on fixtures, and Playwright resolves the graph for you. Designing that graph badly couples everything to everything.

Here is a failure worth understanding before you build a large fixture layer.

A team added a database-seeding dependency to their authedPage fixture. Reasonable change. Every test in the suite used authedPage, so every test now seeded the database, and the suite went from eight minutes to thirty-five. Nobody had changed a single test.

Fixtures form a dependency graph. When a fixture asks for another fixture in its parameters, that is an edge:

export const test = base.extend<Fixtures>({
  api: async ({ request }, use) => {
    await use(new ApiClient(request));
  },

  contact: async ({ api }, use) => {              // depends on api
    const created = await api.createContact(uniqueContact());
    await use(created);
    await api.deleteContact(created.id);
  },

  contactsPage: async ({ page, contact }, use) => {  // depends on page and contact
    const p = new ContactsPage(page);
    await p.goto();
    await use(p);
  },
});

Playwright builds the graph, creates each fixture once per test in dependency order, and tears them down in reverse. You never wire it manually.

Three properties follow, and each has a design consequence.

Fixtures are created lazily. A fixture is only built if something asks for it. A test requesting only page never creates api or contact. This is what makes a large fixture library affordable: unused fixtures cost nothing.

That property is exactly what the opening story destroyed. By adding a dependency to a fixture everything used, the team made the expensive thing unavoidable.

Teardown runs in reverse order. contactsPage tears down before contact, which tears down before api. Dependencies are still alive when their dependents clean up, so contact can still use api to delete itself.

Each fixture is created once per test, no matter how many other fixtures depend on it. If three fixtures each depend on api, there is one api, shared. This is what makes composition safe.

Designing the graph well.

Keep it shallow. Two or three levels is comfortable. Six means a test asking for one fixture silently builds a tree, and diagnosing a slow test requires reading the whole chain.

Do not add dependencies to widely used fixtures. The story above in one sentence. Before adding a dependency, ask how many tests use the fixture you are editing. If the answer is "all of them", the new work must be optional.

Split rather than deepen. When authedPage needs seeded data for some tests, do not add the dependency. Make a second fixture:

authedPage: async ({ browser }, use) => { /* login only */ },
seededPage: async ({ authedPage, seedData }, use) => { await use(authedPage); },

Tests that need seeding ask for seededPage. Everything else keeps the fast path. The cost is explicit and opt-in, which is the whole point.

Circular dependencies fail at run time, with a message about a fixture depending on itself. It is usually a sign that two fixtures are really one thing, or that something belongs in a plain function outside the graph.

Reading the graph when something is slow. --trace on shows fixture setup as timed steps in the trace viewer. When a test takes twelve seconds and the actions take two, the other ten are in fixtures, and the trace tells you which one. That is the fastest way to find a dependency someone added upstream.

Remember this: lazy creation is the feature that makes fixture libraries affordable. Protect it by splitting rather than deepening.

Reference: Fixtures.