from static fixtures to safe parallelization
Most flaky E2E tests don't fail because of broken selectors, they fail because of poorly managed test data. Teams that consistently separate fixtures, factories, ownership models and cleanup strategies gain reproducible, parallelizable test runs that stay reliable even as suites grow and schemas change frequently.
Table of Contents
- 1. Why test data management determines the stability of an E2E suite
- 2. Static fixture files: JSON, YAML and CSV
- 3. Dynamically generated test data: factories and API setup
- 4. Test data ownership: own data vs. shared baseline
- 5. Shared baseline datasets: benefits and risks
- 6. Avoiding test interdependence through data
- 7. Versioning test data with the test code
- 8. Cleanup and teardown strategies
- 9. Static fixtures vs. dynamic test data compared
- 10. Summary
- 11. FAQ
1. Why test data management determines the stability of an E2E suite
Most discussions about flaky tests revolve around selectors, wait times and network timing. In practice, however, the cause is more often found in the test data itself: one test creates a customer with a specific email address, another test uses the same address and fails because it already exists. A third test relies on a product stock level that a parallel test run is changing at that very moment. This class of failure has nothing to do with the actual test logic, but with a lack of control over the data state.
Test data management is therefore not a side issue, it is the foundation every E2E suite stands on. A suite with excellent assertions but chaotic data handling still produces unreliable results. Conversely, a suite with simpler assertions but a clear data strategy stays stable for months and can be parallelized without concern. The following sections cover the central decisions: static versus dynamic data, ownership models, baseline datasets, avoiding interdependence, versioning and cleanup.
2. Static fixture files: JSON, YAML and CSV
Static fixtures are predefined data files that are already fixed before the test run, usually stored in the repository as JSON, YAML or CSV. The big advantage lies in determinism: a test that checks against a fixture file with exactly known values delivers the same result on every run, as long as the application itself doesn't change. Fixtures are also fast, because they don't need an extra setup request against an API or database, and they are easy for humans to read, which makes code reviews and debugging easier.
The downside shows up over time: fixtures drift away from the actual application. If an API response format or a database schema changes, the fixture file stays unchanged until someone manually updates it. This schema drift problem is the most common reason fixtures lead to false-positive test results over months: the test passes because it checks against outdated assumptions, not because the application actually works correctly. Fixtures are therefore best suited for stable, rarely changing structures like configuration values or reference lists, not for core entities such as orders or shopping carts.
{
"customer": {
"id": "cust_fixture_001",
"email": "fixture.customer@example.test",
"firstname": "Erika",
"lastname": "Musterfrau",
"addresses": [
{
"street": "Teststrasse 12",
"postcode": "80331",
"city": "Munich",
"country": "DE"
}
]
},
"cart": {
"items": [
{ "sku": "TEST-SKU-001", "qty": 2, "price": 29.90 },
{ "sku": "TEST-SKU-002", "qty": 1, "price": 14.50 }
]
}
}
3. Dynamically generated test data: factories and API setup
Dynamic test data is created at runtime, usually via factory functions that generate realistic values with a faker library and then create them through the application's API. The decisive advantage over fixtures: factories automatically stay in sync with the current schema, because they run through the same API endpoint the production application also uses. If a required field changes, the factory call fails immediately instead of silently confirming an outdated assumption.
Factories also allow overrides: a test can selectively override a factory's default values to produce exactly the edge case it wants to check, such as a customer with a locked account or a product with zero stock. This drastically reduces the number of separate fixture files needed, because a single factory covers any number of variants. The price is runtime: every factory call means at least one additional API request, which noticeably slows down test suites when factories are called unnecessarily often for trivial data. A pragmatic middle ground combines both approaches: fixtures for stable reference data, factories for everything the individual test needs on its own.
// factories/customer.factory.ts
import { faker } from '@faker-js/faker';
import { apiClient } from '../support/api-client';
interface CustomerOverrides {
email?: string;
isLocked?: boolean;
groupId?: number;
}
/**
* Creates a customer via the application API with realistic
* fake data. Overrides allow tests to target specific edge cases.
*/
export async function createCustomer(overrides: CustomerOverrides = {}) {
const payload = {
email: overrides.email ?? faker.internet.email(),
firstname: faker.person.firstName(),
lastname: faker.person.lastName(),
groupId: overrides.groupId ?? 1,
isLocked: overrides.isLocked ?? false,
};
const response = await apiClient.post('/customers', payload);
return response.data; // includes generated id for later cleanup
}
4. Test data ownership: own data vs. shared baseline
The question of data ownership largely determines how well a suite can be parallelized. In the first model, each test creates its own data at the start and removes it again at the end, so no test depends on another. This model scales cleanly with parallel execution, because there is no shared state that two tests could change at the same time. The price is additional runtime per test, since setup and teardown run again for every single test.
In the second model, many tests access a shared dataset that has been set up in advance, for example a catalog with fixed test products. This saves runtime, because the expensive setup happens only once, but it introduces coupling: if a test accidentally changes an element of the baseline, that affects all subsequent tests that read the same data. The robust practice combines both models by purpose: writing, state-changing tests create their own data; purely reading tests may rely on a stable baseline that is never mutated. The line between the two categories needs to be documented explicitly within the team, otherwise it blurs over time.
5. Shared baseline datasets: benefits and risks
A baseline dataset pays off above all for data that is practically immutable within the application itself, or that is never written by the tests, such as a reference catalog with a hundred products for search and filter tests. Such tests only read, they change nothing, and benefit maximally from eliminating the repeated setup. The time savings across a large suite can be considerable, especially when creating a full catalog via the API takes several seconds.
A baseline becomes risky as soon as a test accidentally mutates it, for example when a checkout test actually purchases a product from the shared catalog and reduces the stock level. The next test that expects the same stock level then fails without any obvious reason, often only days later and hard to reproduce, because the order of parallel execution varies. The rule of thumb: baseline data is only suitable for tests that are guaranteed to be read-only, and any write operation on baseline data is an architectural flaw that should be fixed with a dedicated, test-owned copy of the affected entity.
6. Avoiding test interdependence through data
Interdependence between tests almost always arises from shared identifiers: two tests use the same fixed email address, the same SKU, or the same coupon code string. As long as the suite runs sequentially, the problem often goes unnoticed. As soon as tests run in parallel across multiple workers, these identifiers collide, and tests start failing sporadically without any code change. The solution is consistently unique test data per test run: every generated identifier gets a prefix from the test run ID, worker ID or a timestamp, so that no two parallel executions ever produce the same value.
Equally important is namespace isolation at the database level, for example via separate test tenants, schemas, or at least consistent prefixes in tables used by multiple suites. CI systems that run several workers against the same test environment additionally need a strategy against race conditions when related entities are written simultaneously, for example through optimistic locking on the application side or through database areas strictly isolated per worker. Without this isolation, the benefit of parallelization decreases, because the time gained is lost again to re-running tests due to flakiness.
# ci-pipeline.yml: parallel-safe test data isolation per worker
jobs:
e2e-tests:
strategy:
matrix:
worker: [1, 2, 3, 4]
steps:
- name: Run E2E suite with isolated data namespace
env:
# unique prefix guarantees no cross-worker ID collisions
TEST_RUN_ID: "run-${{ github.run_id }}-w${{ matrix.worker }}"
TEST_DB_SCHEMA: "test_worker_${{ matrix.worker }}"
run: |
npx playwright test --shard=${{ matrix.worker }}/4
7. Versioning test data with the test code
Fixtures and factories must be versioned in the same repository and the same commit as the associated test code, and ideally close to the application schema. If a backend team changes a required field in the customer API, that change must be merged at the same time as the factory function adjustment, not weeks later as a separate fix. In practice this works most reliably when factories compile against a generated API type schema (for example from OpenAPI or GraphQL introspection), so a schema break already fails at build time, long before the test even runs.
For static fixtures, a migration mechanism similar to a database migration is additionally recommended: every fixture file carries a version number, and a small script checks at test run time whether the fixture structure still matches the current schema version, instead of letting the error surface as a cryptic assertion failure in the middle of the test. This upfront check saves significant debugging time, because the error message points directly to the outdated fixture instead of an apparent application problem.
#!/usr/bin/env bash
# validate-fixtures.sh: fail fast on schema drift before tests run
set -euo pipefail
SCHEMA_VERSION_CURRENT="$(cat schema/VERSION)"
for fixture in fixtures/*.json; do
fixture_version="$(jq -r '.schemaVersion // "unknown"' "$fixture")"
if [[ "$fixture_version" != "$SCHEMA_VERSION_CURRENT" ]]; then
echo "[ERROR] $fixture is on schema $fixture_version, expected $SCHEMA_VERSION_CURRENT" >&2
echo "[ERROR] Run 'npm run migrate-fixtures' before running the suite" >&2
exit 1
fi
done
echo "[OK] All fixtures match schema version $SCHEMA_VERSION_CURRENT"
8. Cleanup and teardown strategies
Consistent cleanup is the second half of every ownership strategy: whoever creates their own test data must also reliably remove it again, otherwise the test environment grows uncontrollably and slows down further with every test run. The most robust method is API-based teardown in an afterEach or afterAll hook that logs all IDs created during the test and deletes them specifically at the end, regardless of whether the test passed or failed.
Where the test environment allows it, a database-based rollback is even more reliable: the test runs inside a transaction that is always rolled back at the end, regardless of the test result. This eliminates cleanup code entirely, but only works if the application itself has no side effects of its own running outside the transaction, such as sending emails or calling external webhooks. In addition, any test environment operated over the long term needs automated detection of orphaned data, for example a nightly job that deletes test accounts and orders whose namespace prefix is older than 24 hours, to catch cleanup gaps left by aborted CI runs.
// support/teardown.ts: Playwright fixture with guaranteed cleanup
import { test as base } from '@playwright/test';
import { apiClient } from './api-client';
type TestData = { createdCustomerIds: string[] };
export const test = base.extend<{ testData: TestData }>({
testData: async ({}, use) => {
const data: TestData = { createdCustomerIds: [] };
await use(data);
// Runs after every test, pass or fail
for (const id of data.createdCustomerIds) {
await apiClient.delete(`/customers/${id}`).catch((err) => {
console.warn(`Cleanup failed for customer ${id}:`, err.message);
});
}
},
});
9. Static fixtures vs. dynamic test data compared
Both approaches have their place, but the choice has direct consequences for readability, maintenance effort and the parallelizability of the suite. The following overview compares both strategies along the most important decision dimensions.
| Dimension | Static Fixtures | Dynamic Factories | Recommendation |
|---|---|---|---|
| Determinism | Very high | Medium, depends on faker seed | Fixtures for exact edge cases |
| Schema drift risk | High without validation | Low, follows the API | Factories via real endpoint |
| Parallel safety | Risk with shared fixtures | High with unique IDs | Namespace per test run |
| Runtime per test | Very fast | Additional API requests | Fixtures for reference data |
| Maintenance effort | Manual on schema changes | Low, centralized in the factory | Factory overrides instead of copies |
In most suites a hybrid strategy works best: static fixtures for rare, stable reference data and edge cases, dynamic factories for everything that touches core entities of the application and needs to move with the schema. Teams that use both approaches deliberately by purpose rather than out of habit reduce both flakiness and maintenance effort at the same time.
Mironsoft
E2E test automation, test data architecture and CI/CD integration
Fix the test data chaos in your E2E suite?
We analyze your existing test suite, identify interdependencies and schema drift, and build a resilient test data architecture with clear ownership, clean cleanup and stable parallelization.
Test Data Audit
Analysis of flakiness causes with a focus on data coupling
Factory Architecture
Building schema-safe factories with overrides for Cypress and Playwright
CI Parallelization
Namespace isolation and cleanup jobs for parallel test workers
10. Summary
A sound test data management approach for E2E suites combines several decisions that each seem unremarkable on their own, but together determine stability and maintainability. Static fixtures deliver determinism and speed for stable reference data, but drift away from the application schema without validation. Dynamic factories stay automatically in sync with the API, but cost additional runtime. Clear ownership, either data owned per test or a baseline that is deliberately never mutated, prevents most interdependence problems.
Unique identifiers per test run make suites parallel-safe, versioning fixtures alongside the test code prevents silent schema drift, and reliable cleanup, ideally through transactional rollback or logged API teardown, keeps the test environment clean over the long term. Teams that deliberately combine these five building blocks instead of improvising them ad hoc noticeably reduce flakiness and gain the ability to parallelize their suite without fear of collisions.
Test Data Management Strategies for E2E Suites - The Key Takeaways
Fixtures vs. Factories
Fixtures for stable reference data, factories via the real API for core entities that need to move with the schema.
Separate ownership clearly
Writing tests create their own data, reading tests may access a baseline that is never mutated.
Parallel safety
Unique identifiers with a test run prefix prevent collisions between parallel workers.
Versioning & Cleanup
Version fixtures in the same commit as the test code, teardown via transaction or logged API delete.