PRACTICAL GUIDE / Playwright fixture teardown AggregateError debugging

The test passed, so why did fixture teardown fail?

Trace fixture teardown AggregateErrors to their nested causes, preserve cleanup evidence, and redesign Playwright fixtures so failures stay actionable.

By The Testing AcademyUpdated August 4, 202610 min read
All field guides
In this guide6 sections
  1. Follow the fixture lifecycle, not the test title
  2. Reproduce the aggregate deliberately
  3. Read every nested failure
  4. Make cleanup ownership boring
  5. Separate timeout failures from cleanup failures
  6. When aggregation makes debugging worse

What you will learn

  • Follow the fixture lifecycle, not the test title
  • Reproduce the aggregate deliberately
  • Read every nested failure
  • Make cleanup ownership boring

The checkout assertion passes, then the test turns red while Playwright is tearing its fixtures down. The report shows AggregateError at a helper line, but the first screenful never names the account that failed to delete or the route that stayed installed. Chasing the last page action will waste an hour because the failure happened after the test body returned.

An aggregate is a container, not a diagnosis. The useful evidence is the set of nested errors, the order in which cleanup ran, and the fixture that owned each resource.

Follow the fixture lifecycle, not the test title

A custom Playwright fixture has two distinct halves. Code before await use(value) acquires the resource and exposes it to the test. Code after that call is teardown, and Playwright runs it when the fixture is no longer needed, including when the test body throws.

Scope determines when that happens. A test-scoped fixture is created and torn down for each test. A worker-scoped fixture can survive across several tests and is released when its worker exits. Confusing those scopes produces misleading symptoms: a per-test account stored in a worker fixture leaks between cases, while an expensive shared service in a test fixture churns on every case.

Dependencies determine order. If fixture invoice depends on fixture account, Playwright sets up the account first and the invoice second. Teardown reverses that relationship: the invoice is released before the account. This protects the parent while its dependent still needs it.

The JavaScript AggregateError type holds more than one error in its errors property. Your fixture, an API client, or a cleanup helper may create one after several operations fail. The outer stack often points to the line that constructed the aggregate. That line tells you where failures were collected, not why each operation failed.

Do not assume every AggregateError came from Playwright itself. Search the fixture and helper code for new AggregateError, Promise.any, or a library that groups rejections. The producer determines whether entries retain their original stacks, labels, response details, and causes.

There is another timing detail worth knowing. After await use() returns, testInfo.status and testInfo.errors can help a fixture decide whether the test body already failed and whether diagnostics should be attached. They do not describe an exception that the fixture has not thrown yet. Cleanup still needs to capture its own evidence before it raises that exception.

Reproduce the aggregate deliberately

A cleanup registry makes ownership explicit and lets every registered operation run even if an earlier one fails. The following single file is runnable. Its test body passes, then two intentional teardown failures produce an AggregateError and a JSON attachment.

Save it as tests/teardown.spec.ts:

TypeScript
import { expect, test as base } from '@playwright/test';

type CleanupTask = {
  label: string;
  run: () => Promise<void>;
};

type CleanupRegistry = {
  defer(label: string, run: () => Promise<void>): void;
};

const test = base.extend<{ cleanup: CleanupRegistry }>({
  cleanup: [
    async ({}, use, testInfo) => {
      const tasks: CleanupTask[] = [];

      await use({
        defer(label, run) {
          tasks.push({ label, run });
        },
      });

      const failures: Array<{ label: string; error: unknown }> = [];

      for (const task of [...tasks].reverse()) {
        try {
          await task.run();
        } catch (error) {
          failures.push({ label: task.label, error });
        }
      }

      if (failures.length === 0) return;

      const details = failures.map(({ label, error }) => ({
        label,
        name: error instanceof Error ? error.name : 'Thrown value',
        message: error instanceof Error ? error.message : String(error),
        stack: error instanceof Error ? error.stack : undefined,
      }));

      await testInfo.attach('cleanup-errors.json', {
        body: Buffer.from(JSON.stringify(details, null, 2)),
        contentType: 'application/json',
      });

      const errors = details.map(
        ({ label, message }) => new Error(`${label}: ${message}`),
      );

      if (errors.length === 1) throw errors[0];

      throw new AggregateError(
        errors,
        `${errors.length} fixture cleanup operations failed`,
      );
    },
    { timeout: 30_000 },
  ],
});

