DEV Community

Cover image for How to Build Reproducible Test Fixtures with Seeded Synthetic Data
Genory Team
Genory Team

Posted on

How to Build Reproducible Test Fixtures with Seeded Synthetic Data

Random test data is useful.

Until a test fails.

Then randomness can become your enemy.

Imagine a CI job fails with a generated dataset containing a specific combination of:

  • name
  • country
  • date
  • order value
  • status
  • identifier

You run the test again.

It passes.

Run it a third time.

It passes again.

The failure is gone because the generator created different data.

This is exactly where seeded synthetic data becomes useful.

A seed lets you keep the benefits of generated test data while making the output reproducible.

What does a seed actually do?

A deterministic generator starts with an initial value:

seed = 42
Enter fullscreen mode Exit fullscreen mode

The generator uses that value as the starting state for producing pseudo-random values.

If the algorithm and configuration remain the same:

seed = 42
Enter fullscreen mode Exit fullscreen mode

should produce the same sequence again.

Conceptually:

seed 42
  ↓
Anna
Hamburg
20095
€184.22
active
Enter fullscreen mode Exit fullscreen mode

Run it again with the same seed:

seed 42
  ↓
Anna
Hamburg
20095
€184.22
active
Enter fullscreen mode Exit fullscreen mode

Change the seed:

seed 43
Enter fullscreen mode Exit fullscreen mode

and you get a different dataset.

That makes the seed a compact way to describe an entire generated fixture.

Why this matters in CI

Consider a test like this:

test("checkout accepts generated customer data", async () => {
  const customer = generateCustomer();

  const result = await checkout(customer);

  expect(result.success).toBe(true);
});
Enter fullscreen mode Exit fullscreen mode

If generateCustomer() returns different values every time, a failure can be difficult to reproduce.

Instead, imagine:

const customer = generateCustomer({
  seed: "checkout-1842"
});
Enter fullscreen mode Exit fullscreen mode

Now your CI output can report:

Test failed.
Seed: checkout-1842
Enter fullscreen mode Exit fullscreen mode

A developer can run the test locally using exactly the same seed.

That turns:

"It failed once in CI."
Enter fullscreen mode Exit fullscreen mode

into:

"Run this exact dataset."
Enter fullscreen mode Exit fullscreen mode

That is a huge debugging improvement.

Randomness is not the same as unpredictability

Developers sometimes assume that test data should always be completely unpredictable.

Usually it should not.

Production systems may need cryptographically secure randomness.

Test fixtures usually need something else:

controlled variation.

You want datasets that differ enough to expose assumptions while still being reproducible when something breaks.

For testing, deterministic pseudo-randomness is often more useful than true randomness.

A simple seeded generator

Here is a deliberately small example:

function createRng(seed) {
  let state = seed >>> 0;

  return function () {
    state = (state * 1664525 + 1013904223) >>> 0;
    return state / 4294967296;
  };
}
Enter fullscreen mode Exit fullscreen mode

Now:

const random = createRng(12345);

console.log(random());
console.log(random());
console.log(random());
Enter fullscreen mode Exit fullscreen mode

Calling the same function again with:

createRng(12345)
Enter fullscreen mode Exit fullscreen mode

produces the same sequence.

For real applications, you would normally use a mature deterministic PRNG library rather than inventing one.

The important idea is the deterministic relationship:

same seed + same algorithm = same sequence
Enter fullscreen mode Exit fullscreen mode

Build fixtures from the seeded source

Once you have a seeded random source, other values should derive from it.

For example:

function pick(random, values) {
  const index = Math.floor(random() * values.length);
  return values[index];
}
Enter fullscreen mode Exit fullscreen mode

Then:

const random = createRng(42);

const country = pick(random, ["DE", "FR", "NL"]);
const status = pick(random, ["active", "pending", "blocked"]);
Enter fullscreen mode Exit fullscreen mode

If you recreate the generator with seed 42, you should receive the same choices.

That is the foundation of reproducible synthetic data.

The whole pipeline must be deterministic

A common mistake is seeding only one part of the generator.

For example:

const random = seededRandom(seed);

const name = generatedFromSeed(random);
const email = generatedFromSeed(random);

const createdAt = new Date();
Enter fullscreen mode Exit fullscreen mode

