Module 5: Testing in Sandbox Before Production

3. Generating synthetic data

Description

By the end of this lesson you will be able to build a set of synthetic data —fake but realistic orders— to test order-triage, without using a single piece of real customer data. You will know how to generate it two ways: with a fixed dataset written by hand, and with a Code node that assembles it in memory. And —most importantly— you will design that data on purpose to cover edge cases and dirty data, so your test resembles hard reality instead of a brochure's happy case.

This matters for two reasons that reinforce each other. The first is privacy: testing with real customer data exposes personal information —names, emails, amounts, addresses— in a development environment that has no business seeing it, and that is a legal and ethical risk not worth taking. The second is quality: the real data you have on hand tends to be normal cases, because rare cases are rare by definition. If you only test with what you already have, you never see the 50,000-peso order or the one with an empty name until it shows up in production. Synthetic data lets you manufacture the hard case instead of waiting for it to appear.

Connection to the module: in lesson 2 you connected order-triage to a safe destination —the CRM's sandbox key. But a safe destination with poor inputs is only half a test. This lesson gives it the inputs: the orders you are going to feed the workflow. It is the second point of the checklist. In lesson 5 you are going to pin this synthetic data so the test is reproducible, and in lessons 7 and 8 you are going to write assertions that state, for each of these orders, which classification you expected from the agent. So the data you design here is the backbone of everything that follows: choose it well.

Crash-test dummies

Think about how car safety gets tested. Nobody straps a real person in and crashes them into a wall to see if they survive —that would be monstrous, and also a bad experiment, because every person is different and you could not repeat the test. They use crash-test dummies: figures built on purpose to resemble a human body in whatever matters for the test —weight, height, how the joints bend, where the organs sit— but that are nobody at all. And they do not use a single dummy: they use an entire family. One the size of a large adult, one of an average woman, one of a child, one of a baby in a car seat. Because a seatbelt that protects an 80-kilogram adult can strangle a 20-kilogram child, and you only find that out if you test with both.

Synthetic data is your workflow's dummies. It is fake —it does not correspond to any real customer, so nobody gets hurt if something breaks— but representative: it resembles real data in whatever matters for the test, the shape of the order, the fields it carries, the ranges of the amounts. And, like the dummies, you do not use just one: you use a family that covers the range of cases, including the dangerous ones. The normal order is the average adult. The 50,000-peso order is the large dummy. The order with the empty name is the baby in the car seat: the rare case where your "seatbelt" —the workflow's logic— is most likely to fail.

Let's define the term precisely, because you will use it throughout the module. A piece of synthetic data is data artificially generated for a test, that imitates the structure and behavior of real data without being any of it. "Synthetic" is the opposite of "collected": you did not pull it from Cumbre's real operation, you manufactured it. The word comes from "synthesis," building something out of parts; here you assemble a believable order out of your knowledge of what orders look like, without copying a single one.

Why you do not test with real data

It is tempting to grab a handful of real orders from Cumbre —"I already have them, they are realistic, why invent anything?"— and test with those. Do not, for three reasons.

Privacy and personal data. A real order carries information about a real person or business: the coffee shop's name, maybe the name of whoever placed it, an email, a phone number, amounts that reveal how much that customer buys. That is personal information (sometimes called PII, for Personally Identifiable Information: information that identifies a person). Putting it into your dev environment —which might run on your laptop, which might get backed up unencrypted, whose data might end up in a log— exposes it somewhere it should not be. Many data protection regulations forbid this outright, and even if yours did not, it is a liability you gain nothing by taking on. The simple rule: real customer data does not leave production.

Real data is almost all happy cases. There is a statistical trap here. Most of your real orders are normal —that is exactly why they are the majority. If you grab twenty real orders at random to test with, chances are all twenty are cases the workflow already handles well, and not one of them is the giant order or the incomplete one that breaks the logic. Testing with real data gives you a false sense of coverage: you tested twenty things, but they were the same easy thing twenty times over.

You cannot manufacture the case you need. If you want to test what order-triage does with an order of exactly 50,000 pesos with no customer name, with real data you would have to wait for that order to actually exist —and you would hope it never exists in production without having been tested first. With synthetic data you manufacture it in ten seconds. Total control over the input is exactly what you need to test the edges.

Approach 1: a fixed dataset written by hand

The simplest approach, and for many tests, the best one: you write a small set of orders by hand that covers the cases you care about. "Fixed" means it does not change between runs —the same orders, always— and that is a virtue: a test with fixed inputs is reproducible, which is exactly what lesson 5 is going to exploit.

