Skip to content
End To End Tester

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
// 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);
});
csharp
// 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.

csharp
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.

python
@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.

typescript
// 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:

  1. Static reference data. Seeding 250 currencies per test is waste.
  2. 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 ↗
  • Pythonpython/tests/practices/test-data-management
  • TypeScripttypescript/src/practices/test-data-management

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?