Browser tests rarely fail because the UI is hard. They fail because some earlier run left behind state that the current run can still see. A seeded account that was not deleted, a reused email address, a cookie that survived longer than expected, or a record created by a parallel job, all of these can turn a clean test into a flaky one.

If your only fix is to reset the whole environment, you pay for it every time in runtime, coordination, and maintenance. I would rather prevent test data leakage in CI browser tests with three layers: per-run identifiers, cleanup hooks, and environment-level guardrails. That gives you CI test isolation without demanding a full database wipe on every pipeline.

What data leakage actually means here

Before the tactics, it helps to separate three things that are easy to mix up:

  • Browser session state, cookies, localStorage, sessionStorage, auth tokens, cache.
  • Application data, rows in your database, messages in queues, uploaded files, emails, feature flags.
  • CI job state, artifacts, workspaces, test containers, parallel runners.

A browser reset can clear session state. It does not delete the order created by a previous test. A database truncate can remove application data. It does not help if two CI jobs reuse the same email address and collide during signup.

The goal is not “no shared state exists anywhere.” The goal is “tests can only see the state they created, and they can clean it up deterministically.”

The basic strategy

For most teams, the lowest-risk approach looks like this:

  1. Generate a unique run ID for each CI run.
  2. Namespace every test-created record with that ID.
  3. Use cleanup hooks to delete records created by the current run.
  4. Add guardrails so test code cannot silently fall back to shared production-like data.
  5. Fail fast when cleanup does not happen.

That is enough for a lot of suites. Full environment resets still make sense for some integration layers, but browser E2E should not depend on them unless there is no better boundary.

Use a run ID everywhere test data is created

The single most useful pattern is to stamp every test-created entity with a run identifier. That can be a UUID, a CI build number, or a combination of branch and build ID. I prefer something opaque and globally unique.

Common places to apply it:

  • user email addresses, qa+<runId>@example.com
  • organization names, qa-org-<runId>
  • uploaded files, invoice-<runId>.pdf
  • API-created fixtures, tags or metadata fields
  • feature flags or tenants, if your system supports them

In Playwright, a small helper keeps this consistent:

import { test as base } from '@playwright/test';
import { randomUUID } from 'node:crypto';

export const test = base.extend<{ runId: string }>({ runId: async ({}, use) => { await use(process.env.CI_RUN_ID ?? randomUUID()); }, });

Then use that ID in every factory:

const email = `qa+${runId}@example.com`;

For Selenium, the same idea belongs in your fixture layer, not inside each test.

import os
import uuid

RUN_ID = os.getenv(“CI_RUN_ID”, str(uuid.uuid4())) email = f”qa+{RUN_ID}@example.com”

This sounds small, but it closes one of the easiest leaks to miss, reused natural keys.

Delete only what this run created

Cleanup should be scoped to the current run. Avoid broad deletion like “remove all users created today” unless the environment is disposable and tightly controlled.

A good cleanup design has two parts:

  • a record of created entities, usually an array in test memory or a manifest file
  • a finalizer that deletes them through the API or database helper you already trust

A Playwright pattern using afterEach or afterAll is usually enough if your data volume is small:

const createdUsers: string[] = [];

test.afterEach(async ({ request }) => { for (const userId of createdUsers) {

await request.delete(`/api/test-data/users/${userId}`);
  }
  createdUsers.length = 0;
});

If the test can fail before it records all entities, make the cleanup more defensive. Persist the manifest as soon as a record is created, not only at the end.

For browser suites that create data through the UI, I usually prefer a companion API for cleanup. It is easier to make deterministic than trying to reverse browser actions.

Keep session state isolated too

A clean database does not help if browser context state is reused across tests. Playwright and Selenium approach this differently, but the principle is the same: create a fresh session boundary for each test or each test file when the suite design requires it.

With Playwright, use a new browser context per test unless you have a strong reason not to. The official documentation for browser contexts is worth reading because this is exactly the unit of isolation that matters.

import { test, expect } from '@playwright/test';
test('creates a user', async ({ page }) => {
  await page.goto('/login');
  // fresh context, fresh cookies, fresh localStorage
});

With Selenium, isolate at the driver/session level and avoid reusing the same browser instance across unrelated tests. Selenium’s WebDriver model is session-based, so your fixture lifecycle matters more than the test body. The Selenium documentation is the right place to verify your driver and browser lifecycle assumptions.

Also remember that clearing cookies is not the same as clearing app state. If your app stores auth tokens in localStorage or IndexedDB, you need to clear that path explicitly or start a new context.

Add environment-level guardrails

Per-run IDs and cleanup hooks work best when the environment helps them.

1. Make shared identifiers impossible to use by accident