For order-triage, the cases organize themselves naturally around the agent's three outputs —approve, manual review, missing information— plus a category no guide should skip: dirty data. Here is the dataset, thought of as a family of dummies:

[
  {
    "case": "happy-path-approve",
    "order_id": "ORD-TEST-001",
    "customer_name": "Café Aurora",
    "amount": 1200,
    "currency": "MXN",
    "items": 3,
    "note": "normal order, low amount, should auto-approve"
  },
  {
    "case": "large-order-manual-review",
    "order_id": "ORD-TEST-002",
    "customer_name": "Tostaduría del Sur",
    "amount": 52000,
    "currency": "MXN",
    "items": 40,
    "note": "high amount, should go to manual review"
  },
  {
    "case": "missing-customer-name",
    "order_id": "ORD-TEST-003",
    "customer_name": "",
    "amount": 900,
    "currency": "MXN",
    "items": 2,
    "note": "customer name is missing, should be flagged as incomplete"
  },
  {
    "case": "dirty-amount-as-string",
    "order_id": "ORD-TEST-004",
    "customer_name": "Rincón del Café",
    "amount": "3,500.00",
    "currency": "MXN",
    "items": 8,
    "note": "amount comes in as text with commas, not as a number"
  },
  {
    "case": "dirty-whitespace-and-case",
    "order_id": "ord-test-005 ",
    "customer_name": "  bodega LA MONTAÑA  ",
    "amount": 1500,
    "currency": "MXN",
    "items": 5,
    "note": "extra whitespace and inconsistent capitalization in the text fields"
  },
  {
    "case": "edge-zero-amount",
    "order_id": "ORD-TEST-006",
    "customer_name": "Café Aurora",
    "amount": 0,
    "currency": "MXN",
    "items": 0,
    "note": "edge case: amount and item count are zero, is this a valid order or an error?"
  }
]

Notice how each dummy is built. The case field is not part of a real order —Cumbre does not send a case in its orders— it is a label you added yourself to know what each row tests and what you expect from it. In lesson 7 that label becomes the key for writing the right assertion ("case large-order-manual-review must come out as manual review"). The note field is a comment for humans, for the same reason. The rest of the fields —order_id, customer_name, amount, currency, items— imitate the shape of a real Cumbre order.

And look at the six cases as a family:

  • happy-path-approve is the average adult: the easy case the workflow must handle well. If this one fails, something is very broken.
  • large-order-manual-review is the large dummy: it tests that the manual-review threshold works.
  • missing-customer-name is the baby in the car seat: the incomplete case where the logic has to catch the gap.
  • dirty-amount-as-string and dirty-whitespace-and-case are dirty data: orders that in the real world arrive badly formatted —the amount as text with commas, names with extra whitespace and random capitalization. Almost every real order carries some of this grime, and a workflow you only tested with clean data breaks on the first real order.
  • edge-zero-amount is a deliberate edge: a case where even you are not quite sure what should happen. Is a zero-peso order valid? Is it an error? Including it forces you to decide before production decides for you.

This dataset lives in the cumbre-automations repository, in a file like test/fixtures/orders.json —"fixture" is the standard name for a set of fixed test data. Living in Git has a big advantage: it is versioned just like the workflow, so when someone adds a new case, it gets recorded, and when you test, everyone on the team tests with the same dummies.

Approach 2: a Code node that generates them in memory

When you need more volume —a hundred orders instead of six, to see how the workflow behaves under load— writing each one by hand gets tedious. That is where the Code node comes in: it can manufacture items in memory.

An important clarification for this guide, because it is a real limit of n8n 2.0: the Code node cannot call an API to fetch data —no fetch, no axios, no require of external libraries (only crypto and moment), no filesystem access. But it can create and transform data in memory, and that is exactly what we need: we are not going to fetch orders from anywhere, we are going to make them up with code. Generating synthetic data is exactly the kind of thing the Code node can do.

Here is a Code node, in "Run Once for All Items" mode, that generates a batch of synthetic orders:

// Generates synthetic orders in memory to test order-triage.
// It fetches nothing from any API: it makes it all up. That the Code node CAN do.

// Building blocks for believable but fake orders.
const customers = ["Café Aurora", "Tostaduría del Sur", "Rincón del Café", "Bodega La Montaña"];
const currencies = ["MXN", "COP", "PEN"];

const orders = [];

