Quality is not optional. It's our standard. Free QA tools for testers and developers.

How to Create Test Data Without Copying Production Records

How to Create Test Data Without Copying Production Records

Generating realistic, safe, and maintainable test data is a core skill for any QA engineer. This guide walks through the decisions, tooling, and validation steps you need to build a repeatable data‑creation pipeline—without ever pulling a row from production.


1. Why “Just Copy Production” Is a Bad Idea

RiskWhat HappensTypical Impact
Data‑privacy breachPII, payment info, health records leave the controlled environmentLegal fines, loss of customer trust
Schema driftProduction adds columns, changes types, drops tablesTests break silently or pass with stale assumptions
Volume mismatchProduction holds millions of rows; test env holds thousandsPerformance tests give false confidence
State couplingTests depend on a specific order ID that later disappearsFlaky tests, hard‑to‑debug failures
Regulatory non‑complianceGDPR, CCPA, HIPAA forbid using real data in non‑prodAudit findings, mandatory remediation

Bottom line: Copying production is a shortcut that creates long‑term technical debt and legal exposure. The goal is synthetic data that mimics the shape, distribution, and relationships of real data—without any real records.


2. Prerequisites Before You Generate Anything

PrerequisiteHow to VerifyWhy It Matters
Schema source of truthVersion‑controlled DDL (SQL, Prisma, Django migrations, OpenAPI)Guarantees generators stay in sync with the DB
Data‑model documentationER diagram, data‑dictionary, or a living markdown fileHelps you decide which columns need realistic values vs. placeholders
Constraint inventoryPrimary keys, foreign keys, unique indexes, check constraints, triggersPrevents generation of rows that violate DB rules
Distribution baselinesHistograms or quantiles for key columns (e.g., order_total, user_age)Lets you tune generators to realistic spreads
Environment paritySame DB engine, version, and extensions as productionAvoids “works on MySQL 8, fails on Postgres 13” surprises
CI/CD hook pointA stage that can run a script or container before test executionMakes data generation part of the pipeline, not a manual step

Tip: Store the schema and constraint inventory in a single repo (e.g., db/). Treat it like code—review, test, and version it.


3. Decision Matrix: Choosing a Generation Strategy

StrategyWhen It ShinesTooling ExamplesMain Trade‑offs
Pure synthetic (random + rules)Greenfield projects, unit/integration tests, need full controlfactory_boy, Faker, go-faker, QA3 Test Data Generator (/tools/test-data-generator)Requires explicit rule authoring; may miss hidden correlations
Statistical modeling (copulas, GANs, CTGAN)Large existing datasets where you can train a model offlinesdv, ydata-synthetic, MOSTLY AIHeavy compute, model drift, black‑box debugging
Data masking / anonymizationYou must keep some production shape (e.g., exact FK graph) but cannot expose PIIpg_anonymizer, DataVeil, TonicStill touches production data; requires secure pipeline
Subset + synthetic enrichmentLegacy systems where a small production slice is acceptable after maskingCustom scripts + masking toolsComplexity of keeping referential integrity across subsets
Fixture / snapshot filesVery small, static test suites (e.g., contract tests)JSON/YAML fixtures, pytest-fixtureNot scalable; brittle when schema changes

Rule of thumb: Start with pure synthetic generation. Add statistical modeling only when you have a proven need for production‑like distributions (e.g., load‑testing a recommendation engine).


4. Workflow: From Schema to Ready‑to‑Run Test Data

flowchart TD
    A[Schema & Constraints] --> B[Define Generation Rules]
    B --> C[Run Generator (CI)]
    C --> D[Validate Output]
    D -->|Pass| E[Load into Test DB]
    D -->|Fail| B
    E --> F[Run Test Suite]

4.1 Define Generation Rules

  1. Map each column → generator

    • Primitive types → Faker providers (name, email, uuid, date_time).
    • Enums / status fields → weighted choice ([('new',0.6),('shipped',0.3),('cancelled',0.1)]).
    • Numeric ranges → distribution (uniform, normal, log‑normal).
    • Foreign keys → reference another generated table (see 4.2).
  2. Express cross‑column constraints

    • order_total = sum(line_items.price * qty) → post‑generation calculator.
    • shipping_date >= order_date → conditional generator.
  3. Version the rule file (YAML/JSON) alongside schema migrations.

4.2 Preserve Referential Integrity

