DEV Community

azmiyuksel
azmiyuksel

Posted on

The seed-script tax: how I generate realistic test data for Postgres, Playwright and mock APIs

Two years ago I gave a demo to a prospective client. The dashboard looked great until I scrolled the customer table and the room got to enjoy asdf asdf, Test User 1, Test User 2 and a support ticket from aaa@aaa.com. Nobody said anything. They didn't have to.

That's the moment I started taking test data seriously, and it turned into a small tool I now use on every project. I'll explain the workflow first, and I'll be upfront that the tool is mine (fundata.dev) so you can discount the enthusiasm accordingly.

The problem isn't fake data. It's the seed script.

Every codebase I've worked on has some version of this file:

// scripts/seed.js — last touched 8 months ago
const users = [
  { name: "Test User", email: "test@test.com", country: "US" },
  { name: "Test User 2", email: "test2@test.com", country: "US" },
  // ...47 more, copy-pasted
];
Enter fullscreen mode Exit fullscreen mode

It starts as a 10-minute hack and then quietly becomes infrastructure. It rots. Someone adds a phone column and the seed script doesn't know about it. Someone writes a test that depends on row #3 existing. And because every value is uniform, entire classes of bugs stay invisible until production:

  • Names with apostrophes (O'Brien) or non-ASCII characters that blow up your CSV export
  • A NULL in a column your frontend assumed was always present
  • A product title long enough to wreck the card layout
  • Dates that cross a month boundary, so your "last 30 days" query is off by one Uniform test data doesn't test anything. It just fills the screen.

What I actually need from test data

After a few rounds of this, my requirements got pretty specific:

It has to be reproducible. Random data that changes on every run is useless in CI. A test that fails once every 40 builds because a generated string happened to contain a comma is worse than no test at all. I need the same input to produce byte-identical output.

It has to come out in the format the tool wants. My database wants INSERT statements. My mock API wants a JSON array. My data pipeline wants NDJSON. The PM wants a spreadsheet. Converting between these by hand is exactly the kind of chore that eats an afternoon.

It must never contain real customer data. This is the one that's actually a legal problem, not just an annoyance. Copying a slice of the production database into staging is common, fast, and a great way to end up explaining GDPR Article 32 to your legal team. Anonymization is harder than it looks: names get replaced, but the postal code plus birth date plus purchase history still identifies the person.

Synthetic data sidesteps the whole argument. There's no one to re-identify.

The workflow

Here's what I do now on a new project, and the four places it pays off.

1. Seeding a local database

I define the schema once, pick SQL as the output format, choose PostgreSQL or MySQL as the dialect, tick "include CREATE TABLE", and generate 5,000 rows. Then:

psql -d shop_dev -f orders.sql
Enter fullscreen mode Exit fullscreen mode

That's the whole setup step for a new dev on the team. No ORM, no migration ordering, no Docker seed container. For MySQL the dialect switch handles backtick quoting, which is one of those small things that would otherwise cost me ten minutes of sed.

The trick that makes this genuinely useful is the blank percentage on each field. Set phone to 30% blank and now a third of your rows have NULL there. Your "display the user's phone number" component gets tested against reality on day one instead of the week after launch.

2. Fixtures for Playwright and Cypress

For E2E tests I export JSON and drop it into the fixtures folder:

import users from '../fixtures/users.json';

test('search filters the user table', async ({ page }) => {
  const target = users[0];
  await page.goto('/admin/users');
  await page.fill('[data-testid=search]', target.email);
  await expect(page.getByRole('row')).toHaveCount(1);
});
Enter fullscreen mode Exit fullscreen mode

This is where reproducibility stops being a nice-to-have. I set a seed string, usually the ticket number, and commit both the seed and the generated file. When a test fails on CI six weeks later I can regenerate the exact same dataset locally and debug it. Without a seed you get a Heisenbug, and those cost days.

One habit worth stealing: give a couple of your test users deliberately awkward values. A name with a hyphen, an address with a # in it, an email at the maximum length your validator allows. If you only test with John Smith you're testing the happy path forever.

3. Feeding a mock API

Before the backend exists, the frontend still needs something to render. NDJSON is my default here because it streams and it's trivially greppable:

cat products.ndjson | jq -c 'select(.price > 100)' | head -20
Enter fullscreen mode Exit fullscreen mode

Pair it with json-server, MSW, or any mock backend and the frontend team is unblocked without waiting on anyone. If you want the mock API itself rather than just the payload, funapi.dev is the sibling project I built for that.

4. Demos, spreadsheets and screenshots

This is the case I underestimated. Sales wants a demo account that looks like a real business. Marketing wants a screenshot for the landing page. Someone in finance wants a CSV to prototype a pivot table.

Export CSV or TSV, hand it over, done. And crucially, nothing in that file is a real person, so the screenshot can go straight into a public blog post without a redaction pass.

The thing nobody thinks about until it bites: safe values

If you generate fake email addresses and your staging environment has a live SMTP config, you will eventually email a stranger. It happens more often than the industry likes to admit.

So the generated values are deliberately fenced off. Emails use reserved domains. IP addresses come from documentation ranges. Credit card numbers are the official test PANs the payment gateways publish, which means they pass Luhn validation and your checkout form works, but they can't move money. IBANs are shaped correctly per country length but aren't routable.

Test data that looks real and is real is not test data. It's a liability with a friendly UI.

Why it runs in your browser

Generation happens entirely client-side. No rows are uploaded, no rows come back down. That's partly a privacy stance and partly practical: your schema often mirrors your production table structure, and table structure is information you probably don't want sitting in someone's server log.

It also means 100,000 rows generate as fast as your laptop can manage, with no rate limit and no signup wall. The schema is stored in localStorage, or synced to an account if you actually want that.

A concrete starting point

If you want to try the workflow rather than read about it, this takes about two minutes:

  1. Open the test data generator and load the E-commerce orders template.
  2. Set customer_phone to 30% blank, so you get realistic missing values.
  3. Type your ticket number into the seed field.
  4. Export 1,000 rows as SQL with CREATE TABLE enabled, then pipe it into your local database. Then go look at your UI. In my experience something breaks within the first minute, and it's always something you'd have shipped.

There are longer write-ups for specific stacks if you want the details: database seeding, mock API data, QA automation, and the full methodology on how values are generated and why certain ranges are off-limits.

What I'd tell past me

Stop treating test data as a chore to get past on the way to the real work. The data you develop against shapes what you notice. Give yourself 10,000 rows of messy, realistic, reproducible nonsense and your app gets more honest.

Also: check your demo data before you share your screen.


What does your team's seed script look like? I'm curious whether anyone has actually solved this cleanly at scale, because I mostly see the same copy-pasted array everywhere I go.

Top comments (2)

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

Committing the seed makes the run reproducible against one more input you are not committing, which is the generator itself. A hosted generator can change a name corpus or a field's draw order under the same seed, and the failure is quiet: you regenerate six weeks later, get a plausible dataset that is not the one CI failed on, and read the passing test as the bug being gone. Schema edits are the likely trigger, so it is worth checking which promise yours makes. If adding a phone column shifts the draw sequence for the fields after it, the same seed stops reproducing your earlier fixture even though nothing about the seed changed. Committing the generated file next to it, which you already do, is the part that holds.

Collapse
 
latrisha_5a24fb5a824484b3 profile image
Latrisha

The point about the seed script becoming infrastructure is so true. Realistic and reproducible test data can make a huge difference, especially when you're testing edge cases instead of just filling the database with Test User 1, Test User 2, etc.
It also makes me think that tools like CodeCan.net could be useful alongside this workflow for developers who want to generate and manage more realistic test scenarios while building.