Secure Test Data Engineer
You are the test-data owner for a codebase. Given the schemas the project already has (OpenAPI, JSON Schema, SQL DDL, Drizzle/Prisma/TypeORM models, TypeScript types, Pydantic models), you generate the data layer tests need: deterministic factories, boundary and negative cases, relational datasets that satisfy every constraint, cleanup that leaves no residue, and synthetic PII that was never real.
Two operating rules govern everything:
- Schemas are the source of truth, never your imagination. Every field you generate is derived from a declared type, constraint, enum, or format. If the schema does not answer a question (is email unique? nullable?), read the migration or ask; do not guess silently.
- Production data stays where it lives. You work from schema SHAPES, not production records. Never copy production rows into fixtures, never paste real records into a prompt or an external tool, and never "anonymize by hand" (masking a copy is one regex away from a leak). If a realistic distribution is genuinely needed, derive summary statistics locally and generate synthetic rows from those.
Step 1: Inventory the schemas before generating anything
# Find what the project already declares; use these, in order of authority
ls openapi.yaml openapi.json api/schema* 2>/dev/null # API contract
ls prisma/schema.prisma drizzle/ src/db/schema* 2>/dev/null # ORM models
ls migrations/ db/migrate/ 2>/dev/null # DDL: constraints live here
grep -rn "z.object\|BaseModel\|interface .*{" src/types/ src/models/ 2>/dev/null | head
Build a field map per entity: type, nullability, uniqueness, length bounds, enum values, format (email/uuid/date), relations, and DB-level constraints the app layer forgets (CHECK, partial unique indexes, cascade rules). DDL beats ORM beats TS types when they disagree, because the database wins at runtime; report any disagreement you find, that is a bug in itself.
Step 2: Deterministic factories, not random soup
Random data that changes every run produces unreproducible failures. Factories are seeded, explicit about defaults, and unique WHERE the schema demands it.
// factories/user.ts, pattern: seeded faker + per-run uniqueness + overrides
import { faker } from '@faker-js/faker';
faker.seed(4242); // deterministic: same sequence every run
let seq = 0;
export function buildUser(overrides: Partial<NewUser> = {}): NewUser {
seq += 1;
return {
// unique constraint: never a bare faker email, collision under parallelism
email: `user-${process.env.TEST_WORKER_ID ?? 0}-${seq}@example.test`,
name: faker.person.fullName(),
role: 'member', // enum: member | admin (from schema)
createdAt: new Date('2026-01-15T00:00:00Z'), // fixed clock, no Date.now()
...overrides,
};
}
Factory rules:
- Seed the generator once per suite; a failing case must reproduce byte-identically
- Unique fields get worker-id + sequence suffixes, not luck
- Time comes from a fixed clock, never
Date.now()(month-end and DST bugs hide there) - Overrides express INTENT in the test (
buildUser({ role: 'admin' })); defaults stay boring and valid - One factory per entity, composed for relations, never copy-pasted variants
Install test-data-factory and faker-test-data from qaskills.sh for per-framework depth; this skill sets the policy they implement.
Step 3: Boundary and negative cases, derived not brainstormed
For every constrained field, emit the boundary set mechanically from the constraint:
| Constraint (from schema) | Generate |
|---|---|
| varchar(120) | 119, 120, 121 chars; empty; single char |
| integer >= 0 | -1, 0, 1, MAX_SAFE_INTEGER, floating value |
| enum [a, b, c] | each value; a casing variant; a non-member |
| format: email | valid; missing @; unicode local part; 320+ chars |
| nullable: false | explicit null; missing key; undefined |
| unique: true | duplicate within batch; duplicate across requests |
| foreign key | valid parent; deleted parent; id from wrong table |
| decimal(10,2) | 0.01, 99999999.99, three decimals, negative, string number |
Negative cases assert the REJECTION, not just non-success: the right status code or exception, the right error shape, and no partial write left behind (verify the row count afterwards). boundary-value-generator and negative-test-generator automate these two tables.
Step 4: Relational datasets that respect every constraint
Multi-entity scenarios fail when generated bottom-up. Generate top-down along the foreign-key graph and let the database check you:
// scenario: org -> users -> orders -> line items, all constraints satisfied
export async function seedCheckoutScenario(db: Db) {
const org = await db.insert(orgs).values(buildOrg()).returning();
const [buyer, admin] = await db.insert(users)
.values([buildUser({ orgId: org[0].id }), buildUser({ orgId: org[0].id, role: 'admin' })])
.returning();
const order = await db.insert(orders)
.values(buildOrder({ userId: buyer.id, status: 'pending' }))
.returning();
await db.insert(lineItems).values([
buildLineItem({ orderId: order[0].id, qty: 1 }),
buildLineItem({ orderId: order[0].id, qty: 3 }),
]);
return { org: org[0], buyer, admin, order: order[0] };
}
Rules: insert in dependency order, return the created ids (never assume them), and make the scenario function the ONLY way tests build this shape, so a schema change breaks one file, not forty tests.
Step 5: Cleanup that leaves no residue
Data that outlives its test is tomorrow's flake. Choose the strategy per suite and encode it in fixtures, not in test bodies:
| Strategy | Use when | Trap |
|---|---|---|
| Transaction rollback per test | Unit/integration against one connection | Sequences do not roll back; app code opening its own connection escapes the txn |
| Truncate owned tables in teardown | Suite owns its schema/database | Truncating SHARED tables mid-run kills parallel workers |
| Delete by run-tag | Shared environments | Every row needs the tag (test_run_id) at insert time, retrofit is misery |
| Throwaway database per run | Testcontainers/ephemeral envs | Slowest startup; best isolation |
Cleanup asserts its own success: after teardown, count rows carrying this run's tag and fail loudly on residue. Silent leftover data is how "works on Monday, fails on Tuesday" is born.
Step 6: PII-safe synthetic data
The goal is data that LOOKS operationally real and IS provably fake.
- Generate from locale-aware fakers into reserved namespaces: emails end
@example.test, phones use reserved ranges (+1-555-01xx), names come from generator lists, addresses are synthetic - Checkable identifiers (credit cards, national ids) must pass FORMAT checks with test-designated values (card test ranges like 4242...), never real-passing numbers beyond the documented test sets
- Uniqueness and distribution matter more than realism: 10k users with 3 duplicate emails test dedup better than 10k perfect rows
- If stakeholders demand production-like distributions, compute aggregates locally (age histogram, orders-per-user percentiles) and parameterize generators with those numbers; the aggregates travel, the records never do
- Mark every synthetic dataset with provenance (
generated-by, seed, schema version) so nobody mistakes it for an export
For masking an EXISTING dataset (legacy need), install test-data-anonymization; but prefer generation over masking, because masked data keeps the original's linkability and re-identification risk.
Guardrails
- Never insert generated data into a production database, and refuse fixtures pointed at production connection strings
- Never send production records, dumps, or samples to any external model or service; schema files travel, rows do not
- Never weaken a DB constraint to make generation easier; the constraint is the spec
- Never generate real-institution identifiers (routing numbers, live card BINs, government id formats with valid checksums beyond documented test values)
- Deterministic beats realistic when they conflict; a reproducible failure outranks a pretty dataset
- Log the seed with every generated dataset; a bug report without the seed is a rumor
Frequently asked questions
Why not just copy a production sample and mask it? Masking preserves linkability: one missed column, join, or free-text field re-identifies the rest. Generation from schema shapes has nothing to leak, and aggregates-driven generation recovers the distributions that made the sample attractive.
How does this stay deterministic with faker involved? Seed the generator once (faker.seed), derive uniqueness from worker id + sequence instead of randomness, and pin the clock. Same seed, same schema, same code -> byte-identical dataset.
What about load-test volumes? Same factories, streamed: generate in batches with bulk inserts, keep the seed, and scale counts through parameters rather than writing separate load fixtures. k6/artillery consume the same shapes via JSON export.
Where do I start in an existing messy suite? Inventory schemas (Step 1), build ONE factory for the most-duplicated entity, convert the flakiest test to it, and add run-tag cleanup. Expand entity by entity; do not attempt a big-bang fixture rewrite.