Hard-code test-only prefixes in the test helpers, not in individual tests. If a test author has to invent an email address from scratch, leakage will eventually happen.

2. Block writes without a test namespace

If your app or test backend can enforce a namespace, do it. Examples:

  • reject test writes without runId
  • require qa- prefixed tenants in lower environments
  • store all CI-created rows in a dedicated schema or tenant

3. Fail when cleanup stops working

A cleanup task that logs and continues is a quiet failure mode. If test data remains after a run, the next run inherits a mystery.

I prefer a post-run verification step that checks for leftover entities with the current run ID and fails the job if any remain.

curl -s "$API_URL/test-data/cleanup-status?runId=$CI_RUN_ID" | jq

If you cannot query a cleanup status endpoint, export created IDs into an artifact and verify deletion in a final step.

Where full resets still make sense

There are cases where a full environment reset is still the pragmatic choice:

  • the app has no test-only API or namespace support
  • the tests mutate shared global configuration that cannot be scoped
  • the data model is too tangled to delete safely by ID
  • a containerized disposable environment is already cheap to recreate

That said, a full reset should be a deliberate architectural choice, not the default response to missing isolation.

If the suite depends on resetting everything to stay green, the hidden cost is usually higher than it looks. You are paying in pipeline time, setup complexity, and the debugging tax when a reset itself fails.

A compact decision table

Problem Better first move Why
Reused user emails or tenant names Per-run identifiers Prevents collisions without touching the whole environment
Leftover rows from failed tests Cleanup hooks plus manifest Deletes only what the run created
Stale auth state across tests Fresh browser contexts or sessions Resets cookies, storage, and session-scoped data
Cross-job collisions in CI CI-run namespace in all test writes Makes parallelism safe
Unknown leftover data after runs Post-run verification Turns silent leakage into a visible failure

A practical CI pattern that scales

If I were tightening an existing suite, I would implement this in order:

  1. Generate CI_RUN_ID in the pipeline.
  2. Expose it to tests as an environment variable.
  3. Update all test factories to prefix created entities with that ID.
  4. Add afterEach or afterAll cleanup for test-created records.
  5. Use fresh browser contexts or sessions per test boundary.
  6. Add a post-run check for leftover data.
  7. Only then decide whether a broader reset is still necessary.

Here is a simple GitHub Actions example that seeds the run ID and passes it to the test process:

name: e2e

on: [push, pull_request]

jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci - run: npm test env: CI_RUN_ID: $-$

The important part is not GitHub Actions itself. It is that every downstream helper can rely on one unique identifier.

Failure modes to watch for

A few leaks are easy to miss:

  • Parallel tests share a static email alias and one run races another.
  • Cleanup runs only on success, so failed tests leave data behind.
  • A test creates records through the UI, but cleanup expects API-side IDs that were never captured.
  • LocalStorage or IndexedDB persists between tests because the same browser context is reused.
  • “Delete by prefix” cleanup matches a real user record because the prefix is too weak or too short.

The cure is the same: scope by run ID, capture created IDs immediately, and verify deletion after the run.

Not the best fit if…

This approach is not enough by itself if your suite depends on mutating state that has no cleanup API and no safe namespace boundary. In that case, a disposable environment or a more heavily isolated test stack may be the right investment.

It is also a poor fit if the team cannot make test data creation deterministic. If every test invents its own setup path, you will spend the same engineering time debugging cleanup as you would have spent on a reset strategy, only with more hidden failure modes.

Bottom line

To prevent test data leakage in CI browser tests, start with per-run identifiers, delete only what the run created, and enforce isolation at the browser session boundary. That gets you most of the benefit of a reset without paying for a full environment rebuild every time.

The rule of thumb I use is simple: if a test can create data, it must also be able to identify and remove that data. If it cannot, your suite is borrowing stability from a reset you do not really control.

FAQ

Should I clear cookies between every browser test?

Yes, if your suite reuses a browser session. Better yet, create a new browser context or WebDriver session so cookies, storage, and session data start clean.

Is deleting test data in afterEach enough?

Sometimes. It is fine for small, self-contained tests. For larger suites, combine afterEach cleanup with a post-run verification step so failed tests do not leave silent residue.

What if my application does not have a test cleanup API?

Add one if you can. If you cannot, use a database helper or a dedicated teardown job that deletes only records tagged with the CI run ID.

How do I avoid collisions when tests run in parallel?

Use a unique CI_RUN_ID and include it in every generated record, not just some of them. Parallel safety depends on consistent namespacing.

Do Selenium and Playwright need different isolation strategies?

The implementation differs, but the goal is the same. Playwright makes browser contexts explicit, while Selenium depends more on session lifecycle and your fixture design. Both still need cleanup for application data.