// Generate 100 "normal" orders to test behavior under volume.
for (let i = 1; i <= 100; i++) {
  // An order number with leading zeros: ORD-TEST-0001, 0002, ...
  const orderId = "ORD-TEST-" + String(i).padStart(4, "0");

  // Pick customer and currency by rotating through the lists (deterministic, not random).
  const customer = customers[i % customers.length];
  const currency = currencies[i % currencies.length];

  // Amounts that step upward to cover a range, crossing the 5000 threshold.
  const amount = 500 + (i * 137) % 8000;  // ranges from ~500 to ~8500, crosses the threshold

  orders.push({
    json: {
      case: "generated-normal",
      order_id: orderId,
      customer_name: customer,
      amount: amount,
      currency: currency,
      items: (i % 12) + 1,
    },
  });
}

// Return the 100 orders as n8n items.
// Each item is an object { json: {...} }; that is the format n8n expects.
return orders;

You run it like this: put this Code node at the start of a test workflow, execute it, and its output shows the 100 generated items.

What to expect: the node returns 100 orders, each an item with its order_id, customer_name, amount, currency, and items. The amounts step upward and cross the 5000 threshold, so among the 100 there are orders that should auto-approve and orders that should go to manual review, without you having written a single one by hand. The generation is deterministic —it uses no randomness, just arithmetic on the counter i— so if you run the node ten times, you get the same 100 orders all ten times. That is on purpose, and it is key for lesson 5: a reproducible test needs reproducible inputs, and a deterministic generator gives you exactly that.

On randomness: you could use crypto (which the Code node does allow) to inject randomness so the orders look more varied. Be careful: randomness fights reproducibility. If every run generates different orders, you cannot compare today's result with yesterday's, because the inputs changed and not just the workflow. For tests you want to repeat —almost all of them— prefer deterministic generation, like the one above. Randomness has its place in stress tests or fuzzing, which is a different story.

Combining both approaches: the fixed dataset for the cases, the generator for volume

In practice, the best of both worlds is using them together. The fixed dataset from Approach 1 covers the cases you care about one by one, with their label and their expected result —it is your carefully chosen battery of dummies. The generator from Approach 2 adds volume to see behavior under load and to catch the odd case you did not think to write by hand. A serious test pass usually runs the six labeled cases first (where you know exactly what to expect) and then the batch of a hundred (where you verify nothing explodes under volume). In lesson 8 you are going to put together exactly that combination.

A middle ground: anonymized real data

There is a third path, between inventing from scratch and using real data, worth knowing for special cases: anonymizing real data. Anonymizing means taking a real order and replacing everything that identifies a person or business —the name, the email, the phone number— with fake values, keeping only the structure and the proportions: the same amount ranges, the same frequency of empty fields, the same variety of formats.

When does it help? When the exact shape of your real data is hard to imagine, and you want the test to inherit its real messiness. For example, if Cumbre's orders have a peculiar mix of currencies and formats you would not have thought to invent, anonymizing a sample gives you that texture without exposing anyone.

The honest warning: anonymizing well is harder than it looks. It is easy to miss a field —a hidden identifier, a free-text comment where someone typed a name— and leave personal information leaking through data you thought was clean. That is why, for most tests, inventing from scratch (Approaches 1 and 2) is simpler and safer: if there was never a real piece of data, there is nothing to leak. Save anonymization for when you truly need the texture of the real thing, and do it carefully.

How you feed it to the workflow

Having the synthetic data is half the story; the other half is getting it into the workflow so it runs through it. In production, order-triage receives its orders through a Webhook —a node that waits for someone to send it an order over HTTP. But while testing you do not want to depend on something external triggering the webhook; you want to control the input yourself. There are three ways to inject synthetic data, from least to most elaborate, and each has its moment:

By hand, one order at a time. The n8n editor lets you trigger a Webhook with a body you write yourself, or you can put a temporary Manual Trigger followed by an Edit Fields node with an order. Good for quickly testing a single specific case. It is the simplest and the least repeatable.

With a Code or Edit Fields node at the start. You put the fixed dataset (Approach 1) or the generator (Approach 2) as the first node of a test workflow, and from there it flows into the rest of order-triage's logic. This way you run all six cases —or all hundred— in one pass. It is the approach you are going to use in lesson 8's full pass.

Pinning the data at the entry node. This is the most powerful one, and the one lesson 5 is going to teach in depth: n8n lets you pin a node's output, so the Webhook —or the Code node— "remembers" exactly this synthetic data and uses it on every run without you having to inject it again. It is what makes the test truly reproducible. For now just note that it exists; lesson 5 builds it out.

The connection I want you to see: the synthetic data you designed in this lesson is the data you are going to pin in lesson 5, evaluate in lesson 7, and run in lesson 8's full pass. It is not a standalone exercise; it is the input for the rest of the module. Keep it safe in cumbre-automations.

