Skip to content

Catching leaked E2E rows with test.afterAll

Adityo Guni Waluyo

An afterAll safety net for E2E rows left behind when a test times out: identity unique per worker, a sweep limited to owned and stale rows, idempotent delete.

TL;DR

A timed-out test skipped its DELETE step, leaving a stale row in the admin grid despite a passing spec. The fix adds an afterAll hook that cleans up rows even when tests fail or time out. It targets only this run's rows to avoid deleting data from other workers.

The admin/announcements grid in KotaPortal still displayed one information row after the E2E suite finished, even though the spec reported a pass. Inspection showed the test had exceeded the time limit, so the DELETE step inside the test body never ran and the row leaked. Playwright does separate the hook path from the test body: afterAll executes once per worker after all tests in the file [1].

The fix was installed in frontend/e2e/admin/informasi-crud.spec.ts through commit 262fa07 (+71/-4, 16 October 2026): a single test.afterAll hook as a safety net. The comment in the spec calls it a safety net because this hook still runs when a test fails or times out, while the DELETE step inside the test body does not. The commit demonstrates three rules: row identity unique per worker, sweeping limited to the worker's own rows plus rows older than the start of the run, and an idempotent two-step delete.

Prerequisites. Playwright Test, one admin CRUD spec that creates rows through the API, and login credentials imported from a helper file. The values are never written inside the spec, so the example below uses placeholders. Two endpoints are used: the announcements list including trash mode, and permanent deletion by id. The referenced Playwright documentation was accessed on 17 October 2026.

Build a unique row identity per worker

  1. Replace the test title construction. A millisecond timestamp alone is not enough: two parallel workers can load the spec module at the same millisecond, the titles come out identical, and one worker's test deletes the other worker's row. Playwright itself documents workerIndex for creating data unique per worker [6].
const TITLE_PREFIX = "E2E Informasi ";
const RUN_STARTED_AT = Date.now(); // start of the run, calculated when the module loads
const random5 = Math.random().toString(36).slice(2, 7);
const TITLE = `${TITLE_PREFIX}${Date.now()}-${random5}`;
  1. Use TITLE as the title of the row the test creates, and TITLE_PREFIX as the filtering keyword for the whole spec.

RUN_STARTED_AT must remain at module top level. This value is what separates rows of the current run from rows left behind by previous runs.

Insert afterAll as a safety net

The reason the hook path is safer is recorded in the documentation: when a test fails, Playwright discards the entire worker along with its browser and starts a new worker [2], and afterAll still runs, including being repeated in the replacement worker [1]. The hook also has its own timeout, defaulting to the same value as the test timeout, namely 30 seconds [3].

The first version swept every row carrying the prefix. The commit comment records the regression: rows belonging to tests still running were deleted along with them. Tests within one file run sequentially on a single worker, while parallel mode forbids tests from sharing state or global variables [4]. The safe scope is therefore only the two criteria below.

  1. Register test.afterAll at the end of the spec file, then create a single request context through request.newContext() to log in via the API once.
  2. Fetch the non-trash and trash rows using the keyword from the prefix, trimmed of whitespace first.
  3. Delete only the row titled exactly TITLE, or a prefixed row whose time column falls before RUN_STARTED_AT. Two-step delete: to trash, then permanent; status 404 is tolerated so the operation stays idempotent when the test has already deleted the row itself.
test.afterAll(async () => {
  const api = await request.newContext({ baseURL: `${API}/` });
  try {
    const loginRes = await api.post("admin/auth/login", {
      data: { email: "<ADMIN_EMAIL>", password: "<ADMIN_PASSWORD>" },
    });
    if (!loginRes.ok()) {
      console.warn(`cleanup: login API gagal (${loginRes.status()})`);
      return;
    }
    const { token } = await loginRes.json();
    const headers = { Authorization: *** ${token}` };

    const ids = new Set();
    for (const trashed of [false, true]) {
      const res = await api.get("admin/announcements", {
        headers,
        params: { q: TITLE_PREFIX.trim(), limit: 100, ...(trashed ? { trashed: 1 } : {}) },
      });
      if (!res.ok()) continue;
      const { data = [] } = await res.json();
      for (const row of data) {
        if (!row.title?.startsWith(TITLE_PREFIX)) continue;
        const own = row.title === TITLE;
        const stale = row.created_at && Date.parse(row.created_at) < RUN_STARTED_AT;
        if (own || stale) ids.add(row.id);
      }
    }

    for (const id of ids) {
      await api.delete(`admin/announcements/${id}`, { headers });
      const del = await api.delete(`admin/announcements/${id}/permanent`, { headers });
      // 404 is tolerated: the row may already have been deleted by a passing test
      if (!del.ok() && del.status() !== 404) {
        console.warn(`cleanup: gagal menghapus baris ${id}`);
      }
    }
  } finally {
    await api.dispose();
  }
});

Verify by running the spec twice in a row

  1. Run the spec with two workers: npx playwright test frontend/e2e/admin/informasi-crud.spec.ts --workers=2. Expected output: all tests pass.
  2. Run the same command without a pause to start a second run.
  3. While the second run is in progress, filter the announcements grid with the prefix keyword. Expected output: only rows created by the second run appear, rows from the first run have been swept, and rows belonging to other workers are unaffected.

This pattern applies to every spec that writes to shared data. Playwright best practices require each test to be isolated and its data controlled [5]. afterAll is the last control layer that keeps running when the tests inside it fail, and it has become the standard for the other CRUD specs in KotaPortal.

Sources:

Related articles