ApproachDescriptionExample
Topological orderGenerate parent tables first, then children using the IDs just created.users → addresses → orders → order_items
Deferred FK resolutionGenerate all rows with placeholder UUIDs, then run a second pass to replace placeholders with real IDs.Useful when cycles exist (e.g., manager_id on employees).
Deterministic IDsUse a hash of a natural key (md5(email+role)) so the same logical row always gets the same PK across runs.Guarantees repeatable test data for snapshot testing.

4.3 Run Generator in CI



# .github/workflows/test-data.yml


name: Generate Test Data
on:
  push:
    paths:
      - 'db/**'
      - 'test-data/rules/**'
jobs:
  generate:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: pwd
        ports: ["5432:5432"]
    steps:
      - uses: actions/checkout@v4
      - name: Install generator
        run: pip install qa3-test-data-generator   # <-- QA3 free tool
      - name: Generate
        run: |
          qa3-test-data-gen \
            --schema db/schema.sql \
            --rules test-data/rules.yaml \
            --out test-data/generated.sql
      - name: Load into test DB
        run: psql -h localhost -U postgres -d testdb -f test-data/generated.sql
      - name: Run test suite
        run: pytest -q

Why CI?

  • Guarantees the same data for every PR.
  • Catches schema‑rule drift early (failed validation stops the pipeline).

4.4 Validate Output

Validation LayerTool / TechniqueWhat It Catches
Schema conformancepg_dump --schema-only diff, or sqlfluff lintMissing columns, type mismatches
Constraint satisfactionRun SET CONSTRAINTS ALL IMMEDIATE; then SELECT * FROM information_schema.table_constraintsFK violations, duplicate PKs
Statistical sanityCustom script: compare histograms of generated vs. baseline (Kolmogorov‑Smirnov test)Skewed distributions, out‑of‑range values
Business rule checksAssert order_total == sum(line_items) in a post‑load SQL scriptLogic bugs in generators
Performance smokeEXPLAIN ANALYZE SELECT * FROM orders WHERE status='shipped';Missing indexes, data volume issues

Automate all of the above as a single validation job that fails fast.


5. Worked Example: E‑Commerce Order Domain

5.1 Schema (PostgreSQL)

CREATE TABLE users (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email         TEXT NOT NULL UNIQUE,
    full_name     TEXT NOT NULL,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    role          TEXT NOT NULL CHECK (role IN ('customer','admin'))
);


CREATE TABLE products (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    sku         TEXT NOT NULL UNIQUE,
    name        TEXT NOT NULL,
    price_cents INT NOT NULL CHECK (price_cents > 0),
    active      BOOLEAN NOT NULL DEFAULT true
);


CREATE TABLE orders (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id       UUID NOT NULL REFERENCES users(id),
    status        TEXT NOT NULL CHECK (status IN ('new','paid','shipped','cancelled')),
    total_cents   INT NOT NULL CHECK (total_cents >= 0),
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    shipped_at    TIMESTAMPTZ
);


CREATE TABLE order_items (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    order_id    UUID NOT NULL REFERENCES orders(id),
    product_id  UUID NOT NULL REFERENCES products(id),
    qty         INT NOT NULL CHECK (qty > 0),
    unit_price_cents INT NOT NULL CHECK (unit_price_cents > 0)
);

5.2 Generation Rules (YAML)



# test-data/rules.yaml


tables:
  users:
    count: 500
    columns:
      email:        "{{unique.email}}"
      full_name:    "{{name}}"
      role:         "{{choice: [customer:0.95, admin:0.05]}}"
      created_at:   "{{date_time_between: ['-2y', 'now']}}"


products:
    count: 200
    columns:
      sku:          "{{bothify: 'SKU-####-??'}}"   # unique enforced by DB
      name:         "{{commerce.product_name}}"
      price_cents:  "{{random_int: [100, 50000]}}"   # $1 – $500
      active:       "{{choice: [true:0.9, false:0.1]}}"


orders:
    count: 1000
    columns:
      user_id:      "{{ref: users.id}}"               # FK reference
      status:       "{{choice: [new:0.2, paid:0.5, shipped:0.25, cancelled:0.05]}}"
      created_at:   "{{date_time_between: ['-1y', 'now']}}"
      shipped_at:   "{{conditional: 
                         if: 'status == \"shipped\"'
                         then: '{{date_time_between: [created_at, now]}}'
                         else: 'NULL'}}"
      # total_cents will be calculated in post‑process step


order_items:
    count: 3000
    columns:
      order_id:     "{{ref: orders.id}}"
      product_id:   "{{ref: products.id}}"
      qty:          "{{random_int: [1,5]}}"
      unit_price_cents: "{{ref: products.price_cents}}"

