Skip to content
SDETRole
SDETRole

Lesson 9 of 12 · Automation Frameworks

Test Data Management

Where test data should come from, how to keep it unique and isolated across parallel runs, builders and factories with Faker, data-driven tests, cleanup, and privacy.

intermediate 8 min read Updated

You will learn to

  • Classify test data and choose the right source for each kind
  • Generate unique, isolated data with factories, builders and Faker
  • Write data-driven tests in Playwright and JUnit 5
  • Plan cleanup and protect personal data in test environments

Prerequisites

  • Framework Architecture
  • API Automation with Playwright

Ask a team why their end-to-end suite is flaky and the answer is often not the tool or the locators but the data. Two tests edit the same user. A test assumes an order exists that someone deleted yesterday. A run in CI collides with a run on a colleague’s laptop. Test data management is the discipline of making sure every test has exactly the data it needs, in a known state, without interfering with anything else.

Kinds of test data

Not all data should be handled the same way. A useful classification:

Kind Example Source Who may change it
Reference data Countries, currencies, product categories Loaded with the environment Nobody during a test run
Seed data A known admin user, a fixed catalogue Scripted seed loaded on deploy Nobody; read-only for tests
Test-owned data The customer and order a checkout test uses Created by the test, usually via API Only the test that created it
Boundary and invalid data Empty strings, 81-character names, negative quantities Designed by the tester n/a (sent, not stored)
Volume data 100,000 orders for a performance test Generated in bulk Performance environment only

The single most valuable rule: tests read reference and seed data, and own everything they change. A test that modifies seed data will eventually break another test.

The SDETRole Practice API is a useful illustration of seed data: 25 users, 10 products and two orders that never change, because the API validates writes but does not store them. Tests can rely on “user 1 is Priya Shah, a tester” forever. On a real system you would get that stability only for data nobody is allowed to modify.

Where test-owned data should come from

In order of preference for functional tests:

  1. The public API: fast, exercises real validation, and does not depend on database internals.
  2. A test-only API or seeding endpoint: when the public API cannot create a state (for example, an order that is already shipped). Make sure it is disabled outside test environments.
  3. Direct database inserts: fast, but couple tests to the schema and can create states the application itself could never produce.
  4. The UI: only when the UI flow is what you are testing.

Unique by default

Parallel workers, parallel CI jobs and other people share environments. Data that is unique only within one run is not unique enough. Build uniqueness into every generated value:

// src/data/unique.ts
import { randomUUID } from 'node:crypto';

const runId = process.env.TEST_RUN_ID ?? randomUUID().slice(0, 8);
let counter = 0;

export function uniqueEmail(prefix = 'user'): string {
  counter += 1;
  return `${prefix}.${runId}.${process.pid}.${counter}@example.com`;
}

A run identifier (from CI, or random locally) plus the process id plus a counter guarantees no collisions between workers or runs, and makes it easy to find everything a run created when debugging. Using the reserved example.com domain ensures no real person ever receives an email from your tests.

Factories and builders

Hand-writing full payloads in every test hides what the test is actually about. A factory produces a valid object with sensible defaults; the test overrides only what matters:

// src/data/user-factory.ts
import { faker } from '@faker-js/faker';
import { uniqueEmail } from './unique';

export type Role = 'admin' | 'tester' | 'developer' | 'viewer';

export interface NewUser {
  name: string;
  email: string;
  role: Role;
}

export function aUser(overrides: Partial<NewUser> = {}): NewUser {
  return {
    name: faker.person.fullName(),
    email: uniqueEmail('user'),
    role: 'tester',
    ...overrides,
  };
}
import { test, expect } from '@playwright/test';
import { aUser } from '../src/data/user-factory';

test('viewer can be created', async ({ request }) => {
  const input = aUser({ role: 'viewer' });

  const res = await request.post('users', { data: input });

  expect(res.status()).toBe(201);
  expect(await res.json()).toMatchObject(input);
});

The test now says “a viewer user” and nothing else; the name and email are clearly incidental. For objects with many optional parts (an order with items, discounts and shipping), a builder with chained methods such as anOrder().withItem('KB-101', 2).shippedTo('IN').build() reads even better.

Generated names are also a cheap source of realism: Faker produces apostrophes, hyphens and accented characters that regularly expose encoding and validation bugs. When you want those cases deliberately, design them, using the Boundary Value Calculator and Test Data Generator.

Data-driven tests

When the same behaviour must hold for many inputs, separate the data from the test logic. In Playwright, generate one test per row:

const invalidUsers = [
  { title: 'name too short', body: { name: 'A', email: 'short.name@example.com' }, field: 'name' },
  { title: 'invalid email', body: { name: 'Valid Name', email: 'not-an-email' }, field: 'email' },
  { title: 'unknown role', body: { name: 'Valid Name', email: 'role.x@example.com', role: 'superuser' }, field: 'role' },
  { title: 'unexpected field', body: { name: 'Valid Name', email: 'extra.x@example.com', age: 30 }, field: 'age' },
];

for (const { title, body, field } of invalidUsers) {
  test(`POST /users rejects ${title}`, async ({ request }) => {
    const res = await request.post('users', { data: body });
    expect(res.status()).toBe(422);
    const { errors } = await res.json();
    expect(errors.map((e: { field: string }) => e.field)).toContain(field);
  });
}

Each row is a separate test with its own name in the report, so one failure does not hide the others. In JUnit 5:

@ParameterizedTest(name = "{0} -> {1}")
@CsvSource({
    "A,                 short.name@example.com, name",
    "Valid Name,        not-an-email,           email",
})
void rejectsInvalidUsers(String name, String email, String field) {
    given().spec(Specs.PRACTICE_API)
        .body("""
            {"name": "%s", "email": "%s"}
            """.formatted(name, email))
    .when().post("/users")
    .then().statusCode(422)
        .body("errors.field", hasItem(field));
}

@CsvSource trims surrounding whitespace by default, which lets you align columns for readability. Larger tables belong in a CSV file with @CsvFileSource, or a JSON file read by the Playwright spec.

Cleanup

Data that tests create must not accumulate forever: it slows environments, confuses manual testers and can change the behaviour of later tests (search results, pagination counts). Options, from most to least precise:

  • Fixture teardown: the fixture that created the data deletes it after use, even when the test fails.
  • Tagging and sweeping: everything created carries the run id (as in the emails above); a scheduled job deletes tagged data older than a day.
  • Ephemeral environments: a fresh database per pipeline run (for example with containers), thrown away afterwards. The cleanest option when the system allows it.
  • Transaction rollback: for integration tests that talk to the database directly, run each test in a transaction and roll it back.
// a fixture that creates a user and always cleans up
export const test = base.extend<{ tempUser: User }>({
  tempUser: async ({ request }, use) => {
    const res = await request.post('users', { data: aUser() });
    expect(res.status()).toBe(201);
    const user: User = await res.json();

    await use(user);

    await request.delete(`users/${user.id}`); // runs even if the test failed
  },
});

Teardown should not fail the test if the data is already gone; log and continue.

Privacy and production data

Copying production data into test environments is tempting because it is realistic. It is also, in most organisations, a data protection problem: personal data in test systems usually has weaker access controls, ends up in logs, screenshots and bug reports, and may breach regulations such as GDPR.

The same applies to secrets: credentials are configuration, not test data, and belong in a secret store, not in CSV files.

Practice exercise

Design the test data approach for an “order history” page that lists a customer’s orders, newest first, 10 per page. Tests must run in parallel on a shared staging environment. Cover: which data is seed vs test-owned, how each test gets a customer with a known number of orders, how you make values unique, and how you clean up. Look at the Practice API’s /orders/1001 in the API Playground for a realistic order shape.

Show a model answer
  • Seed data (read-only): the product catalogue, shipping methods and currencies. Tests reference products by SKU, such as KB-101.
  • Test-owned data: each test creates its own customer through the API, with uniqueEmail('orders'), then creates the exact number of orders it needs (0, 1, 10, 11) through a seeding endpoint that can set createdAt, so “newest first” can be checked with known timestamps.
  • Factories: aCustomer() and anOrder().forCustomer(id).withItem('KB-101', 1).placedAt(date).build(), with defaults for everything else.
  • Parallel safety: no test touches another test’s customer, so counts and ordering are deterministic even with many workers. Tests never assert on global counts.
  • Key cases: 0 orders (empty state), exactly 10 (one page, no “next”), 11 (two pages, the 11th on page 2), and two orders with the same timestamp (stable tie-breaking).
  • Cleanup: a customerWithOrders fixture deletes the customer (cascading to orders) after use; a nightly job removes anything tagged with a run id older than 24 hours, in case teardown was skipped by a crashed run.

Key takeaways

  • Classify data: reference and seed data is read-only; test-owned data is created by, and belongs to, one test.
  • Create test-owned data through the API by default, and make every value unique across runs and workers.
  • Use factories and builders so tests state only the data that matters; seed and log random generators for reproducibility.
  • Separate inputs from logic with data-driven tests, one reported test per row.
  • Plan cleanup (teardown, tagging, ephemeral environments) and never put real personal data or secrets in tests.

Key terms

Finished this lesson?

Progress is saved in this browser. No account needed.

Type to search across SDETRole.

Full-text search by Pagefind · runs in your browser Open search page →