[ Operations & testing ]
Generate believable test data
Seed test databases and component fixtures with realistic data that comes out identical on every run, so failures replay and snapshots hold.
Last updated 2026-09-29
Tests written against "Test User" and test@test.com pass while the real thing breaks: the
name with an accent, the company name too long for its column, the invoice list with forty
rows instead of one. Random data finds those cases, and then fails once on a value nobody can get back.
@sdxc/sample gives you the first without the second. Every generator opens on
a seed, and the same seed with the same calls produces the same people, companies, amounts and
dates on any machine, on any day.
This guide builds one set of factories for your Accounts and Invoices, and uses it three
ways: to seed a test database, to feed a component preview, and to render snapshots that only
change when your markup does. It assumes the test setup from
Test Workers apps.
npm add -D @sdxc/sample
One generator per fixture
A generator takes a seed and, optionally, the instant its date module measures from. Pin
both, in one module the tests and the previews share:
import { createSample } from "@sdxc/sample";
export const REFERENCE = new Date("2026-06-15T12:00:00Z");
export function fixtureSample(seed: string) {
return createSample({ seed, now: REFERENCE });
}
The seed is required on purpose, so every run's data is replayable from something you can
name. The reference instant is what keeps dates put: without it, "an invoice issued in the last
90 days" moves every day, and so does every page that shows how long ago it was. Name seeds
after what they are for, like "invoice-list", so two tests never share a stream by accident.
Write factories that agree with themselves
A factory draws one row's worth of input for your model. The generator's modules return the
pieces, and person.record() returns a whole person whose email and handle match their name:
import type { Sample } from "@sdxc/sample";
import type { AccountInput } from "~/app/data/account";
import type { InvoiceInput } from "~/app/data/invoice";
export function accountInput(sample: Sample): AccountInput {
let person = sample.person.record();
let country = sample.location.country();
return {
name: person.fullName,
email: person.email,
company: sample.company.name(),
country,
city: sample.location.city({ country }),
createdAt: sample.date.past({ days: 365 }),
};
}
export function invoiceInput(sample: Sample, accountId: string): InvoiceInput {
return {
accountId,
number: sample.helpers.fromRegExp("INV-[0-9]{6}"),
amountCents: sample.number.int({ min: 1_000, max: 250_000 }),
status: sample.helpers.weightedPick([
{ weight: 6, value: "paid" as const },
{ weight: 3, value: "open" as const },
{ weight: 1, value: "void" as const },
]),
issuedAt: sample.date.past({ days: 90 }),
notes: sample.helpers.maybe(() => sample.lorem.sentence(), { chance: 0.3 }),
};
}
Reading the country first and passing it to city({ country }) keeps an address believable: a
city in the country it claims. weightedPick makes most invoices paid and a few void, the mix a
real list has, and maybe leaves notes as null most of the time, so the empty case is
covered without a test of its own. Contact details can't reach anyone: emails and links use the
domains reserved for examples, and phone numbers come from the 555-01xx range kept for
fiction.
Seed a test database
A seeding function writes a believable amount of data through your own models, with each part drawing from a stream of its own:
import type { Sample } from "@sdxc/sample";
import type { Database } from "remix/data-table";
import Accounts from "~/app/data/account";
import Invoices from "~/app/data/invoice";
import { accountInput, invoiceInput } from "~/app/fixtures/factories";
export async function seedDatabase(db: Database, sample: Sample, accounts = 10) {
let people = sample.derive("accounts");
let billing = sample.derive("invoices");
let seeded = [];
for (let index = 0; index < accounts; index++) {
let account = await Accounts.create(db, accountInput(people));
let count = billing.number.int({ min: 0, max: 6 });
let build = () => invoiceInput(billing, account.id);
let invoices = billing.helpers.multiple(build, { count });
for (let input of invoices) await Invoices.create(db, input);
seeded.push({ account, invoices });
}
return seeded;
}
Values follow the order of the calls, so adding one call shifts everything drawn after it.
derive(label) opens an independent stream named by its label, which is why this uses two:
when you add a phone field to accountInput next month, the accounts change and every
invoice amount stays exactly where it was.
A test then seeds a fresh database, requests a page, and checks it against what was seeded:
import { expect, test } from "vitest";
import { fixtureSample } from "~/app/fixtures/sample";
import { createTestDatabase, fetchApp } from "~/app/test/router";
import { seedDatabase } from "~/app/test/seed";
test("the open filter lists every open invoice", async () => {
let db = await createTestDatabase();
let seeded = await seedDatabase(db, fixtureSample("invoice-list"));
let open = seeded
.flatMap((entry) => entry.invoices)
.filter((invoice) => invoice.status === "open");
let response = await fetchApp(db, "/invoices?status=open");
let body = await response.text();
expect(open.length).toBeGreaterThan(0);
for (let invoice of open) expect(body).toContain(invoice.number);
});
createTestDatabase and fetchApp are the helpers from the testing guide. The assertions
read the expected values from seeded instead of spelling out a name the seed happens to
produce, so the test states the rule, every open invoice is listed, and keeps passing when a
package update grows the name lists. The first expectation guards the seed itself: a filter
test over zero open invoices would pass without testing anything.
A development database can use the same function. A development-only route that calls
seedDatabase(ctx.db, fixtureSample("local-dev")) gives every developer the same accounts,
so a bug report that names a customer points at the same row on every laptop.
Feed component previews
A component worth previewing needs props that look like production. Build the fixture once, with ids, from the same factories:
import type { Account } from "~/app/data/account";
import type { Invoice } from "~/app/data/invoice";
import { accountInput, invoiceInput } from "~/app/fixtures/factories";
import { fixtureSample } from "~/app/fixtures/sample";
export interface InvoiceFixture {
account: Account;
invoice: Invoice;
}
export function invoiceFixture(seed: string): InvoiceFixture {
let sample = fixtureSample(seed);
let account = { id: sample.string.uuid(), ...accountInput(sample) };
let invoice = { id: sample.string.uuid(), ...invoiceInput(sample, account.id) };
return { account, invoice };
}
A preview page renders your InvoiceCard with it, and takes the seed from the query string:
import { createAction } from "remix/router";
import { InvoiceCard } from "~/app/components/invoice-card";
import { invoiceFixture } from "~/app/fixtures/invoice";
import routes from "~/routes/web";
export default createAction(routes.previews.invoiceCard, (ctx) => {
let seed = ctx.url.searchParams.get("seed") ?? "invoice-card";
let { account, invoice } = invoiceFixture(seed);
return ctx.render(<InvoiceCard account={account} invoice={invoice} />);
});
Changing ?seed= flips through variations: a long company name, a void invoice, notes that
wrap. A seed doesn't choose a variation, it replays one, so when a card looks wrong the URL is
the bug report: the same seed renders the same card for whoever opens it. Map the route only in development, so the previews never ship.
Keep snapshots stable
A snapshot records output and fails when it changes, which is only useful when nothing but your code can change it. Generated data meets that bar once the seed and the reference instant are fixed:
import { renderToString } from "remix/ui/server";
import { expect, test } from "vitest";
import { InvoiceCard } from "~/app/components/invoice-card";
import { invoiceFixture } from "~/app/fixtures/invoice";
test.each(["invoice-card", "invoice-card-2", "invoice-card-5"])(
"an invoice card renders the same markup for %s",
async (seed) => {
let { account, invoice } = invoiceFixture(seed);
let html = await renderToString(
<InvoiceCard account={account} invoice={invoice} />,
);
await expect(html).toMatchFileSnapshot(
`./__snapshots__/invoice-card.${seed}.html`,
);
},
);
The seeds are the ones worth keeping: browse the preview until one renders a case you care
about, such as a void invoice with long notes, and add it to the list. Each part of the markup
holds still for its own reason. The names and amounts come from the
seed, the ids from the seeded string.uuid() rather than crypto.randomUUID(), and the
dates from REFERENCE rather than the clock. What is left is the component, so a failing
snapshot is a change you made. Format dates with an explicit timeZone in the component too,
or the same instant renders differently on a laptop and in CI.
When you upgrade @sdxc/sample and its word lists grow, the snapshots change together, in one
diff you review once. That is the trade: values are reproducible for a given version, not
promised across versions, which is also why the database test above asserts rules rather than
names.
Shake out hidden assumptions
Fixed seeds make a suite repeatable, and they also let it lean on one lucky set of values. For code that should hold for any input, draw a fresh seed each run and print it where a failure shows it:
import { createSample, systemSeed } from "@sdxc/sample";
import { describe, expect, test } from "vitest";
import { totals } from "~/app/data/invoice-totals";
import { invoiceInput } from "~/app/fixtures/factories";
const SEED = Number(process.env.SAMPLE_SEED) || systemSeed();
describe(`invoice totals (SAMPLE_SEED=${SEED})`, () => {
test("the totals per status add up to the whole", () => {
let sample = createSample({ seed: SEED });
let build = () => invoiceInput(sample, "account-1");
let sum = totals(sample.helpers.multiple(build, { count: 200 }));
expect(sum.paid + sum.open + sum.void).toBe(sum.all);
});
});
totals is your own function. systemSeed() draws a 32-bit seed from a strong random source.
Because the seed is in the suite's name, a failure prints it, and
SAMPLE_SEED=<seed> vp test run app/data replays that exact run down to the last field.
Where to go next
Test Workers apps: the test database,
fetchAppand the Vitest projects these tests run in.Write executable specs: the same seeded generation, drawn inside a
.specsuite.Query D1 and Durable Object SQL: the models the seeding function writes through.
Build the interface with remix/ui: the components the previews render.