test('reports every failed cleanup', async ({ cleanup }) => {
  cleanup.defer('delete account qa-1842', async () => {
    throw new Error('DELETE /accounts/qa-1842 returned 503');
  });

  cleanup.defer('remove checkout route', async () => {
    throw new Error('route handler was already detached');
  });

  expect(2 + 2).toBe(4);
});

The registered tasks run in reverse order because resources are commonly acquired in layers. The loop is sequential, so a dependent resource is fully handled before its parent. It also continues after a failure and throws only after the attachment is written.

That policy has a cost. Sequential cleanup takes longer than parallel cleanup, especially when remote deletions are slow. If operations are genuinely independent, Promise.allSettled can collect their results in parallel. Do not use Promise.all for this job and assume it reports everything; it rejects as soon as one input rejects, while the other operations may still be running.

The fixture timeout is separate from the intent of the test assertion, but it is not free time. A long cleanup timeout can make an already failed suite crawl. Set it from observed service behavior, then emit progress per resource so a timeout still leaves a last known operation.

Read every nested failure

Run the reproduction without retries and with one worker:

Shell
npx playwright test tests/teardown.spec.ts \
  --workers=1 \
  --retries=0 \
  --reporter=line

The outer message should say that two cleanup operations failed. Open cleanup-errors.json from the test output or HTML report. It should name both operations and retain the original messages and stacks.

For an existing failure, classify each nested entry before editing code:

  • An HTTP 401 or 403 points to expired credentials, wrong identity, or teardown running under a different client.
  • A 404 can mean successful prior deletion, but it can also expose a bad ID or a second cleanup owner.
  • A connection reset or 503 belongs to service availability until retry evidence says otherwise.
  • A timeout with no request log often means cleanup never reached the client call or was waiting behind another task.
  • A browser or context closed error suggests a fixture depended on page or context after the owning fixture had already torn it down.
  • Two errors for the same resource usually mean an afterEach hook and a fixture both believe they own deletion.

When the reporter collapses the aggregate, a small recursive formatter is useful in the helper or custom reporter:

TypeScript
function errorMessages(error: unknown): string[] {
  if (error instanceof AggregateError) {
    return [...error.errors].flatMap(errorMessages);
  }

  if (error instanceof Error) {
    return [`${error.name}: ${error.message}`];
  }

  return [String(error)];
}

Keep the original structured errors as well as the flattened summary. A readable console line helps triage, while the stack and response details are needed to repair the cause.

Browser tracing is valuable only for cleanup that interacts with a page or context. It will not reveal why a database client rejected a query or why an external API returned 503. For those resources, attach sanitized request IDs, status codes, resource identifiers, and elapsed time. The absence of page actions in a trace is not proof that teardown never ran.

Make cleanup ownership boring

Register cleanup immediately after acquisition succeeds. If account creation returns an ID and the next setup step fails, the account still has an owner. Registering all cleanup at the end of setup leaves a leak whenever setup exits halfway through.

Use one owner per resource. If the account fixture creates the account, that fixture deletes it. A page object should not quietly delete the same account, and an afterEach hook should not perform a second best-effort sweep. Multiple owners convert a simple failure into order-dependent 404s.

Prefer idempotent server operations where the product boundary supports them. A delete endpoint with an idempotency rule makes teardown safer after a lost response: the client can repeat the request without wondering whether the first one succeeded. The trade-off is that accepted not-found responses can hide wrong identifiers, so log the exact test-owned ID and environment.

