July 7, 2026
How to Set Up Repeatable Test Data for Browser QA Projects Without Breaking Parallel Runs
Learn how to seed, isolate, and clean repeatable test data for browser tests so your QA suite stays stable in local runs, CI, and parallel execution.
Browser QA becomes much easier to trust when every run starts from a known state. The hard part is that browser tests rarely touch only the browser. They create users, upload files, place orders, change profiles, send notifications, and leave traces in a database, cache, queue, or object store. Once that happens, the same suite can pass once, fail later, and behave differently in parallel runs.
That is why repeatable test data for browser tests matters. You want the same scenario to be available every time, but you also want tests to run independently, in parallel, and in both local and CI environments. The goal is not to make data static forever. The goal is to make the data lifecycle predictable enough that the test suite can create, use, and clean up what it needs without interfering with other tests.
This article walks through a practical project approach to seeding, isolating, and resetting browser test data. The examples use common automation patterns, but the ideas apply whether you are using Playwright, Cypress, Selenium, or another test stack.
What repeatable test data actually means
Repeatable test data is data that a test can rely on being available in a known shape before it starts. In browser automation, that usually means a combination of:
- deterministic seed records, such as a user with a known role
- isolated test-specific records, such as a fresh cart or order
- cleanup rules that remove or reset what the test changed
- environment-level reset steps for shared backing systems
A practical test data strategy needs to answer four questions:
- What data must exist before the test starts?
- What data can the test create on its own?
- What data must be removed or reset after the test?
- What data can be shared safely across tests, if any?
If a test depends on a record that another test can mutate, you do not have a repeatable test. You have a race condition with a browser.
Start with the simplest useful data model
Many teams overcomplicate test data because they try to mirror production too closely. For browser QA projects, that usually creates more maintenance than value. Instead, define a minimal test fixture model around the user journeys you actually test.
A good starter set might include:
- one admin user
- one standard user
- one user with a locked or pending state
- one product or entity with a known identifier
- one empty workspace or account
Do not seed dozens of nearly identical records unless you have tests that truly need them. The fewer moving parts, the easier it is to reset state and diagnose failures.
The shape of seed data should be stable. That means fixed IDs or unique natural keys, controlled timestamps when possible, and predictable relationships. If your tests depend on “the latest order,” that is already a sign the data model is too loose for reliable automation.
Separate seed data from test-created data
A common mistake is using the same records for setup and verification. For example, a test logs in as a seeded user, creates an order, then expects the user profile page to reflect that order. This can work, but only if the test knows exactly what the seeded state was before the run.
A cleaner model is to split data into two buckets:
1. Seed data
Seed data is the baseline state loaded before tests run. It is usually:
- small
- deterministic
- versioned with the test suite
- safe to recreate repeatedly
Examples:
qa-admin@example.comwith an admin role- a product called
QA Laptop Stand - a workspace named
Browser QA Sandbox
2. Test data
Test data is created during execution and is usually disposable.
Examples:
- a signed-up user with a random email suffix
- a draft blog post created by a test
- an uploaded file used only in that scenario
This separation makes failures easier to reason about. If a seeded record changes unexpectedly, you know the environment is dirty. If a test-created record lingers, you know cleanup is incomplete.
Design a test data reset workflow
A test data reset workflow is the set of steps that restores your environment to a known baseline. In small projects, this might be a simple database truncate and reseed. In larger systems, it may include cache flushes, queue cleanup, and object storage pruning.
A solid reset workflow should be:
- deterministic, the same inputs produce the same state
- idempotent, safe to run more than once
- fast enough for local and CI use
- scoped so one suite does not destroy data needed by another suite
A typical workflow looks like this:
- wipe test-only records
- recreate reference data
- seed baseline users and domain objects
- clear caches or session stores
- verify that the environment is ready
For browser QA projects, it helps to implement this as a script or command that runs before the suite, not as a manual process. Manual resets drift quickly.
Example: database reset script
If your test environment uses a dedicated database, a reset script can be as simple as truncating mutable tables and rerunning seeds.
bash #!/usr/bin/env bash set -euo pipefail
psql “$TEST_DB_URL” -c “TRUNCATE orders, carts, audit_logs RESTART IDENTITY CASCADE;” psql “$TEST_DB_URL” -f ./test/seed/base.sql
This is intentionally blunt. For a test database, that can be exactly what you want. The important detail is that it only targets a test environment, never a shared development or production database.
Example: Playwright global setup
In Playwright, you can run setup once before tests and create the baseline through an API rather than the UI.
import { request } from '@playwright/test';
export default async function globalSetup() { const api = await request.newContext({ baseURL: process.env.BASE_URL }); await api.post(‘/api/test/reset’); await api.post(‘/api/test/seed’); await api.dispose(); }
Using backend APIs for reset and seed is usually faster and less brittle than driving the browser to create the same records every run.
Prefer API or database setup over browser setup
If the test is about browser behavior, use the browser for the behavior under test, not for all setup. Logging in through the UI for every test often adds noise and flakiness. Creating users through the UI often makes the suite slow and harder to reset.
Use the browser when you need to validate:
- login flows
- permissions visible in the UI
- form validation
- navigation and rendering
- cross-page user journeys
Use API calls or direct database setup when you need to:
- create fixture records
- provision accounts
- seed permissions and roles
- reset or delete mutable data
That division keeps browser tests focused. It also reduces the amount of state a test can accidentally corrupt.
Make parallel runs safe by isolating test data
Parallel execution changes the problem. A data reset that works in serial can fail badly once multiple workers start touching the same records. If two tests share one account, one cart, or one inbox, they will eventually collide.
The safest strategy is to give each worker or test its own namespace.
Isolation patterns that work well
Per-worker data namespaces
Assign each parallel worker its own suffix or namespace, for example:
qa-user-worker-1@example.comqa-user-worker-2@example.com
This works well when tests need persistent records during the run but do not need to share them.
Per-test unique records
Generate a unique identifier per test, often with a timestamp or UUID. Use it in emails, titles, or reference fields.
import { test, expect } from '@playwright/test';
test('creates a draft post', async ({ page }) => {
const title = `QA Draft ${Date.now()}`;
await page.goto('/posts/new');
await page.getByLabel('Title').fill(title);
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText(title)).toBeVisible();
});
This is useful, but only if your backend can tolerate many temporary records. It also requires cleanup.
Per-suite isolated environments
For higher-risk flows, give a whole suite its own environment, such as a disposable database schema or container. This is heavier, but it avoids most cross-test interference.
What to avoid in parallel runs
- shared inboxes for email verification
- shared shopping carts or draft objects
- hardcoded usernames that multiple tests modify
- global counters or queue messages that one test can consume from another
Parallel test safety is mostly a data ownership problem. If no test owns the record, every test can break it.
Use cleanup hooks, but do not rely on them alone
Cleanup hooks for end-to-end tests are useful, but they are not a substitute for isolation. If a test crashes mid-run, cleanup may never happen. If the process is killed, the hook may not execute. If cleanup depends on the UI, it can fail for the same reason the test failed.
Use cleanup in layers:
Layer 1: test-level cleanup
Clean up the records created in the test itself.
import { test } from '@playwright/test';
test.afterEach(async ({ request }, testInfo) => {
const id = testInfo.annotations.find(a => a.type === ‘orderId’)?.description;
if (id) {
await request.delete(/api/test/orders/${id});
}
});
Layer 2: worker-level cleanup
Remove anything created by a worker if the suite uses per-worker namespaces.
Layer 3: environment-level cleanup
Run a reset job before the suite or after the suite to catch leftovers.
The important idea is defense in depth. Cleanup hooks reduce debris, but repeatability comes from the ability to recreate the baseline at any time.
Seed data should be versioned like code
Treat seed data as part of the test project, not as an invisible admin task. Keep it in source control, review changes through pull requests, and make it easy to reproduce locally.
A practical structure might look like this:
test/seed/base.sqlfor core reference datatest/seed/users.jsonfor fixture userstest/seed/seed.tsfor scriptable setuptest/reset.tsfor cleanup and bootstrap logic
This helps in several ways:
- reviewers can see when a test fixture changes
- contributors can reproduce failures from the repo alone
- CI and local runs use the same baseline
- it is easier to delete obsolete fixture data
If you keep seed data in a separate repo or hidden admin UI, it tends to drift out of sync with the tests that depend on it.
Handle external systems deliberately
Browser test data is not only about your app database. It often includes email, file storage, third-party APIs, and background jobs.
Use a test email sink, a local mail catcher, or an API you can query. Shared real inboxes are risky because messages can be consumed out of order or reused by unrelated tests.
File uploads
If tests upload files, name them uniquely and store them in a dedicated test bucket or folder. Clean up after the run if possible.
Queues and async jobs
If your app processes background jobs, make sure the test waits for the job to finish or confirms the eventual state through polling. Resetting the database alone does not clear a queue consumer that is still acting on old messages.
Caches and sessions
If a session or cache key can survive a reset, your test can observe stale state. Include cache invalidation in the reset workflow where needed.
Decide when to reset by transaction, by database, or by API
There is no universal best reset method. Choose based on scope and speed.
Transaction rollback
Best when tests run close to the data layer and each test can wrap its work in a transaction. This is fast, but browser tests often span multiple processes or services, which makes transaction-only strategies harder to use.
Database truncate and reseed
Best for isolated test databases and end-to-end suites. This is simple and usually reliable, but it can be slower than transactional reset.
API-driven cleanup
Best when you want the test environment to remain realistic and you have endpoints dedicated to test setup. This keeps tests closer to real system behavior without using the UI for setup.
Disposable environment
Best for critical flows or heavily parallel suites. This can mean a fresh schema, namespace, container, or ephemeral cloud resource per run.
A good rule is to start with the cheapest reset that still gives you reliable isolation. Then add stronger isolation only where the suite proves it needs it.
A practical pattern for browser QA projects
If you are building a project from scratch, this is a workable baseline:
- Create a dedicated test environment.
- Add a reset endpoint or script that truncates mutable tables.
- Seed a small, versioned set of reference records.
- Generate unique data for each test that creates its own records.
- Clean up test-created data in hooks and via an environment reset job.
- Assign separate namespaces to parallel workers if tests share backend resources.
A CI workflow can then look like this:
name: browser-tests
on: push: pull_request:
jobs: e2e: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npm run db:reset - run: npm run test:e2e
This pattern keeps the reset step explicit. If the tests fail, you know the suite started from a known state rather than inheriting yesterday’s leftovers.
Common failure modes and how to spot them
Flaky login tests
Often caused by shared users, lingering sessions, or stale cookies. Use dedicated test users and clear auth state between tests.
Missing records in parallel runs
Usually means one test consumed or modified the record another test expected. Create per-worker data or isolate the environment.
Tests that pass locally but fail in CI
Often due to CI running with higher parallelism, slower async jobs, or a cleaner environment that exposes missing seed data. Verify the reset workflow in CI, not just on a developer laptop.
Cleanup that hides real issues
If your cleanup code silently deletes everything, you may miss bugs in business logic. Make cleanup explicit, and log what it removed.
A checklist for stable repeatable data
Before you call a browser QA project stable, check that you can answer yes to most of these:
- Can I recreate the suite baseline with one command?
- Are seed records versioned with the test code?
- Does each test own its data or namespace?
- Do cleanup hooks remove only test-created records?
- Can the suite run in parallel without shared record collisions?
- Does CI run the same reset workflow as local development?
- Are external systems, like email and uploads, isolated or disposable?
If not, your suite may still be too dependent on accidental state.
Final takeaway
Repeatable test data for browser tests is less about generating more fixtures and more about defining ownership. Seed the smallest useful baseline, isolate what each test changes, and reset the environment in a way you can run again tomorrow without guessing what changed. That is the difference between a browser suite that merely executes and one that you can trust in parallel local runs and CI.
If you want to keep improving this area, revisit your data lifecycle every time a new test becomes flaky. In many teams, the real root cause is not the browser at all, it is shared state that was never designed for repeatable automation.
For background reading on the broader fields involved, see software testing, test automation, and continuous integration.