skip to content

In Playwright, a fixture seeds a staff account and deletes it after await use() — when does that delete never run?

level: seniorimportance: should knowfreq 36%

answer

  1. Teardown is just the rest of the function
  2. It runs only if reached
  3. A red test is not the problem case
  4. The throw happened above the seam
  5. Creation and use should be adjacent

basics

~20 s

When the fixture throws before it reaches use, because execution never gets to the lines below. Once use has been reached, Playwright resumes the fixture after the test, so a failing or timed-out test still triggers the delete.

solid answer

~50 s

The lines after `await use(value)` are teardown, and they run whenever the fixture actually reached `use` — including when the test fails an assertion or times out. The delete is lost in a different situation: the fixture threw **on the way to** `use`. If seeding the account succeeded but the next setup line (activating it, granting a role, waiting for it to appear) threw, the fixture is reported failed, its dependants never run, and its own cleanup lines are never reached — so the account leaks. The other loss is a worker that dies mid-test, where no in-process teardown runs at all. Practical defences: create the resource as late as possible before `use`, wrap creation plus `use` in `try/finally` when a fragile step sits between them, and make the delete idempotent so a rerun cannot fail on an already-deleted seat.

code

typescript · 21 lines
typescript
import { test as base } from '@playwright/test';

type Fixtures = { staffSeat: { id: string } };

export const test = base.extend<Fixtures>({
  staffSeat: async ({ request }, use) => {
    const response = await request.post('/admin/api/staff', {
      data: { role: 'editor' },
    });
    const seat = await response.json();

    try {
      await request.post(`/admin/api/staff/${seat.id}/activate`);
      await use(seat);
    } finally {
      await request.delete(`/admin/api/staff/${seat.id}`, {
        failOnStatusCode: false,
      });
    }
  },
});

go deeper

for a junior

Remember that the lines after use only run if the fixture actually reached use, so a throw during setup skips the cleanup entirely.

for a middle

Explain the two paths clearly: a failing test still resumes the fixture and runs its teardown, while a fixture that throws in setup never gets there and fails its dependants instead.

for a senior

Diagnose by asking which line threw. Keep creation immediately before use, put anything fragile in the middle inside try and finally, and make deletes idempotent so reruns are safe.

for a principal

Set the suite's policy for leaked data, since no in-process teardown survives a crashed worker: decide what is best-effort in the fixture and what a separate reconciliation job owns.

## Where the delete lives, and when it is reached A fixture is one function with a pause in it. The delete after `await use(seat)` is not a callback registered somewhere the runner will hunt for later; it is just the rest of the function body. It runs if and only if execution gets there. That single sentence answers most leak questions. ## The cases, one by one | What happened | Does the code after use run? | |---|---| | The test passed | Yes | | The test failed an assertion | Yes — `use` resolves once the body ends, whatever the verdict | | The test timed out | Yes — the runner still tears the fixture down | | A *later* fixture in the chain failed its setup | Yes — this one was already set up, so it unwinds | | This fixture threw before calling `use` | **No** — the lines below were never reached | | The worker process crashed | **No** — nothing in that process gets to run | The middle rows are the ones candidates misremember in both directions: they either believe a failing test skips cleanup (it does not), or they believe a failed fixture still runs its own teardown (it does not). ## Why a failing test still cleans up `await use(seat)` resolves after the test body has finished. Playwright does not reject that promise to signal a failed test, which is exactly why the documented fixture pattern has no `try/finally` around it. If `use` threw on failure, every fixture in every suite would leak on every red test. ## Why a failed setup does not Consider a seat fixture for an internal admin console that creates the seat, then activates it: ```typescript const seat = await createSeat(request); // succeeded await activateSeat(request, seat.id); // throws here await use(seat); // never reached await deleteSeat(request, seat.id); // never reached -> leak ``` Everything created above the throw is orphaned. The fixture reports the error, and every test that named the fixture fails without running. ## Three defences, in order of preference 1. **Shrink the gap.** Put the creation immediately before `await use(...)` and move anything fragile either above the creation or into the test itself. Nothing can be orphaned if nothing can throw between creating and yielding. 2. **Wrap it yourself.** When a step genuinely has to sit between creation and `use`, put creation and `use` inside `try` and the delete inside `finally`. That covers both paths with one cleanup. 3. **Reconcile out of band.** Since no in-process teardown survives a crashed worker, a long-lived environment needs something outside the run that removes stale test data — a nightly sweep keyed on a naming convention, or a per-run tag the cleanup job can match. ## Make the teardown boring - **Idempotent.** A delete that tolerates "already gone" survives reruns and double cleanup. - **Short.** Teardown that hangs is itself a leak source; keep it to the one call that matters. - **Non-throwing where it can be.** An exception in teardown fails the test that already passed and can mask the real result; decide deliberately whether a cleanup failure should fail the test. - **Not dependent on the test's state.** Capture the id when you create it; do not read it back from the page during teardown, since a failed test may have left the page anywhere. ## Diagnosing an existing leak Start from the question "which line threw?", not "why did teardown not run?". Take the run's error for the failing fixture, find the first `await` above `use` that can fail against a real environment, and check what was already created above it. In practice the usual culprits are an activation or role-grant step, a wait for the new record to appear in a list, and an assertion accidentally written into the fixture's setup half. ## What this is not This is not a reason to wrap every fixture in `try/catch` and swallow the error — a fixture that hides its own setup failure produces tests that fail later with something unrelated. Catch to clean up, then rethrow.

  • Does a fixture's teardown still run when the test times out?
    Yes. The runner still tears down the fixtures for a timed-out test, so a delete written after `use` is attempted. What actually leaks in that situation is teardown that itself hangs, which is a good reason to keep cleanup to one short, idempotent call.
  • If a fixture's setup throws, do the fixtures it depends on still tear down?
    Yes. Its dependencies were already constructed, so they unwind normally in reverse order. Only the fixture that threw skips its own cleanup, because execution never reached the lines below its `use` call.
  • Should a cleanup failure fail the test?
    Decide deliberately. A throw in teardown marks the test failed even if the body passed, which is loud but can mask the real result and cause flaky reruns. Many suites make deletes tolerant of a missing resource and report leftovers separately instead.

saying these in an interview costs you the question

  • Assumes cleanup after use is skipped whenever a test fails
  • Believes a fixture that threw still runs its own teardown
  • Wraps every fixture in try/catch and swallows the setup error
  • Thinks a crashed worker still flushes fixture teardown
  • Reads the resource id back from the page during teardown