An organizational tip that will pay off in lesson 7: alongside each case's data, also save the expected response. That is, not just "this is the 52,000 order," but "this 52,000 order must be classified as manual_review." You are not going to use that field yet —assertions are lesson 7— but writing it down now, while you are designing the case and it is fresh in your mind what should happen, is much easier than reconstructing it later. The case field you already added is the label; add an expected field with the correct classification, and your fixtures are ready to be evaluated with no extra work down the road. Designing the data and its correct answer at the same time is a habit that pays for itself.

Dirty data: the part almost everyone skips

It is worth pausing on dirty data, because it is what separates a toy test from a serious one, and it is what almost everyone skips.

Real data is dirty. It does not arrive like in the brochure —round amounts, well-formatted names, every field present. It arrives with real-world grime: an amount that comes in as the text "3,500.00" instead of the number 3500, a name with extra whitespace " bodega LA MONTAÑA ", an order_id in lowercase when you expected uppercase, a field that sometimes shows up and sometimes does not, an accented character or a quote that breaks your parsing, a date in a format different from the one you expected, a null where you expected text. Every one of these bits of grime is an order that, in production, is going to pass through order-triage, and if your test never saw them, neither did the workflow, and it is going to run into them for the first time with a real order on the line.

The discipline is simple to state and easy to forget: for every clean case you test, also test its dirty version. Did you test a normal order? Test an identical one with the amount as text. Did you test an order with a name? Test one with the name full of whitespace. The two dirty- cases in the dataset above are the bare minimum; in a real system you would have more. The guiding question is: "how would this piece of data arrive if the source system sent it wrong?" —and that "wrong" includes empty values, wrong types, odd formats, duplicates, and unexpected characters.

There is an extra benefit to testing dirty data with order-triage in particular, and it is that its classifier is an AI Agent node. An AI agent is, by nature, more tolerant of mess than a rigid if —it might understand "3,500.00" as three thousand five hundred without you doing anything— but it is also more unpredictable: maybe it understands it, maybe it does not, maybe it reads it as 3.5. Testing dirty data against the agent tells you which of those three things it does, which is information you do not have until you test it. You are going to exploit this in lessons 6 and 7.

Common mistakes

Testing with a single case, the happy one (conceptual). What happens: someone generates or writes a nice order, sees order-triage approve it, and calls the test good. It is lesson 1's "it ran once and it worked," now applied to data. Why it happens: the happy case is the one you have in your head when you think about your workflow, so it is the one you write first, and it is easy to stop there. How to spot it: count your test cases and classify them. If all of them are "normal" and none is large, incomplete, dirty, or an edge case, you have a single dummy. How to fix it: use the family of dummies as a checklist —do I have the large one, the incomplete one, the dirty one, the edge one? A test dataset with no hard case at all is not testing anything, it is confirming what you already knew.

Using real data "because it is more realistic" (practical and risky). What happens: someone copies real orders from production into their dev environment to test with. Now there is personal information about Cumbre's customers on a development laptop. Why it happens: real data is right there and feels more faithful. How to spot it: if your test data has names, emails, or amounts that correspond to real customers, you are exposing personal information. How to fix it: generate synthetic data. It is just as faithful in the shape that matters for the test, without carrying the risk of exposing anyone. If you truly need it to resemble specific real data, anonymize it: replace the real names, emails, and amounts with fake ones, keeping only the structure. But for most tests, inventing from scratch is simpler and safer.

Introducing randomness where you wanted reproducibility (practical). What happens: someone generates their test data with random values so it looks more varied, and later cannot figure out why their test gives a different result every time. Why it happens: randomness seems "more realistic" and is easy to add. How to spot it: if you run your generator twice and get different data, your test is not reproducible, and that is going to collide head-on with lesson 5. How to fix it: generate deterministically —arithmetic on a counter, lists you rotate through, fixed values— not with Math.random(). Save randomness for stress tests where variety is the point; to verify behavior, you want the same inputs every time.

Exercises

Exercise 1 — Design the family of dummies. order-triage is going to add a rule: orders from new customers (that the CRM does not know) go to manual review, regardless of amount. Design four synthetic test cases to cover this new rule, with their case label and a note on what you expect. Think about the whole family, not just the happy case.

See solution

A reasonable family:

[
  { "case": "known-customer-small", "customer_name": "Café Aurora", "amount": 1000, "note": "known customer, low amount → approve" },
  { "case": "new-customer-small", "customer_name": "Cafetería Recién Nacida", "amount": 1000, "note": "NEW customer, low amount → manual review due to the new rule" },
  { "case": "new-customer-large", "customer_name": "Startup del Café", "amount": 60000, "note": "new customer AND high amount → manual review (two reasons)" },
  { "case": "known-customer-large", "customer_name": "Tostaduría del Sur", "amount": 60000, "note": "known customer but high amount → manual review due to the threshold" }
]

Why it works: the new rule introduces a dimension —known/new customer— that crosses with the one that already existed —low/high amount. A good family covers all four combinations of those two dimensions, to verify that each reason for "manual review" works on its own and that they do not step on each other. The new-customer-small case is the critical one: it is the only one where the new rule is the only reason to go to review, so if something is broken, that is where it shows up.

Exercise 2 — Dirty up a clean case. Take this clean order and write three dirty versions of it, each with a different kind of grime. Explain what each one would test. Clean order: { "order_id": "ORD-TEST-010", "customer_name": "Café Aurora", "amount": 2500 }.

See solution

Three kinds of grime of different natures:

{ "order_id": "ORD-TEST-010", "customer_name": "Café Aurora", "amount": "2.500" }

Tests: the amount comes in as text with a different thousands separator (period, European/Latin American style). Does the workflow read it as 2500 or as 2.5? This is the most dangerous one: if it reads it as 2.5, a 2500-peso order looks like a 2.5-peso one.

{ "order_id": "ORD-TEST-010", "customer_name": null, "amount": 2500 }

Tests: the name comes in as null, not as an empty string "". A workflow that checks if name === "" does not catch a null; they are two different shapes of "the name is missing."

{ "order_id": "ORD-TEST-010", "customer_name": "Café Aurora 🎉☕", "amount": 2500 }

Tests: the name carries emoji and special characters. Does the CRM accept them? Do they break some parsing step or some URL? Unexpected characters in free text are a classic source of failures.

Why it works: the three kinds of grime are of different types —number format, null vs. empty value, special characters— and that variety is the point. Dirtying only one way (always the same kind of grime) leaves gaps; reality gets dirty in every way at once.

Exercise 3 — Deterministic or not. Look at this fragment of a Code node that generates test amounts and say whether it is reproducible. If it is not, fix it so it is, while keeping the variety of amounts.

const amount = Math.floor(Math.random() * 10000);
See solution

It is not reproducible. Math.random() returns a different number every time, so every run of the node generates different amounts. A test with this input gives a different result on every run, which makes it impossible to compare the effect of a change in the workflow against the effect of a change in the data.

A deterministic version that keeps the variety, using the loop counter:

// Assuming you are inside a for loop with counter i:
const amount = 500 + (i * 137) % 9500;  // varies across values of i, but is the same for the same i

(i * 137) % 9500 produces amounts that jump around the whole range —137 is a number that "mixes" well— but deterministically: order number 7 always has the same amount, no matter when you run the node. You get variety without losing reproducibility.

Why it works: reproducibility does not fight variety; it fights uncontrolled randomness. A generator can produce a hundred amounts very different from each other and still give the same hundred every time it runs, as long as the "variation" comes from something fixed (the counter) and not from something random (Math.random). That distinction is what lesson 5 needs so your tests repeat identically.

Summary and next step

In this lesson you saw what synthetic data is —fake but representative orders, your workflow's crash-test dummies— and why you do not test with real data: for privacy (customers' personal information does not leave production) and for coverage (real data is almost all happy cases, and does not let you manufacture the hard case you need). You learned two ways to generate it: a fixed dataset written by hand, with labeled cases that cover the whole family —happy, large, incomplete, dirty, edge— and that lives versioned in cumbre-automations; and a Code node that manufactures it in memory for volume, done deterministically so as not to lose reproducibility. And you paused on dirty data —amounts as text, names with whitespace, null values, odd characters— the part almost everyone skips and the one that breaks the most workflows in production, with the discipline of "for every clean case, also test its dirty version."

With this you check off the second point of the checklist: you use synthetic data that covers edge cases and dirty data.

Before moving on you should be able to: explain in one sentence why you do not test with real data; name the family of cases every test dataset should cover; and say why deterministic generation matters for a reproducible test.

You now have a safe destination (lesson 2) and safe, varied inputs (this lesson). But there is a danger neither the sandbox key nor the synthetic data fully solves: the workflow still fires its side effects when it runs. Lesson 4 gets into the dry run and guarding side effects: what a dry run exactly is, why in n8n it is not a button but a design pattern you build yourself, and how to cut off the workflow before the node that writes to the CRM —or redirect it to a node that does nothing— so you can observe its behavior without triggering the irreversible action.

Resources