E-commerce Test Data: Catalogs, Carts, and Orders That Hold Together
E-commerce test data is a chain of dependencies. A product has SKUs, each SKU has a price and a stock level, carts hold SKUs at a price, orders turn into order lines, and payments turn into refunds. Most checkout bugs live on the links of that chain, not inside one table: an out-of-stock SKU that still sells, an order line for a product that no longer exists, a cart that outlives a price change, a refund larger than what's left on the payment. This post builds the whole graph from seeded templates, wires the foreign keys with batch relations, and plants each of those edge cases on purpose, so a failing checkout test can be replayed with exactly the data that broke it.
JsonFabrica's part is generating JSON over HTTP. There's no Shopify, Stripe, or database integration: you load the generated records into your own store and run your own tests against them.
Why e-commerce test data breaks at the edges
Random data per table is easy, and it doesn't find much. A product list from one generator, an order list from another, and a script that picks random IDs to link them will give you orders, but every order will look like the happy path. The bugs need specific combinations across tables:
- Stock edges. A SKU with 0 on hand, a SKU with 2 on hand and a cart asking for 3, and the boundary where the cart asks for exactly 2.
- Catalog changes. An old order line whose product has since been discontinued and removed. The order history page must still render it.
- Price changes. A cart line holding the price from when it was added, against a current price that has since gone up.
- Quantities. Zero, and a quantity too large for a 32-bit integer column.
- Money. A currency without decimal places, and prices stored as floats that don't survive multiplication.
- Refunds. A refund that fits the captured amount but not what's left after earlier refunds.
The general case for test data that respects your domain's relationships is made in Random isn't realistic. This post is the concrete e-commerce version: a specific data model, the templates for it, and the edge cases a checkout suite needs.
Sample product catalog data: products, SKUs, and stock
The model has five record types: products, SKUs (one per sellable
variant), customers, orders, and order lines. Each gets a small template.
Fields that point at another record are written as null; the batch
request later fills them in from real records.
Products only need an id, a name, and a status:
{
"id": "prd_<getRandomNumber(100000, 999999)>",
"name": "<getRandomElement('Wool Cap', 'Tote Bag', 'Rain Shell', 'Polo')>",
"status": "active"
}
JsonFabrica has no product-name or SKU functions, so names come from a
literal list via getRandomElement.
Write a list that looks like your catalog. Lorem ipsum from
getRandomTextWithSpaces works for descriptions nobody reads, but not for
names that show up in test failure messages.
SKUs carry the price and the stock level, which is where most checkout logic looks:
{
"sku": "SKU-<appendBefore('0', getRandomNumber(1, 99999), 5)>",
"productId": null,
"productName": null,
"size": "<getRandomElement('S', 'M', 'L', 'XL')>",
"color": "<getRandomElement('Black', 'Navy', 'Sand', 'Olive')>",
"currency": "USD",
"priceMinor": <getRandomElement(1299, 1999, 2450, 3499, 4999, 8900)>,
"onHand": <if(getRandomBoolean(0.2))>0
<elseIf(getRandomBoolean(0.25))><getRandomNumber(1, 3)>
<else><getRandomNumber(20, 400)><endIf>
}
What each part does:
- SKU codes.
appendBeforepads the random number to five digits, so codes come out asSKU-08053rather thanSKU-8053. For strictly sequential codes,createSeqandgetSeqgive you a counter, but sequences are durable and keep counting between runs, so a fixed seed won't reproduce them. Seeded random codes will. - Prices from a price list.
getRandomElementover real-looking price points gives you $12.99 and $49.99 rather than $37.41. The values are integer cents; the next section explains why. - Stock in three bands. The first condition is true 20% of the time and gives an out-of-stock SKU. Of the remaining SKUs, a quarter get 1 to 3 units, which is low stock. That's another 20% overall. The rest get 20 to 400. These are rates, not guarantees, so the check script further down confirms that the seed you commit actually contains both edges.
- Variant labels are independent draws. Two SKUs of one product can come out with the same size and color. If your schema has a unique constraint on that pair, add it to the checks.
Customers are equally small:
{
"id": "cus_<getRandomNumber(100000, 999999)>",
"name": "<getRandomFullName()>",
"email": "<getRandomEmail('example.com')>",
"country": "<getRandomCountry()>"
}
Money as integer minor units, not floats
Every price in these templates is an integer in the currency's minor
unit: 1999 means $19.99. That's the representation to test with, and
usually the one to store.
getRandomNumber can produce decimal
prices: getRandomNumber(5, 150, 2) returns a number like 34.99. That
number is a binary float, and floats don't hold most decimal prices
exactly. In JavaScript, 0.1 + 0.2 is 0.30000000000000004, and
4.35 * 100 is 434.99999999999994, which Math.floor turns into 434
cents. A test fixture full of float prices tests your rounding by
accident, if at all. Use getRandomNumber(min, max) without the third
argument, which returns integers, and treat the result as minor units.
Two related points:
- Minor units depend on the currency. ISO 4217 gives JPY no minor
unit, USD two decimal places, and KWD three.
2450is $24.50 but ¥2,450. Keep the currency code on every record that holds an amount; the checkout scenarios below include a JPY case for this reason. - Display strings are a separate field.
formatNumberreturns a formatted string, such as"1,234.50"fromformatNumber(1234.5, 2, '.', ','). It's useful for fixtures that test rendering, but it can't divide by 100, so it doesn't turn123450into"1,234.50". Keep amounts as integers and let the code under test format them.
What the template can't compute: totals, tax, remaining stock
JsonFabrica templates have no arithmetic. Expressions support the
comparison operators ==, !=, <, <=, >, >= and the logical
operators &&, ||, and !. There is no +, -, *, or /. So a template
can't compute any value that is derived from other values:
- the line total, quantity times unit price
- the order subtotal, the sum of its lines
- tax, discounts, and how rounding is distributed across lines
- the stock left after an order, or the balance left on a payment after a refund
That's a hard limit, and it shapes the whole approach. The templates generate the inputs: unit prices, quantities, stock levels, captured amounts. Derived values are computed in one of two places:
- In your test, when your app computes them. If the app calculates order totals and decrements stock, then "does it get the arithmetic right?" is the test. The test computes the expected value from the inputs, in integers, and compares it with what the app returns.
- In your loader, when your schema stores them. If an
orderstable has asubtotalcolumn, the script that loads the generated data computes it from the lines before inserting.
Either way, the fixture never contains a total that the generator claims to have calculated. Every template in this post follows that rule.
Generate orders and order lines in one batch
Orders and lines are where the links matter. The order template leaves
customerId empty:
{
"id": "ord_<getRandomNumber(100000, 999999)>",
"customerId": null,
"status": "<getRandomElement('paid', 'paid', 'shipped', 'cancelled')>",
"currency": "USD",
"placedAt": "<getRandomDate('2026-07-01', '2026-10-01')>"
}
There's no "now" function, so dates come from an explicit range. The order line template only decides the quantity. Everything else comes from its order and its SKU:
{
"id": "lin_<getRandomNumber(100000, 999999)>",
"orderId": null,
"sku": null,
"productName": null,
"unitPriceMinor": null,
"quantity": <getRandomElement(1, 1, 1, 1, 2, 2, 3)>
}
Repeating a value in getRandomElement weights it, so most lines have a
quantity of 1. Save each template with
POST /v1/templates and note the
templateId it returns. A single batch request
then generates the whole graph. This is the part that turns JsonFabrica
into an ecommerce test data generator rather than a per-table faker. The
templateId values here are placeholders for your own:
{
"seed": 20261007,
"documents": [
{ "templateId": "tpl_product", "alias": "products", "count": 3 },
{
"templateId": "tpl_sku",
"alias": "skus",
"count": 9,
"relations": {
"productId": { "from": "products.id", "strategy": "round-robin" },
"productName": {
"from": "products.name", "strategy": "round-robin"
}
}
},
{ "templateId": "tpl_customer", "alias": "customers", "count": 4 },
{
"templateId": "tpl_order",
"alias": "orders",
"count": 8,
"relations": {
"customerId": { "from": "customers.id", "strategy": "round-robin" }
}
},
{
"templateId": "tpl_line",
"alias": "lines",
"count": 24,
"relations": {
"orderId": { "from": "orders.id", "strategy": "round-robin" },
"sku": { "from": "skus.sku", "strategy": "round-robin" },
"productName": {
"from": "skus.productName", "strategy": "round-robin"
},
"unitPriceMinor": {
"from": "skus.priceMinor", "strategy": "round-robin"
}
}
},
{
"templateId": "tpl_retired_line",
"alias": "retired_lines",
"count": 1,
"relations": {
"orderId": { "from": "orders.id", "strategy": "round-robin" }
}
}
]
}
The batch generates each alias's documents first, then overwrites the
fields named in relations with values copied from another alias. The
templates declare those fields as null so the keys keep their position
in the output. How the copying works decides what the data can and can't
represent:
- Every reference is real. Each SKU's
productIdand each line'sorderIdandskuare copied from records generated in the same run. No line can point at a SKU that doesn't exist, unless you plant one on purpose, as the next section does. - Relations copy any field, and several relations to one alias agree.
Round-robin assigns the child at position i to the parent at position
i modulo the parent count. All three relations from
linestoskususe the same position, so a line'ssku,productName, andunitPriceMinoralways come from the same SKU. Lines also chain:productNamereaches the line through the SKU, which got it from the product. The line ends up with a snapshot of the name and price at order time, the way real order lines store them. - Counts decide which SKUs share an order. With 24 lines, 8 orders, and 9 SKUs, order k gets lines k, k+8, and k+16, which land on three different SKUs. With 9 orders and 9 SKUs, each order would get the same SKU three times. Check the arithmetic when you change counts.
- Round-robin spreads evenly; it doesn't model popularity. It's the only strategy, and it gives every order exactly three generated lines (the first also gets the planted retired line, below) and every SKU two or three lines. Real stores don't look like that: a few products sell far more than the rest. Even distribution is fine for checkout correctness tests. If you're testing reports or "best sellers" logic, you'll need to shape that skew yourself, for example with separate aliases for a few best-selling SKUs.
Batch templates get no request params, so they shouldn't rely on
getParam beyond its default.
Plant an order line for a discontinued product
Relations always point at a record that exists, so they can't produce a
dangling reference. When you want one, write it as a literal. This is
the whole tpl_retired_line template:
{
"id": "lin_retired_<appendBefore('0', 1, 2)>",
"orderId": null,
"sku": "SKU-RETIRED-01",
"productName": "Classic Logo Tee",
"unitPriceMinor": 1800,
"quantity": 1
}
POST /v1/templates rejects a template that contains only literal
text, because every template needs at least one placeholder. The
appendBefore call is that placeholder: it pads 1 to two digits, so
the id still renders as lin_retired_01 every time.
Only orderId comes from a relation. With a count of 1, round-robin
always assigns this line to the first order, so you know where to look.
SKU-RETIRED-01 isn't in the catalog: it stands for a product that was
discontinued and removed after it sold.
Loading it tells you something about your schema. If order_lines has a
foreign key to skus, this row fails to insert, which means your app
can never delete a SKU that has been ordered. In that case, discontinue
products with a status column and test that instead; the checkout
scenarios below include a SKU with "productStatus": "discontinued". If
the row loads, your order history page has to render a line whose SKU
lookup returns nothing, and that's worth a test of its own.
Fetch, check, and load the dataset
This batch has 49 documents in total. Batches with fewer than 50
documents run synchronously and return 200 with the results. At 50 or
more, POST /v1/batches returns 202 Accepted with a batchId, and you
poll GET /v1/batches/{batchId} until the status is completed. The
script handles both, so the counts can grow later:
#!/usr/bin/env bash
# scripts/regenerate-shop.sh: run on purpose, review the diff, commit.
set -euo pipefail
API=https://api.jsonfabrica.com/v1
AUTH="Authorization: Bearer $JSONFABRICA_API_KEY"
RES=$(curl -fsS -X POST "$API/batches" -H "$AUTH" \
-H 'Content-Type: application/json' -d @shop/batch.json)
BATCH_ID=$(jq -r .batchId <<<"$RES")
while jq -e '.status == "queued" or .status == "running"' \
<<<"$RES" > /dev/null; do
sleep 2
RES=$(curl -fsS "$API/batches/$BATCH_ID" -H "$AUTH")
done
jq -e '.status == "completed"' <<<"$RES" > /dev/null \
|| { echo "$RES" >&2; exit 1; }
jq . <<<"$RES" > fixtures/shop.json
node scripts/check-shop.mjs
A synchronous batch that fails still returns 200, with
"status": "failed" and an error object, which is why the script
checks the status instead of trusting the HTTP code. The results are
grouped by alias: results.products, results.skus, results.lines,
and so on. The OpenAPI reference currently shows results as a flat
array; the running API returns the grouped object.
The sample output in the rest of this post is real, not mocked up. It
comes from running the exact templates and batch request shown here
through the JsonFabrica engine with seed 20261007. Here's one
out-of-stock SKU, one generated order line, and the planted line (an
excerpt of results):
{
"skus": [
{
"sku": "SKU-15981",
"productId": "prd_910454",
"productName": "Polo",
"size": "M",
"color": "Sand",
"currency": "USD",
"priceMinor": 4999,
"onHand": 0
}
],
"lines": [
{
"id": "lin_950638",
"orderId": "ord_546146",
"sku": "SKU-94078",
"productName": "Polo",
"unitPriceMinor": 1999,
"quantity": 1
}
],
"retired_lines": [
{
"id": "lin_retired_01",
"orderId": "ord_546146",
"sku": "SKU-RETIRED-01",
"productName": "Classic Logo Tee",
"unitPriceMinor": 1800,
"quantity": 1
}
]
}
The same seed produces two out-of-stock SKUs and three low-stock ones,
and both out-of-stock SKUs appear on paid orders. Read onHand as stock
remaining after these orders. If your schema stores opening stock and
decrements it, the loader has to subtract units sold, and some SKUs will
go negative. That's an oversell, and a reasonable thing to test for.
The check script runs once per regeneration. It confirms what the templates can't guarantee and computes what they can't calculate:
// scripts/check-shop.mjs: run after every regeneration, before committing.
import fs from 'node:fs';
const { seed, results } = JSON.parse(
fs.readFileSync('fixtures/shop.json', 'utf8'),
);
const { products, skus, customers, orders, lines, retired_lines } = results;
const fail = (msg) => {
throw new Error(`seed ${seed}: ${msg}`);
};
const allLines = [...lines, ...retired_lines];
// 1. Seeded random ids are stable per seed: check for collisions once.
const ids = [...products, ...customers, ...orders, ...allLines]
.map((d) => d.id)
.concat(skus.map((s) => s.sku));
if (new Set(ids).size !== ids.length) fail('duplicate ids');
// 2. The only line without a catalog SKU is the one we planted.
const known = new Set(skus.map((s) => s.sku));
const orphans = allLines.filter((l) => !known.has(l.sku));
if (orphans.length !== retired_lines.length) fail('unplanned orphan line');
// 3. The stock edge cases this seed must contain.
if (!skus.some((s) => s.onHand === 0)) fail('no out-of-stock SKU');
if (!skus.some((s) => s.onHand >= 1 && s.onHand <= 3)) {
fail('no low-stock SKU');
}
// 4. Derived values, computed here because templates can't do arithmetic.
const subtotalMinor = {};
for (const l of allLines) {
subtotalMinor[l.orderId] =
(subtotalMinor[l.orderId] ?? 0) + l.quantity * l.unitPriceMinor;
}
fs.writeFileSync(
'fixtures/shop-expected.json',
JSON.stringify({ seed, subtotalMinor }, null, 2) + '\n',
);
console.log(`seed ${seed}: ${orders.length} orders, ${allLines.length} lines`);
For seed 20261007, the first order, ord_546146, has four lines:
SKU-94078 at 1 × 1999, SKU-35084 at 1 × 3499, SKU-75030 at 2 × 1999, and
the retired line at 1 × 1800. The script's subtotal for it is 11296. A
test that loads the data and reads the order back from your API asserts
that number. If the seed is missing an edge, or two seeded IDs collide,
the script fails and you pick another seed before anything is committed.
Load the records in dependency order: products, SKUs, customers, orders, then order lines. If you need this data behind a browser test suite, Playwright test data covers seeding a real backend from global setup. The shared dataset is also a reasonable base for local development. It isn't a substitute for the targeted checkout cases, which come next.
Test data for checkout flows: plant every edge case
A random dataset contains edge cases by luck. Checkout tests need them by design, each one isolated and labeled with the outcome it should produce. This template generates a scenario file, one row per test case, using the same trick as boundary-value testing in general: the risky rows come first and are written deliberately, and random rows fill the rest. Boundary value test data covers the technique; here it's applied to carts and refunds.
{
"refunds": [
{ "case": "refund equals remaining", "capturedMinor": 5000,
"refundedMinor": 3000, "refundMinor": 2000, "expect": "OK" },
{ "case": "refund one cent over remaining", "capturedMinor": 5000,
"refundedMinor": 3000, "refundMinor": 2001,
"expect": "REFUND_EXCEEDS_REMAINING" },
{ "case": "refund on fully refunded payment", "capturedMinor": 5000,
"refundedMinor": 5000, "refundMinor": 1,
"expect": "REFUND_EXCEEDS_REMAINING" },
{ "case": "over remaining but under captured",
"capturedMinor": <getRandomNumber(8000, 9000)>,
"refundedMinor": <getRandomNumber(3000, 4000)>,
"refundMinor": <getRandomNumber(6001, 7999)>,
"expect": "REFUND_EXCEEDS_REMAINING" }
],
"checkout": [<for(i, 1, getParam('rows', 12))><if(getVar('i') > 1)>,<endIf>
<setVar('price', getRandomElement(1299, 1999, 2450, 3499, 4999))>
{
"case": <if(getVar('i') == 1)>"out of stock"
<elseIf(getVar('i') == 2)>"quantity above stock"
<elseIf(getVar('i') == 3)>"quantity equals stock"
<elseIf(getVar('i') == 4)>"discontinued product"
<elseIf(getVar('i') == 5)>"price rose since add to cart"
<elseIf(getVar('i') == 6)>"zero quantity"
<elseIf(getVar('i') == 7)>"quantity above int32 max"
<elseIf(getVar('i') == 8)>"zero-decimal currency"
<else>"valid"<endIf>,
"sku": "SKU-<appendBefore('0', getRandomNumber(1, 99999), 5)>",
"productStatus": <if(getVar('i') == 4)>"discontinued"
<else>"active"<endIf>,
"currency": <if(getVar('i') == 8)>"JPY"<else>"USD"<endIf>,
"cartUnitPriceMinor": <if(getVar('i') == 5)>
<getRandomNumber(500, 999)><else><getVar('price')><endIf>,
"currentUnitPriceMinor": <getVar('price')>,
"onHand": <if(getVar('i') == 1)>0
<elseIf(getVar('i') <= 3)>2
<elseIf(getVar('i') == 7)>1000000
<else><getRandomNumber(20, 400)><endIf>,
"quantity": <if(getVar('i') == 1)>1
<elseIf(getVar('i') == 2)>3
<elseIf(getVar('i') == 3)>2
<elseIf(getVar('i') == 6)>0
<elseIf(getVar('i') == 7)>2147483648
<else><getRandomNumber(1, 5)><endIf>,
"expect": <if(getVar('i') == 1)>"OUT_OF_STOCK"
<elseIf(getVar('i') == 2)>"INSUFFICIENT_STOCK"
<elseIf(getVar('i') == 4)>"PRODUCT_UNAVAILABLE"
<elseIf(getVar('i') == 5)>"PRICE_CHANGED"
<elseIf(getVar('i') == 6 || getVar('i') == 7)>"INVALID_QUANTITY"
<else>"OK"<endIf>
}<end_for>
]
}
How it's built:
- One defect per row. The out-of-stock row asks for 1 unit, so only stock can fail it. The int32 row has a million units on hand, so it tests quantity validation, not stock. The discontinued product has plenty of stock and an unchanged price. When a row fails, it can only fail for one reason.
- Boundaries are literals. "One cent over the remaining balance" is remaining plus one, and the template can't add. So the refund boundaries are written out: 5000 captured, 3000 already refunded, refunds of 2000 and 2001.
- Random values that still can't break the case. The fourth refund row draws from ranges that can't overlap. Captured is 8000 to 9000 and already refunded is 3000 to 4000, so the remaining balance is between 4000 and 6000. The refund is 6001 to 7999: always above what's left, always below what was captured. That catches the common bug of checking a refund against the captured amount instead of the remaining balance. The same trick covers the stale cart: cart prices of 500 to 999 cents are below every price on the list, so the current price is always higher.
priceis the only variable. It's printed twice, once as the cart price and once as the current price, which keeps them equal on every row except the price-change row. Everything else is decided inline in its own field. The control flow docs coverfor,if,elseIf, andelse.- The 2147483648 quantity is 2^31, one above the largest signed
32-bit integer. It finds
integercolumns andint32parsers that overflow or reject the value with a 500 instead of a validation error. - The JPY row has the same integer amounts as the others. If the app divides by 100 before displaying or charging it, ¥2,450 becomes ¥24.50.
- Refunds come first in the file because their random draws then
happen before the loop. Raising
rowsadds valid checkout rows at the end and changes nothing above them.
The error codes are illustrative. Replace them with your API's own. The
template's loop syntax follows the usual rules: the loop end is
inclusive, the loop variable is read with getVar('i'), and
<if(getVar('i') > 1)>,<endIf> puts a comma before every row but the
first.
Generate the file with POST /v1/templates/generate, which takes the
raw template, a seed, and params, and saves nothing:
jq -n --rawfile body shop/checkout-scenarios.tmpl \
'{body: $body, seed: 20261007, params: {rows: 12}}' \
| curl -fsS -X POST https://api.jsonfabrica.com/v1/templates/generate \
-H "Authorization: Bearer $JSONFABRICA_API_KEY" \
-H 'Content-Type: application/json' -d @- \
| jq '.data' > fixtures/checkout-scenarios.json
Rendered with seed 20261007 and rows: 12, these are three of the
rows: the random refund row and checkout rows 5 and 8.
[
{
"case": "over remaining but under captured",
"capturedMinor": 8963,
"refundedMinor": 3425,
"refundMinor": 6103,
"expect": "REFUND_EXCEEDS_REMAINING"
},
{
"case": "price rose since add to cart",
"sku": "SKU-49309",
"productStatus": "active",
"currency": "USD",
"cartUnitPriceMinor": 718,
"currentUnitPriceMinor": 4999,
"onHand": 110,
"quantity": 5,
"expect": "PRICE_CHANGED"
},
{
"case": "zero-decimal currency",
"sku": "SKU-46674",
"productStatus": "active",
"currency": "JPY",
"cartUnitPriceMinor": 2450,
"currentUnitPriceMinor": 2450,
"onHand": 22,
"quantity": 4,
"expect": "OK"
}
]
The refund row leaves 5538 on the payment and asks for 6103, which is below the 8963 captured. A refund check against the captured amount lets it through.
Run the checkout tests against the fixture
The test iterates over the rows and computes every derived value itself.
runCheckout, runRefund, and getOnHand are your own helpers:
runCheckout creates the SKU with the row's status, stock, and cart
price, adds the quantity to a cart, sets the current price, and checks
out.
// test/checkout.scenarios.test.ts (Vitest; Jest's test.each also works)
import { describe, expect, test } from 'vitest';
import fixture from '../fixtures/checkout-scenarios.json';
import { getOnHand, runCheckout, runRefund } from './helpers';
describe('checkout', () => {
test.each(fixture.checkout)('$case ($sku)', async (row) => {
const result = await runCheckout(row);
if (row.expect !== 'OK') {
expect(result.error).toBe(row.expect);
return;
}
// Derived values: computed here, in integers, never in the fixture.
expect(result.order.subtotalMinor).toBe(
row.quantity * row.currentUnitPriceMinor,
);
expect(result.order.currency).toBe(row.currency);
expect(await getOnHand(row.sku)).toBe(row.onHand - row.quantity);
});
});
describe('refunds', () => {
test.each(fixture.refunds)('$case', async (row) => {
const result = await runRefund(row);
const remaining = row.capturedMinor - row.refundedMinor;
if (row.expect !== 'OK') {
expect(result.error).toBe(row.expect);
return;
}
expect(result.remainingMinor).toBe(remaining - row.refundMinor);
});
});
The expected subtotal, the stock left after checkout, and the balance left after a refund are all computed in the test from integer inputs. If your app applies tax or discounts, encode your rounding rule here too, for example per line or per order. Two implementations that round in different places will disagree by a cent, and the test should say which one is right.
The $case ($sku) title puts the row's label and SKU in the test name, so
CI reports price rose since add to cart (SKU-49309) rather than a row
index.
Reproduce a failing checkout test from its seed
Both fixtures are generated from seed 20261007 and committed. Every
run, local or CI, reads the same files. When
quantity above int32 max (SKU-34374) fails in CI, the row that failed
is in fixtures/checkout-scenarios.json on the branch, and running the
test locally sends the same request with the same quantity. Editing a
template or changing batch counts reshuffles the values, so regenerate,
rerun the check script, and commit the new fixtures in the same pull
request as the change.
To find failures you didn't plan for, run a nightly job that omits the
seed. JsonFabrica picks one and returns it, as seed in a batch response
and as meta.seed from template generation. Log it next to the test
results, and when the nightly run fails, regenerate with that seed to get
the exact data back.
Deterministic test data
covers that workflow in detail. If you need a store that looks good in a
sales demo rather than one that breaks checkout, that's a different
dataset, covered in SaaS demo data.
FAQ
How do I generate test data for an e-commerce site?
Generate the records in dependency order: products, then SKUs with prices and stock, then customers, orders, and order lines that reference them. A batch generator with relations fills each order line's order and SKU fields from records created in the same run, so nothing points at a missing row. Then plant the edge cases you care about, such as out-of-stock SKUs and refunds above the remaining balance, as explicit rows rather than hoping randomness produces them.
What test cases should I write for an e-commerce checkout?
Cover stock, product state, price, quantity, currency, and refunds. That means an out-of-stock SKU, a quantity one above stock and one equal to it, a discontinued product in the cart, a price that changed after the item was added, zero and absurdly large quantities, a currency with no decimal places, and refunds equal to, just above, and well above the remaining balance. Give each case one defect and an expected outcome so a failure has a single cause.
Should prices be stored as integers in cents or as decimals?
Store money as integers in the currency's minor unit, such as cents for
USD, and keep the currency code next to it. Binary floats can't represent
most decimal prices exactly: in JavaScript, 4.35 * 100 is
434.99999999999994, which truncates to 434 cents. Minor units also vary
by currency: JPY has none, USD has two, and KWD has three.
Can JsonFabrica calculate order totals or tax in a template?
No. JsonFabrica template expressions support comparisons and logical operators but no arithmetic, so a template can't multiply quantity by price, sum order lines, apply tax, or subtract sold units from stock. Generate the inputs, such as unit prices, quantities, and stock levels, and compute derived values in your loader or test. Asserting that your application computes them correctly is usually the point of the test anyway.
How do I make a failing checkout test reproducible?
Generate the test data from a fixed seed and commit the generated file, so every run reads identical carts, prices, and quantities. With JsonFabrica, the same template, seed, and parameters return the same output, as long as the templates don't use sequences, which keep counting between runs. Name each test after its case and SKU so a CI failure points straight at the row that broke.
Relational batches and seeded scenario templates are part of the JsonFabrica API: the catalog, orders, and lines come out linked, and the checkout edge cases come out exactly where you planted them. The batches API reference documents relations and the request format.
Generate realistic test data with JsonFabrica
Describe the shape of your data once, then generate as many fresh, realistic JSON documents as you need via a simple API call.