5.3 Post‑Process Script (Python)



# test-data/post_process.py


import sqlalchemy as sa
from sqlalchemy.orm import Session


engine = sa.create_engine("postgresql://postgres:pwd@localhost/testdb")
with Session(engine) as sess:
    # 1. Compute order totals
    sess.execute(sa.text("""
        UPDATE orders o
        SET total_cents = sub.total
        FROM (
            SELECT order_id, SUM(qty * unit_price_cents) AS total
            FROM order_items
            GROUP BY order_id
        ) sub
        WHERE o.id = sub.order_id;
    """))
    # 2. Enforce shipped_at only for shipped orders
    sess.execute(sa.text("""
        UPDATE orders
        SET shipped_at = NULL
        WHERE status <> 'shipped';
    """))
    sess.commit()

5.4 Validation Checks (SQL)

-- 1. No orphan order_items
SELECT COUNT(*) FROM order_items oi
LEFT JOIN orders o ON oi.order_id = o.id
WHERE o.id IS NULL;   -- expect 0


-- 2. Order total matches line items
SELECT o.id
FROM orders o
JOIN (
    SELECT order_id, SUM(qty * unit_price_cents) AS calc_total
    FROM order_items
    GROUP BY order_id
) li ON o.id = li.order_id
WHERE o.total_cents <> li.calc_total;   -- expect 0 rows


-- 3. Status distribution sanity
SELECT status, COUNT(*) FROM orders GROUP BY status;

Running the CI pipeline with the above artifacts produces a fully referentially‑intact, statistically‑reasonable dataset in ~30 seconds on a modest GitHub Actions runner.


6. Common Failure Modes & How to Detect Them Early

Failure ModeSymptomDetection PointMitigation
Rule drift (schema changed, rules not updated)INSERT errors on new NOT NULL columnCI validation job (schema conformance)Enforce schema‑rule PR gate: any migration must include a rule diff
FK cycles (e.g., manager_id on employees)Generator hangs or produces NULL FKTopological sort step logs “cycle detected”Use deferred resolution or deterministic UUIDs
Distribution collapse (all status = 'new')Load test shows unrealistic throughputStatistical sanity check (KS test)Add weighted choices; periodically re‑sample from production histograms (offline)
Data volume explosion (10 M rows generated for unit tests)CI timeout, OOMRow‑count guard in generator config (max_rows_per_table)Separate unit vs load data profiles
Non‑deterministic IDs causing flaky snapshot testsSnapshot diff on every runDeterministic ID rule (hash(email+role))Lock IDs for snapshot suites; keep random for exploratory tests
Masking leakage (real email appears in synthetic data)Security audit flags PIIPost‑generation PII scanner (regex for email, SSN)Never feed production data into generator; use only schema + rules

7. Scaling the Approach

ScaleRecommended Adjustments
< 10 tables, < 5 k rowsSingle YAML rule file, run in CI container
10‑100 tables, 100 k‑1 M rowsSplit rules per domain, parallelize generation with make -j or airflow DAG
> 100 tables, > 1 M rowsUse a dedicated data‑factory service (e.g., a small Flask app that streams rows via COPY), store generated CSV/Parquet in object storage, load with COPY FROM
Multi‑region test clustersGenerate once, snapshot the DB (pg_basebackup / RDS snapshot), share snapshot across regions

8. Checklist: Before You Ship a New Data‑Generation Pipeline

  • Schema and constraints are version‑controlled and reviewed.
  • Generation rules cover every column (no “default” fall‑backs).
  • Referential integrity strategy documented (topological vs deferred).
  • Deterministic IDs for any snapshot‑tested tables.
  • Post‑generation validation job runs every CI build.
  • Statistical sanity thresholds defined (e.g., KS‑p > 0.05).
  • PII scanner integrated in validation step.
  • Separate unit and load data profiles with row‑count limits.
  • Documentation includes “how to add a new table” and “how to tweak a distribution”.
  • [

Read more

Test Data Generation Strategy: From Requirements to Reusable Datasets

A ready-to-use companion to “Test Data Generation Strategy: From Requirements to Reusable Datasets,” including decision points, examples, ownership guidance, and review criteria.

Test Data Generator Checklist for QA Teams

A ready-to-use companion to “Test Data Generator Checklist for QA Teams,” including decision points, examples, ownership guidance, and review criteria.

Best Test Data Generation Tools: 12 Features to Compare

A buyer-focused comparison for “Best Test Data Generation Tools: 12 Features to Compare,” with concrete selection criteria, trade-offs, and an evaluation path QA teams can use.