Most flaky end-to-end suites don't have a browser problem. They have a data problem. A test passes alone and fails in CI, or passes on Monday and fails on Friday, because two runs fought over the same record. Managing test data deliberately (how it's created, isolated, and cleaned up) removes a whole class of failures before you ever touch a selector.
This is a set of strategies, ordered roughly from "fix this first" to "nice to have once the basics hold."
The anti-pattern: shared staging data
The default setup for most teams is one long-lived staging database that every test reads and writes. It works for a week, then rots:
- Two runs create a user named
test@example.com; the second hits a uniqueness constraint and fails. - One test edits the invoice another test asserts on. Order of execution now decides the result.
- A developer manually "fixes" a record to unblock themselves, and a suite that depended on the old value goes red.
- Nobody can run the suite in parallel, because parallel workers trample each other's rows.
Shared mutable data couples tests that should be independent. The failures it produces look like product bugs, so people waste hours debugging the app when the real cause is two tests sharing a row. Every strategy below exists to break that coupling.
Seed the state a test needs, explicitly
A test should own the data it depends on. If a scenario needs an account with three open orders, create that account with three open orders as part of setup. Don't assume it already exists.
The fastest, most reliable way to seed is through your own API or a factory, not through the UI. Driving sign-up and order-creation forms in the browser just to reach the state you actually want to test is slow and fragile. Reserve the browser for the behavior under test.
import { test as base } from '@playwright/test';
type Fixtures = { account: { id: string; email: string } };
export const test = base.extend<Fixtures>({
account: async ({ request }, use) => {
// Seed via the API — fast and deterministic.
const res = await request.post('/api/test/accounts', {
data: { plan: 'pro', orders: 3 },
});
const account = await res.json();
await use(account);
},
});
Seeding through a fixture means the setup is visible, versioned with the test, and torn down in one place. It also documents intent: anyone reading the test sees exactly what state the assertion assumes.
Keep seed data minimal. A common mistake is loading a giant shared fixture ("the demo dataset") into every test. That reintroduces coupling: a change to the shared blob can now break dozens of unrelated tests. Seed the smallest state that makes the assertion meaningful, and nothing else.
Isolate per test, or at least per run
Isolation means one test's data can't collide with or be mutated by another's. There are three levels, and you want the strongest one you can afford:
| Level | Isolation | Cost |
|---|---|---|
| Per test | Each test creates and owns its own records | Highest confidence, more setup |
| Per run | Each suite run works in a fresh namespace or schema | Good balance for parallelism |
| Shared | Everyone reads the same data | Fragile; avoid for anything mutable |
Per-test isolation is the gold standard: each test creates its own account, its own records, and asserts only on those. Tests can then run in any order and in parallel without interference. When per-test creation is too expensive, fall back to per-run isolation: a fresh schema, tenant, or ID prefix minted once per suite invocation so concurrent runs in CI don't overlap.
The enemy of both is hardcoded literals. The moment a test writes "Acme Corp" or "user@test.com", it has claimed a name in a shared space, and the next run that claims the same name collides.
Generate unique values for anything you create
Any value a test creates (an email, a username, a record name, an SKU) needs to be unique on every run. Any value a test only reads can stay a stable literal.
The mechanics are simple: append a fresh stamp to the created value.
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const email = `casey+${stamp}@example.com`;
const projectName = `Project ${stamp}`;
Two rules make this actually work. First, the stamp has to be unique enough to survive parallel workers: a bare timestamp collides when two workers start in the same millisecond, so mix in a random or worker-scoped component. Second, a value created early and asserted (or reused) later in the same test must resolve to the same stamp both times, or the assertion checks the wrong record.
If you author tests in TestVibe from natural-language or Gherkin instructions, the built-in {{unique}} token handles exactly this. You write it inside any created value, and it expands to a fresh stamp at run time:
When the user signs up with email "casey+{{unique}}@example.com"
And the user creates a project named "Project {{unique}}"
Then the project "Project {{unique}}" should appear in the list
Every occurrence of {{unique}} within the same run resolves to the same value, so a later step can sign back in with, or assert against, what an earlier step created. It takes no arguments and needs no variable definition. A value that only references data that already exists stays a plain literal. (For credentials and other secrets, use dedicated variable and secret tokens instead of embedding them in the feature text.)
Cleanup vs. disposable environments
Two philosophies handle the leftover data:
Clean up what you create. Tear down each test's records in an afterEach or fixture teardown. This keeps a persistent database usable over time.
test.afterEach(async ({ request }, testInfo) => {
for (const id of createdAccountIds) {
await request.delete(`/api/test/accounts/${id}`);
}
});
Cleanup is disciplined but brittle: a test that crashes mid-run leaks its records, and over months the leaks accumulate into exactly the polluted database you were trying to avoid. Guard against it with a periodic sweep that deletes anything older than a day carrying your test prefix.
Throw the whole environment away. Instead of deleting rows, discard the entire database. Spin up a fresh, ephemeral instance per run (a container or a template-cloned schema) and destroy it when the suite finishes. Nothing to clean up because nothing persists.
services:
db:
image: postgres:16
environment:
POSTGRES_DB: e2e_${RUN_ID}
tmpfs:
- /var/lib/postgresql/data # in-memory: gone when the container stops
Disposable environments give the strongest isolation and never accumulate cruft, at the cost of provisioning time per run. If your app can boot against a fresh database quickly, they're usually worth it. If setup is heavy, per-test cleanup against a shared instance is the pragmatic middle ground.
Where to start
If you fix one thing, kill shared mutable data. Give every test its own records with unique names, seed through an API instead of the UI, and pick one cleanup strategy, sweep or disposable, and apply it consistently. The suite gets faster, parallelizable, and quiet.
TestVibe allocates a fresh, isolated cloud sandbox for every run and gives you {{unique}} for created values, so per-run collisions are handled out of the box. Get early access, or read the test-authoring guide for how uniqueness and seeding play into good instructions.