38Test Data Management (TDM)
What you will master here
- Why TDM matters and what bad TDM looks like
- Strategies: refresh, subset, synthetic, masked
- Data factories & builders in code
- PII masking / anonymisation rules
- Per-tenant / per-test isolation patterns
- Seeding via API / SQL / fixture files
- Cleanup, leases, and "noisy neighbour" prevention
38.1 Bad TDM looks like…
- Tests fail Monday because "the QA data got refreshed over the weekend"
- Engineer A's test depends on User #42 that Engineer B deleted
- A flaky test only flakes after lunch because two engineers happened to run it concurrently
- Half the suite breaks because someone changed the seed
- Personal data of real customers ends up in the test environment
38.2 The four classical TDM strategies
| Strategy | What it is | Pros | Cons |
|---|---|---|---|
| Production clone | Copy of prod DB | Realistic data | PII exposure, expensive to refresh, hard to mutate |
| Production subset + mask | Selected rows, sensitive fields masked | Realistic shape, safer | Masking pipeline to maintain |
| Synthetic generation | Faker-style fake but realistic-looking data | Safe, on-demand, infinite | May miss real-world weirdness |
| Hybrid | Synthetic for most, masked-prod for hard cases | Best of both | Most plumbing |
38.3 Per-test factories (code-driven TDM)
// src/factories/userFactory.ts
import { faker } from '@faker-js/faker';
import type { APIRequestContext } from '@playwright/test';
export interface User { id: string; email: string; name: string; role: 'admin' | 'user'; }
export function userPayload(over: Partial<User> = {}) {
return {
email: faker.internet.email({ provider: 'qa.acme.test' }), // never @gmail.com
name: faker.person.fullName(),
role: 'user' as const,
...over,
};
}
export async function createUser(api: APIRequestContext, over: Partial<User> = {}): Promise<User> {
const res = await api.post('/test/users', { data: userPayload(over) });
if (!res.ok()) throw new Error(`createUser failed: ${res.status()}`);
return res.json();
}
export async function deleteUser(api: APIRequestContext, id: string) {
await api.delete(`/test/users/${id}`);
}
Use in a fixture:
export const test = base.extend<{ user: User }>({
user: async ({ request }, use) => {
const u = await createUser(request);
await use(u);
await deleteUser(request, u.id);
},
});
38.4 Builder pattern for complex objects
class OrderBuilder {
private order: Partial<Order> = { items: [], status: 'pending' };
withUser(u: User) { this.order.userId = u.id; return this; }
withItem(sku: string, qty=1){ this.order.items!.push({ sku, qty }); return this; }
withCoupon(code: string) { this.order.coupon = code; return this; }
withStatus(s: OrderStatus) { this.order.status = s; return this; }
async build(api: APIRequestContext): Promise<Order> {
return (await api.post('/test/orders', { data: this.order })).json();
}
}
const order = await new OrderBuilder()
.withUser(user).withItem('SHOE-42', 2).withCoupon('TEST50').build(request);
38.5 Per-tenant isolation
Each parallel test slot gets its own tenant (a top-level data namespace). Tests within a slot share the tenant safely; tests across slots can't collide.
// fixtures.ts
export const test = base.extend<{}, { tenant: string }>({
tenant: [async ({}, use, info) => {
const t = `qa-slot-${info.parallelIndex}`;
await use(t);
}, { scope: 'worker' }],
});
The factory / API call includes tenant in headers; the backend scopes data accordingly.
38.6 PII masking — non-negotiable for prod-derived data
| Field | Mask example |
|---|---|
user-7a3f@masked.test | |
| Name | Faker fake name with consistent locale |
| Phone | Format kept (digits → 9), area code retained |
| Credit card | 4242424242424242 (Stripe test PAN) or all 9s |
| SSN / Aadhaar | Random with matching format + checksum |
| Address | Random street, real city retained |
| DOB | Shift by random days while preserving age bucket |
Tools: Tonic.ai, Delphix, GDPR-mask, custom Python pipeline. Whatever you pick: keep the mapping deterministic within a refresh (the same prod email always maps to the same test email) — otherwise referential integrity breaks.
38.7 Cleanup & leases
- Per-test factories should clean up via teardown (covered above).
- For long-lived seeds (one user per slot), use leases: "this slot owns user X for the next 1 hour". Expire on TTL so crashed runs don't lock data forever.
- Nightly job: delete all
qa-slot-*records older than 7 days — catches leaks.
38.8 Time-sensitive data
- Frozen clocks — backend exposes a test-only endpoint to set "now". Tests asserting on relative times (e.g. "expires in 2 days") need this.
- Schedule fast-forward — endpoint to advance time (cron triggers, expiry).
- Playwright clock API —
page.clock.install()+page.clock.setSystemTimefreeze the browser clock for client-side date logic.
38.9 Common SDET TDM mistakes
- Hardcoded ids that "always work" — until they don't (env reset, parallel run)
- Reusing the same email and getting 409 in parallel tests
- Manual cleanup in
afterAllthat doesn't run when the test crashes — usetry/finallyor fixture teardown - Relying on database snapshots that drift from real schema
- Treating test users as "real" data — they should be ephemeral, tagged, and trivially deletable
Module 31 — TDM Q&A bank
What's the goal of test data management?
Provide every test with the data it needs — correct shape, isolated from other tests, realistic enough to exercise the system — without manual intervention. Bad TDM is the most common silent killer of test reliability.
Should you use production data for testing?
Only if every PII field is masked deterministically and consistently across tables. Even then, it's regulated (GDPR, CCPA) and expensive to refresh. Default to synthetic data; reach for prod-derived only when realism matters and your security/legal sign-off allows it.
What's a data factory?
A function (or builder) that returns a valid default object with optional overrides.
userFactory({ role: 'admin' }) creates a user with default fields and admin role. Keeps tests focused on intent ("a user", "an admin") not data plumbing.When is the Builder pattern better than a factory function?
When the object is complex with many optional fields and the construction order matters.
new OrderBuilder().withUser(u).withItem(sku).withCoupon(c).build() reads better than a 12-property object literal.How do you handle data isolation in parallel tests?
Either per-test creation+cleanup, or per-slot tenant (each parallelIndex owns one tenant). The latter is faster when data is expensive to create; the former is simpler when creation is cheap.
What's a lease in TDM?
A time-bound reservation of a shared data resource. Slot X leases user Y for 1 hour; if the lease isn't renewed (crash), the user becomes available again. Prevents dead-lock if a test process disappears.
How do you mask production data safely?
Apply deterministic, format-preserving masking: the same email always maps to the same masked email; numbers stay numeric; addresses stay valid-looking. Tools: Tonic.ai, Delphix, custom pipelines. Mask before the data leaves prod's security boundary.
How do you test time-dependent behaviour (e.g. expires in 30 days)?
Backend test-only endpoint to set/advance the clock. Plus Playwright's
page.clock API for browser-side time. Don't use real waiting; tests can't run for 30 days.What's the danger of relying on a shared "QA user account"?
Cross-test contamination — one test changes the user's state, another fails because of it. Race conditions in parallel runs. Hard-to-reproduce flakiness. Always prefer ephemeral per-test users.
How do you ensure test data doesn't pile up over time?
Per-test cleanup in fixture teardown. Nightly cleanup job deletes anything older than N days tagged with the QA marker. Monitoring on test DB size — alert if it grows.
How does TDM relate to test stability?
Most "flaky" tests are actually data-flaky: data left in an unexpected state by a previous test, or shared with a parallel test. Strict per-test isolation removes a huge class of intermittent failures.