Cleanup should preserve the first business failure too. Do not catch the test body's assertion, combine it with teardown errors yourself, and throw a new generic exception. Let Playwright retain the test failure, then report cleanup failures with separate labels. The test may have found the product defect, while teardown found an infrastructure defect. Both need owners.

Retries do not absolve leaks. A failed test is retried in a new worker, but backend records, queues, and files can outlive that process. Use unique resource names that include a safe run and worker identity, and keep a server-side expiry or janitor for disaster recovery. The janitor is a safety net, not the normal owner.

Separate timeout failures from cleanup failures

A test timeout can interrupt the body and leave teardown with less time or a half-finished resource. In the report, the timeout may appear beside one or more cleanup errors. Error order alone does not establish causality.

Build a short timeline from attached timestamps:

  1. Record when acquisition completed and the resource ID returned.
  2. Record when await use() returned or was interrupted.
  3. Mark the start and finish of each cleanup task.
  4. Preserve the external request ID and status for failures.
  5. Compare the fixture timeout with the duration of the last operation.

If cleanup starts after the test timeout, the page or context may already be closing. Move external cleanup to the API client that owns the resource instead of driving the UI backward through logout and delete screens. API cleanup is usually faster and less coupled to browser lifetime, though it covers less of the user journey.

If cleanup itself consistently consumes most of the timeout, batching or parallelizing independent deletions may help. That optimization costs deterministic order and can amplify service load. Measure it under the same worker count as CI before changing the policy.

A setup failure is different again. Code after await use() cannot run when execution never reached that call. Fixtures that were successfully established earlier in the dependency chain can still tear down, but the failing fixture must clean partial acquisition inside its own setup try/catch. This is a common source of records that exist even though the fixture is reported as "not set up."

When aggregation makes debugging worse

Do not wrap a single cleanup error in AggregateError. A direct error keeps the most useful stack at the top and makes standard reporters easier to read.

Avoid parallel aggregation when cleanup order carries meaning. Deleting an account while its invoices still exist may produce two failures where one orderly sequence would have succeeded. Continue after errors only when later operations remain safe.

Never "fix" teardown by swallowing every exception. A green test with leaked routes, users, files, or feature flags contaminates later results. If cleanup is non-critical, state that policy for a named resource and emit a visible annotation or metric instead of a silent catch.

Worker fixtures are also the wrong place for resources that must be fresh per test. One teardown at worker exit cannot prove which test corrupted shared state, and a worker crash may skip the cleanup path entirely. Use test scope unless sharing is an intentional, reviewed performance trade-off.

Finally, do not point destructive fixture cleanup at a shared environment without strong ownership markers. A broad "delete all test users" call can remove another worker's data or a colleague's session. Exact IDs, run-specific prefixes, least-privilege credentials, and server-side expiry cost more engineering than a catch-all delete. They also turn the next AggregateError into a short list of facts instead of a mystery.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 4, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official developer.mozilla.org reference

    developer.mozilla.org

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

Why did my Playwright test fail after its assertions passed?

Fixture teardown is part of the test result. An exception while deleting data, closing a client, removing a route, or releasing another resource makes the test fail even when every body assertion passed.

Where are the real errors inside a JavaScript AggregateError?

Inspect the AggregateError's `errors` property rather than stopping at its outer message and stack. Each nested value may identify a different cleanup operation, so log a label and original stack for every entry.

Does Playwright tear fixtures down in reverse order?

Dependency order makes Playwright tear a dependent fixture down before the fixture it depends on. Within a custom cleanup registry, reverse registration order is sensible when later resources were built on earlier ones.

Can testInfo.errors replace explicit cleanup logging?

`testInfo.errors` can describe errors already associated with the test, but it cannot replace explicit cleanup evidence. Record which owned resource failed, the attempted operation, and the underlying error before throwing.

Should I ignore a 404 during fixture cleanup?

Not-found is safe only when the API contract makes deletion idempotent and the resource identity proves the request targeted test-owned data. A blanket 404 catch can hide a wrong environment, malformed ID, or cleanup by another test.