Test Data Management
Where a test's data comes from decides whether the suite can run in parallel, twice in a row, or at all — builders, factories, fixtures and per-test isolation.
2 min read · updated 19 September 2026
Ask "where does this test's data come from" and you can predict most of a suite's problems: whether it can run in parallel, whether it passes on a clean machine, whether the second run passes, and how much of it breaks when someone changes a fixture.
#Four strategies, ranked
1. Build it in the test. Each test constructs exactly what it needs, in the test body, visible to the reader. The default, and the best.
2. Create it through an API. For integration and end-to-end tests: hit your own endpoints to set up state. Fast, uses real validation, and not coupled to the schema.
3. Seed static reference data once. Currencies, countries, plan tiers. Data that never changes and every test assumes.
4. Load a shared fixture file. A 4,000-row dump that every test depends on. Avoid. It becomes unchangeable within a year — nobody knows which of the 900 tests depend on customer 47 having two orders.
#Builders with meaningful defaults
The technique that makes strategy 1 practical: everything has a sane default, and the test names only what it cares about.
// TypeScript. The test states the one thing under test; the rest is noise
// the reader is spared.
export const aCustomer = (overrides: Partial<Customer> = {}): Customer => ({
id: crypto.randomUUID(),
email: `customer-${crypto.randomUUID()}@example.test`, // unique by construction
tier: 'standard',
createdAt: Date.parse('2026-01-01T00:00:00Z'),
...overrides
});
export const anOrder = (overrides: Partial<Order> = {}): Order => ({
id: crypto.randomUUID(),
customer: aCustomer(),
lines: [aLine()],
status: 'reserved',
...overrides
});
// The test reads as a sentence about tiers, not about order plumbing.
it('gives gold customers free shipping', () => {
const order = anOrder({ customer: aCustomer({ tier: 'gold' }) });
expect(shippingFor(order).cents).toBe(0);
});// C#. The fluent form, same idea.
public class OrderBuilder
{
private CustomerTier _tier = CustomerTier.Standard;
private readonly List<OrderLine> _lines = [];
public static OrderBuilder AnOrder() => new();
public OrderBuilder ForA(CustomerTier tier) { _tier = tier; return this; }
public OrderBuilder With(string sku, int quantity, int unitCents)
{
_lines.Add(new OrderLine(sku, quantity, unitCents));
return this;
}
public Order Build() => new(
Id: Guid.NewGuid(),
Customer: new Customer(Guid.NewGuid(), $"c-{Guid.NewGuid():N}@example.test", _tier),
Lines: _lines.Count > 0 ? _lines : [new OrderLine("default-sku", 1, 1_000)]);
}Uniqueness by construction is the important detail. Every email, every reference, every tenant id contains a fresh UUID or the worker index. That one habit is what makes the suite safe to parallelise later, and retrofitting it across four hundred tests is a miserable job.
#Isolation for database tests
Three approaches, all valid:
Transaction rollback. Start a transaction before the test, roll back after. Fast and total. Fails when the code under test manages its own transactions or uses a separate connection.
public async Task InitializeAsync() => _tx = await _connection.BeginTransactionAsync();
public async Task DisposeAsync() => await _tx.RollbackAsync();Unique scope per test. Every test operates under its own tenant id, prefix or namespace. Works with any transaction strategy, works in parallel, and leaves data behind — which is usually fine in an ephemeral container.
@pytest.fixture
def tenant(db):
tenant_id = f"t-{uuid4().hex[:8]}"
db.create_tenant(tenant_id)
yield tenant_id
# No cleanup: the container is thrown away with the suite.Fresh schema per worker. With Testcontainers, one database per parallel worker. Perfect isolation, costs container startup once per worker.
#End-to-end data
The rule from end-to-end testing: set up through the API, act through the UI.
// Playwright. The fixture creates and returns data; nothing is shared.
export const test = base.extend<{ customer: Customer }>({
customer: async ({ request }, use, testInfo) => {
const customer = await createCustomer(request, {
// Worker index keeps parallel shards from colliding on unique keys.
email: `buyer-${testInfo.workerIndex}-${Date.now()}@example.test`
});
await use(customer);
await deleteCustomer(request, customer.id); // best-effort tidy-up
}
});#Up front or as you go?
As you go — each test creates what it needs — is the right default. Tests are independent, readable and parallel-safe, and the data is beside the assertions that depend on it.
Up front is worth it in exactly two cases:
- Static reference data. Seeding 250 currencies per test is waste.
- Expensive shared setup that is genuinely read-only. A 50,000-row dataset for a performance or reporting test. Build it once per suite, and have every test treat it as immutable.
The failure mode of "up front" is a test that mutates the shared data. One such test poisons every later one, and the failure appears somewhere else entirely — a classic flaky test cause and maddening to track down.
#Production data
Do not copy production data into test environments. It is personal data with no lawful basis for that processing, it will end up in a CI log or a screenshot, and it makes tests depend on values nobody controls.
If you need production-shaped data — volume, distribution, edge cases — generate it. A synthetic dataset built from a real distribution gives you the same signal with none of the liability.
Common questions
- Should tests share a seeded dataset or create their own data?
- Create their own. A shared dataset couples every test to a fixture file that nobody dares change, and a test that mutates shared data breaks its neighbours. The exception is genuinely static reference data — currencies, countries, product categories — which is cheap to seed once.
- How do I keep integration tests from interfering with each other?
- Give each test its own scope — a transaction that is rolled back, a unique tenant or prefix, or a fresh schema. All three work; which one you pick depends on whether the code under test manages its own transactions.
- Should I create test data through the UI?
- Almost never. It is slow, it makes every test depend on unrelated screens, and a failure in setup is reported as a failure of the thing you were testing. Use the API or the database directly and keep exactly one test that exercises the creation journey itself.
Runnable samples for this page
last test results ↗- Python
python/tests/practices/test-data-management - TypeScript
typescript/src/practices/test-data-management
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Running Tests in ParallelHow parallelism turns hidden coupling into failures, the shared resources that clash, and how to make a suite genuinely safe to run concurrently.
- Flaky TestsWhy tests fail intermittently, the six root causes and how to fix each one, how to detect flakiness deliberately, and what to do with a test you cannot fix today.
- Integration TestingTesting your code against real dependencies — databases, HTTP clients, message brokers — with Testcontainers, and what belongs at this level rather than above or below it.
- Database TestingTesting migrations, constraints, queries and transactions — the layer everything else depends on, and the one most suites replace with a mock.
- End-to-End TestingWhat belongs in an end-to-end suite and what does not, how many journeys are enough, and the practices that keep a browser suite from becoming the thing everyone ignores.
- Terraform for Test InfrastructureProvisioning ephemeral test environments as code — per-branch stacks, workspaces, cost controls, and testing the Terraform itself.
- Writing Tests in Pythonpytest fixtures, the patching rules that catch everyone, async testing, and the toolchain for a Python project that has to hold up in CI.
- Writing Tests in TypeScriptThe TypeScript testing toolchain — Jest or Vitest, typed doubles, async idioms, and the type-level tricks that make tests both safer and more readable.