sdxc

Type to search, or start from one of these:

[ 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:

app/fixtures/sample.ts
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:

app/fixtures/factories.ts
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:

app/test/seed.ts
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:

app/http/controllers/invoices.test.ts
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:

app/fixtures/invoice.ts
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:

app/http/controllers/previews/invoice-card.tsx
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:

app/components/invoice-card.test.tsx
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:

app/data/invoice-totals.test.ts
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