Synthetic Data Generation for Testing and Fine-Tuning
Real data is scarce, private, and expensive to label. Learn how to generate high-quality synthetic data with LLMs — for unit tests, evals, and fine-tuning datasets.
Published on • August 9, 2026
AI Assistant

Labeling 10,000 examples by hand is a month of work. Real user data is often private, skewed toward happy paths, and too small. Synthetic data — generated by an LLM — fills the gap: it’s cheap, private by construction, and lets you create the edge cases your real data never shows you.
In this post, you will learn how to generate synthetic data with an LLM for three distinct jobs: unit-test fixtures, evaluation sets, and fine-tuning corpora. We’ll cover generation strategies, validation, and the failure modes that make synthetic data worse than no data.
Three jobs, three different datasets
Synthetic data isn’t one thing. The shape that’s right for each job differs:
| Job | Goal | Dataset shape |
|---|---|---|
| Unit tests | Deterministic, edge-case coverage | Small (10–100), hand-verified |
| Evals | Measure quality objectively | Medium (50–500), labelled |
| Fine-tuning | Teach the model a task | Large (1K–50K), quality-filtered |
1. Unit-test fixtures: generate edges you can’t find
Real data clusters around the happy path. Generate the adversarial inputs — the weird spacing, the unicode, the missing fields — that users will eventually send:
from google import genai
import json
client = genai.Client()
schema = {
"type": "object",
"properties": {
"text": {"type": "string"},
"variant": {"type": "string"},
},
"required": ["text", "variant"],
}
resp = client.models.generate_content(
model="gemini-2.5-pro",
contents=(
"Generate 20 edge-case English strings for a name-field validator. "
"Include: empty, only-spaces, 1-char, 255-char, unicode, emoji, "
"SQL-injection-looking, and HTML-tag text. Return JSON."
),
config={"response_mime_type": "application/json", "response_schema": schema},
)
for item in json.loads(resp.text):
test_name_field(item["text"], item["variant"])
Because these are unit tests, hand-verify the generated outputs — a test with a wrong expectation is worse than no test.
2. Eval sets: generate diversity, then verify
For evals you want breadth: many phrasings of the same intent, plus rare conditions. Generate candidates, then validate them programmatically or with a small labelled pass:
responses = []
for intent in ["refund", "cancel", "track"]:
resp = client.models.generate_content(
model="gemini-2.5-pro",
contents=(
f"Generate 30 diverse customer-support queries expressing intent "
f"'{intent}'. Vary length, tone, typos, and urgency. Some should be "
"ambiguous enough to need clarification."
),
)
responses.extend(parse_lines(resp.text))
# dedupe near-duplicates with embeddings before labelling
import numpy as np
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
embs = model.encode(responses)
keep = []
for i, emb in enumerate(embs):
if all(np.dot(emb, e) < 0.92 for e in embs[:i]):
keep.append(responses[i])
The dedup step matters: 90 identical phrasings train your eval to measure nothing.
3. Fine-tuning corpora: generate with a rubric
Fine-tuning datasets are the one place where quality beats quantity by an order of magnitude. Generate in a structured way — teacher LLM produces (input, reasoning, output) triples — and filter aggressively:
instructions = {
"classify_support_intent": "Classify the query. Output one of: refund|cancel|track|other.",
"extract_order_id": "Extract the order ID as an integer, or null.",
}
triples = []
for task, instruction in instructions.items():
resp = client.models.generate_content(
model="gemini-2.5-pro",
contents=(
f"You are a data generator. Write 100 diverse examples for the task: "
f"{instruction}\nFor each, give input, a short reasoning chain, and the "
"correct output. Cover 15% edge cases: mixed intents, missing IDs, typos."
),
)
triples.extend(parse_triples(resp.text, task))
# quality filter: only keep examples where a cheap judge model agrees
def judge_is_sure(task, inp, out):
verdict = judge_model.complete(
f"Task: {task}\nInput: {inp}\nAnswer: {out}\nConfident and correct? yes/no"
)
return verdict.text.strip().lower() == "yes"
clean = [t for t in triples if judge_is_sure(*t)]
print(f"kept {len(clean)}/{len(triples)} examples")
This generation → judge → keep loop is how you end up with a corpus that fine-tuning actually benefits from.
A warning: distribution drift
Synthetic data inherits the generator’s biases. If your generator never produces a certain accent, dialect, or failure mode, your synthetic set won’t have it — and your model won’t either. Always blend in a slice of real data and keep a holdout of real examples to detect when synthetic coverage diverges from reality.
Putting It All Together
The complete toolkit — edge-case fixtures, deduped eval sets, and a judge-filtered fine-tuning corpus — is in this gist. Run the pipeline once and you’ll have all three datasets from a single schema.
Conclusion & Next Steps
You can now generate synthetic data for unit tests, evals, and fine-tuning — with validation so it stays trustworthy. Next: set a diversity budget (min embedding distance between retained samples), log the generator version so datasets are reproducible, and periodically measure eval performance on real user traffic to catch drift from your synthetic baseline.
References / Sources
- Gemini API structured output for generation. https://ai.google.dev/gemini-api/docs
- Hugging Face datasets for storing and validating corpora. https://huggingface.co/docs
- Sentence-transformers for embedding-based dedup. https://huggingface.co/docs/sentence-transformers