The name and email are reproducible.

The timestamp is not.

If reproducibility matters, avoid hidden dynamic inputs such as:

current time
machine hostname
process ID
random UUID
timezone
environment-specific ordering
Enter fullscreen mode Exit fullscreen mode

Instead, make them explicit inputs.

For example:

generateFixture({
  seed: 42,
  now: "2026-10-05T12:00:00Z"
});
Enter fullscreen mode Exit fullscreen mode

Now both randomness and time are controlled.

Version your fixture generators

There is another subtle problem.

Imagine this configuration:

seed = 42
Enter fullscreen mode Exit fullscreen mode

generates:

{
  "name": "Anna Schneider",
  "country": "DE"
}
Enter fullscreen mode Exit fullscreen mode

Then six months later you improve your generator.

You add more names and change the country selection algorithm.

Now seed 42 might produce:

{
  "name": "Jonas Weber",
  "country": "NL"
}
Enter fullscreen mode Exit fullscreen mode

Technically, the seed stayed the same.

But the generator changed.

If you rely heavily on reproducibility, save the generator version too:

generatorVersion = 3
seed = 42
Enter fullscreen mode Exit fullscreen mode

A failure report can then contain:

Fixture generator: v3
Seed: 42
Enter fullscreen mode Exit fullscreen mode

That is much more reliable than storing only the seed.

Seeds should describe scenarios

You do not have to use numbers.

Readable seeds can make test output easier to understand.

For example:

checkout-basic
checkout-eu-user
checkout-large-order
import-invalid-address
profile-long-name
Enter fullscreen mode Exit fullscreen mode

Internally, the string can be hashed into a numeric seed.

This gives your tests meaningful names while preserving deterministic generation.

Instead of:

generateUser(91281);
Enter fullscreen mode Exit fullscreen mode

you can write:

generateUser("signup-german-user");
Enter fullscreen mode Exit fullscreen mode

The second version communicates intent.

Don't make every test depend on huge generated profiles

Synthetic-data systems can generate enormous records.

That does not mean every test should use them.

Suppose you are testing:

email normalization
Enter fullscreen mode Exit fullscreen mode

You probably need:

{
  "email": "Test.User@Example.com"
}
Enter fullscreen mode Exit fullscreen mode

You probably do not need:

{
  "name": "...",
  "address": "...",
  "phone": "...",
  "company": "...",
  "job": "...",
  "iban": "...",
  "device": "...",
  "orderHistory": []
}
Enter fullscreen mode Exit fullscreen mode

Large fixtures increase cognitive load.

Generate only what the test actually needs.

A useful principle is:

Small deterministic fixtures for unit tests, richer synthetic datasets for integration and system tests.

Separate normal fixtures from edge cases

Pure randomness is surprisingly bad at finding many important bugs.

A generator may happily produce normal data thousands of times without ever creating:

an empty string
a 255-character name
February 29
a negative value
a duplicate identifier
a zero-value order
a Unicode-heavy address
Enter fullscreen mode Exit fullscreen mode

So combine generated fixtures with deliberate edge cases.

For example:

const cases = [
  generateCustomer("normal-customer"),
  generateCustomer("another-customer"),
  {
    ...generateCustomer("long-name"),
    name: "A".repeat(255)
  }
];
Enter fullscreen mode Exit fullscreen mode

Randomness should complement scenario design, not replace it.

Keep relationships between fields deterministic

Realistic fixtures often contain dependent fields.

For example:

country = DE
city = Hamburg
postalCode = 20095
phonePrefix = +49
Enter fullscreen mode Exit fullscreen mode

A poor generator might independently produce:

country = DE
city = Toronto
postalCode = SW1A 1AA
phonePrefix = +81
Enter fullscreen mode Exit fullscreen mode

That may be useful for some validation tests.

But it is not a realistic German profile.

If your application depends on relationships between fields, derive those values from the same deterministic context.

Conceptually:

const country = generateCountry(random);

const address = generateAddress({
  random,
  country
});

const phone = generatePhone({
  random,
  country
});
Enter fullscreen mode Exit fullscreen mode

Now the fixture stays coherent while remaining reproducible.

Save the seed when reporting errors

If generated data is used in automated tests, always include the seed in failure output.

