by thetestingacademy
Generate test data from the schemas you already have. Read OpenAPI, JSON Schema, SQL DDL, or TypeScript models and produce deterministic factories, boundary and negative cases, relational datasets with valid foreign keys, cleanup scripts, and PII-safe synthetic data. Production records never leave the machine.
npx @qaskills/cli add secure-test-data-engineerAuto-detects your AI agent and installs the skill. Works with Claude Code, Cursor, Copilot, and more.
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:
# 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.
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:
Date.now() (month-end and DST bugs hide there)buildUser({ role: 'admin' })); defaults stay boring and validInstall test-data-factory and faker-test-data from qaskills.sh for per-framework depth; this skill sets the policy they implement.
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.
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.
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.
The goal is data that LOOKS operationally real and IS provably fake.
@example.test, phones use reserved ranges (+1-555-01xx), names come from generator lists, addresses are syntheticgenerated-by, seed, schema version) so nobody mistakes it for an exportFor 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.
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.
- name: Install QA Skills
run: npx @qaskills/cli add secure-test-data-engineer12 of 29 agents supported
Go from zero to Playwright pro: Page Object Model, fixtures, and CI/CD on real projects.
Use code PROMODE at checkout