For example:

try {
  await runCheckoutTest(fixture);
} catch (error) {
  console.error(`Fixture seed: ${seed}`);
  throw error;
}
Enter fullscreen mode Exit fullscreen mode

Your CI logs might then show:

FAIL checkout.integration.test.js

Expected: 200
Received: 500

Fixture seed: checkout-regression-9381
Enter fullscreen mode Exit fullscreen mode

That one line may save an hour of debugging.

Seeds are also useful outside automated tests

Reproducible datasets help with more than CI.

Bug reports

Instead of attaching a huge JSON file:

Use seed: bug-2841
Enter fullscreen mode Exit fullscreen mode

UI development

Designers and frontend developers can repeatedly work with the same profile dataset.

Database demos

Seeded data keeps screenshots and demonstrations consistent.

Performance testing

Specific seeds can represent different dataset shapes.

For example:

small-shop
enterprise-customer
high-order-volume
Enter fullscreen mode Exit fullscreen mode

QA

A tester can send a developer:

Generator: profile
Seed: qa-login-114
Enter fullscreen mode Exit fullscreen mode

Both sides can reproduce the same data.

But don't mistake seeds for security

A deterministic seed is not a secret.

If someone knows:

seed = customer-demo
Enter fullscreen mode Exit fullscreen mode

and knows the generator algorithm, they may be able to reproduce the same data.

That is completely fine for synthetic test fixtures.

It is not fine for generating:

  • passwords
  • encryption keys
  • API tokens
  • password-reset tokens
  • session secrets

Security-sensitive values require cryptographically secure generation.

Seeded fixture generation and secure random generation solve different problems.

A practical test-data strategy

A good setup might contain three layers.

Layer 1: Explicit fixtures

For exact business rules:

{
  "price": 100,
  "discount": 10
}
Enter fullscreen mode Exit fullscreen mode

Layer 2: Seeded synthetic fixtures

For realistic variation:

seed = checkout-customer-42
Enter fullscreen mode Exit fullscreen mode

Layer 3: Randomized exploratory tests

For discovering unexpected combinations:

random seed generated per run
Enter fullscreen mode Exit fullscreen mode

The trick is to log the random seed.

If an exploratory run fails:

Random seed: 9182811
Enter fullscreen mode Exit fullscreen mode

you can convert an unpredictable failure into a reproducible one.

Generate random seeds — but record them

This gives you a useful hybrid approach.

At test startup:

const seed =
  process.env.TEST_SEED ??
  Math.floor(Math.random() * 1_000_000);
Enter fullscreen mode Exit fullscreen mode

Then print it:

console.log(`TEST_SEED=${seed}`);
Enter fullscreen mode Exit fullscreen mode

Your CI run might use:

TEST_SEED=718421
Enter fullscreen mode Exit fullscreen mode

If the test fails, reproduce it locally:

TEST_SEED=718421 npm test
Enter fullscreen mode Exit fullscreen mode

Now each CI execution explores new data while every failure remains reproducible.

That is often the best of both worlds.

Using Genory for reproducible datasets

You can experiment with synthetic test data using the Genory Test Data tools.

For dataset-style workflows, Genory can help generate structured test records while keeping development data separate from real customer information.

For programmatic workflows and supported endpoints, the current API documentation is available at:

genory.dev/docs

Whether you use Genory, a local fixture library, or your own generator, the underlying principle remains the same:

configuration
+
seed
+
generator version
=
reproducible fixture
Enter fullscreen mode Exit fullscreen mode

A useful failure report

Instead of logging an entire fixture, you may be able to log:

Generator: customer-profile
Version: 3
Seed: checkout-regression-9381
Locale: de-DE
Now: 2026-10-05T12:00:00Z
Enter fullscreen mode Exit fullscreen mode

That is enough information to reconstruct the dataset.

It is compact, searchable and easy to paste into a bug report.

Final thought

Generated test data should not make bugs harder to reproduce.

It should make bugs easier to discover.

The key is controlling randomness instead of eliminating it.

Use synthetic data to introduce variation.

Use seeds to make that variation repeatable.

And whenever a randomized test fails, save the seed.

Because:

A random failure you cannot reproduce is frustrating.

A random failure with a seed is a test case.

Top comments (0)