When an LLM is asked to return structured data, the hard part is not whether the response looks plausible. The hard part is deciding what should count as correct, what should fail fast, and what should be monitored separately. For teams shipping product features on top of model output, that distinction matters more than any single prompt trick.

This tutorial shows a reproducible way to test LLM structured outputs against JSON Schema, regex rules, and golden files. The goal is not to turn language models into deterministic functions, because they are not. The goal is to build a contract-oriented test harness that can tell you, with high signal, whether a response is valid, reviewable, and safe to consume.

A useful rule of thumb, schema checks answer “is this structurally acceptable?”, regex rules answer “does this field obey local constraints?”, and golden files answer “did the model drift in a way that matters to humans or downstream systems?”

Why structured output testing needs more than semantic judgment

A common anti-pattern is to inspect a model response manually and decide that it “looks good enough.” That works for demos and fails quickly in production, because structured outputs are consumed by code, not by vibes. A frontend may expect a date in ISO 8601 format, a downstream service may expect a finite enum, and a workflow engine may require every required field to be present.

For software teams, the testing question is closer to software testing than to prompt writing. You are not just reviewing text quality, you are validating a contract. That contract usually has three layers:

  1. Shape: Are the required keys present and typed correctly?
  2. Local validity: Do specific values match the rules you care about, such as email format, ID format, or allowed ranges?
  3. Regression behavior: Did the model change output semantics, phrasing, or field selection in a way that breaks expectations?

These layers are different enough that they should not share a single assertion type. If you force everything into one “equal expected text” check, tests become fragile. If you validate only the JSON parse step, tests become too weak. A good harness separates failure categories so debugging stays cheap.

The testing stack at a glance

A practical stack for LLM structured output validation usually has four pieces:

  • Parser: turn raw text into JSON or a structured object.
  • Schema validator: verify shape, required keys, types, and constraints.
  • Rule checks: apply targeted assertions such as regex, ranges, cross-field checks, and business logic.
  • Golden comparison: compare current output to a blessed fixture set with tolerance rules.

This aligns well with conventional test automation practice, where fast validation runs in local development and broader suites run in continuous integration.

The hidden cost is not just implementation time. It is ownership. Someone has to decide when schema changes are intentional, when new examples belong in the golden set, and when a test failure is a model regression versus a test that encoded an outdated assumption.

Step 1, define the contract before writing tests

Before writing a single assertion, define what the output is for. A structured response contract usually needs one of these patterns:

  • Extraction: model pulls fields from a document, email, or ticket.
  • Classification: model returns labels or categories.
  • Transformation: model rewrites input into a normalized schema.
  • Generation with constraints: model returns a JSON object to drive downstream logic.

For each pattern, identify the fields that are truly contract-bound. Not every field should be equally strict. For example:

  • Required: status, priority, summary
  • Strongly constrained: status must be one of open, pending, closed
  • Loosely constrained: summary may vary in wording but must not be empty
  • Observability-only: model_version, confidence

That separation helps you avoid overfitting tests to the model’s exact phrasing.

A useful pattern is to write the contract as if a consumer engineer had to build against it without reading prompt internals. If they would need a schema, a few field rules, and a change log, that is what your tests should mirror.

Step 2, use JSON Schema for structural validation

JSON Schema is the right first line of defense for LLM JSON schema validation because it expresses the shape of the output in machine-checkable terms. It can validate required fields, types, enums, string lengths, array lengths, nested objects, and some content constraints.

A minimal example:

{ “type”: “object”, “required”: [“status”, “summary”, “tags”], “properties”: { “status”: { “type”: “string”, “enum”: [“open”, “pending”, “closed”] }, “summary”: { “type”: “string”, “minLength”: 1 }, “tags”: { “type”: “array”, “items”: { “type”: “string” }, “maxItems”: 5 } }, “additionalProperties”: false }

That schema catches several failure modes immediately:

  • missing keys
  • wrong types, such as an object where a string is expected
  • invalid enum values
  • accidental extra keys that a downstream consumer does not know how to ignore

Why additionalProperties: false matters

Many teams skip this because they worry about brittleness. In practice, leaving it open can hide accidental prompt drift. If a model starts returning reasoning, debug, or other fields that your application never intended to consume, tests may still pass while downstream assumptions quietly break.

The tradeoff is that strict schemas require explicit change management. That is a feature, not a bug, if the output is part of a contract. If you expect evolving fields, version your schema rather than loosening every test.

Example validation in JavaScript

import Ajv from "ajv";

const ajv = new Ajv({ allErrors: true }); const validate = ajv.compile(schema);

const ok = validate(output); if (!ok) { console.error(validate.errors); }

This is intentionally boring. Boring is good here. The job of the validator is to tell you exactly where the object violates the contract, not to interpret the semantics of the response.

Step 3, add regex checks for field-level rules

JSON Schema can express some patterns, but regex checks are often clearer for localized constraints. Use them for fields where format matters more than general shape.

Common examples:

  • ticket IDs like ABC-1234
  • ISO-like references
  • account numbers with fixed prefixes
  • human-readable codes with a constrained alphabet

A regex rule is appropriate when the field is supposed to match a machine-facing format and failures should be obvious.

const ticketIdPattern = /^[A-Z]{3}-\d{4}$/;

expect(ticketIdPattern.test(output.ticket_id)).toBe(true);

Keep regex rules narrow

Do not use regex to encode the whole business meaning of a field. That becomes unreadable quickly. Use schema for structure, regex for format, and explicit code for business logic.

For example:

  • schema says ticket_id is a string
  • regex says it matches ABC-1234
  • code says IDs starting with ZZZ are not allowed in production workflows

That split keeps each rule understandable and easier to review.

Common regex failure modes

  • over-escaping, especially in test fixtures
  • accidental acceptance of whitespace or punctuation
  • locale-specific characters that a simple ASCII pattern rejects
  • false confidence when the regex validates format but not meaning

If a field needs more than a regex, make that explicit in the test name. For example, should reject unsupported locale codes is clearer than should match regex.

Step 4, use golden files for regression detection

Golden file testing for LLM outputs is where many teams gain practical leverage. A golden file is a saved fixture of expected output, checked into version control. It is useful when exact output matters enough to detect drift, but not so much that every character must be frozen forever.

Golden files work especially well for:

  • prompt templates whose output should remain mostly stable
  • extraction tasks where field content matters more than wording
  • transformation tasks where canonicalization is the goal
  • multi-field outputs where subtle drift can break downstream consumers

A representative golden fixture might look like this:

{ “input”: “Refund request for order 1234”, “expected”: { “status”: “open”, “category”: “billing”, “priority”: “medium” } }

Exact match is usually too strict

For LLM outputs, golden testing should often be partial or normalized rather than raw string equality. Useful normalization steps include:

  • stable key ordering
  • trimming whitespace
  • ignoring known metadata fields like timestamps or request IDs
  • normalizing case if case is not semantically important

A better golden comparison checks canonicalized JSON, not a raw text blob.

Decide what kind of drift matters

There are at least three kinds of drift:

  1. Benign drift: wording changed but the contract remains valid
  2. Structural drift: keys, types, or required values changed
  3. Semantic drift: output is structurally valid, but meaning shifted in a way that matters to the application

Golden files are most valuable for structural and semantic drift. They are weaker for purely stylistic changes, and that is okay if the downstream system does not care about style.

A practical test matrix

The most reliable setup does not rely on one huge fixture. It uses a small but intentionally varied matrix.

Include examples that cover:

  • happy path input
  • missing or ambiguous input
  • long input
  • conflicting instructions
  • edge case values
  • multilingual or locale-sensitive input, if relevant
  • inputs likely to trigger empty or partial output

For each case, define the expected failure category if the model misbehaves.

Case Primary check Common failure category
Simple extraction JSON Schema Missing field, wrong type
Ticket normalization Regex Invalid code format
Classification Golden file Wrong label or drifted category
Nested object output Schema + field rules Partial object or extra keys
Ambiguous input Golden file + tolerance Overconfident or inconsistent response

This matrix makes reviews easier. A teammate can look at the test and understand why the fixture exists, not just what value it asserts.

Example, schema plus regex plus golden file in one test flow

A clean implementation often looks like this:

  1. Call the model with a fixed prompt and fixed seed if the provider supports it.
  2. Parse the response into JSON.
  3. Validate the parsed object against JSON Schema.
  4. Apply field-level regex and business rules.
  5. Compare selected fields against the golden fixture.

Here is a compact Playwright-based example for an API-backed flow:

import { test, expect } from "@playwright/test";
import Ajv from "ajv";
import fs from "fs";

const schema = JSON.parse(fs.readFileSync(“schema.json”, “utf8”)); const golden = JSON.parse(fs.readFileSync(“fixtures/refund-ticket.json”, “utf8”)); const ajv = new Ajv({ allErrors: true }); const validate = ajv.compile(schema);

test("structured output contract", async ({ request }) => {
  const res = await request.post("/api/llm/classify", {
    data: { input: "Refund request for order 1234" }
  });

const output = await res.json(); expect(validate(output)).toBe(true); expect(output.ticket_id).toMatch(/^[A-Z]{3}-\d{4}$/); expect({ status: output.status, category: output.category, priority: output.priority }).toEqual(golden.expected); });

This pattern keeps the assertions legible. If the test fails, you immediately know whether the problem was schema, format, or regression drift.

Failure categories that make debugging cheaper

The biggest maintenance win is not test coverage, it is failure classification. Separate failures into categories your team can act on.

1. Parse failure

The response is not valid JSON or cannot be parsed into the expected structure.

Action: inspect prompting, response formatting, and model settings.

2. Schema failure

The object is parseable but fails structural validation.

Action: check missing keys, type mismatches, or extra keys. Schema failure usually means the contract was violated.

3. Rule failure

The object passes schema but violates a regex or business rule.

Action: inspect field-level constraints or ambiguous prompt wording.

4. Golden drift

The object is valid but no longer matches the expected fixture.

Action: determine whether the new output is acceptable. If yes, update the fixture with review. If no, treat it as a regression.

If every failure looks the same in CI, the team will eventually distrust the suite. Classification is not cosmetic, it is how test signals stay usable.

Handling nondeterminism without giving up on regression tests

LLMs are probabilistic, so naive equality checks can become noisy. There are several ways to reduce false failures without diluting the suite.

  • lower temperature for contract tests
  • pin model version when possible
  • test a bounded set of inputs instead of random prompts for the main regression suite
  • normalize unimportant fields before comparison
  • validate invariants instead of exact prose when prose is not contract-bound

A common mistake is to overcompensate for nondeterminism by weakening assertions until they stop protecting anything. That trades flaky tests for useless tests. Better to preserve strong assertions on the fields that matter and use tolerance only where the business allows it.

CI integration and why it should be boring

Contract tests for LLM outputs belong in continuous integration, but the pipeline should be designed to keep costs bounded.

A practical layout is:

  • pre-merge: small fast set, schema and regex checks, one or two golden fixtures
  • nightly: broader fixture matrix, edge cases, prompt variants, and model version checks
  • release gate: smoke validation against the exact production prompt and schema

You do not want every pull request to run an expensive, sprawling matrix unless the model call is cheap and stable. The real cost is not just API usage. It is triage time, reruns, and context switching.

Example GitHub Actions step:

name: llm-contract-tests
on: [pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm test -- llm-contract

If the model provider is external, add retries carefully. Retries can hide transient failures, but they can also mask genuine nondeterminism. Limit retries to transport errors, not validation failures.

How to decide what belongs in schema, regex, or golden files

Use this decision rule:

  • Schema when the test concerns object shape, required fields, types, and basic constraints
  • Regex when a single field has a strict format
  • Golden file when you need regression detection over a whole output or a meaningful subset of fields

If a test starts as a golden comparison but all you really care about is one field, reduce it to a field assertion. Smaller tests are cheaper to maintain.

If a test starts as a regex but the rule grows into business logic, move it into code. Regex is a format tool, not a workflow engine.

If a schema grows too large, split it into reusable fragments or versioned schemas. Large monolithic schemas can become as hard to maintain as the prompts themselves.

Operational tradeoffs and hidden costs

The visible cost of structured output testing is writing assertions. The hidden costs are usually elsewhere:

  • maintaining the schema as product requirements evolve
  • reviewing fixture updates without letting regressions through
  • explaining failures to non-ML engineers
  • handling provider upgrades and model version shifts
  • keeping test data representative without exposing sensitive content

This is why documentation-first testing pays off. A readable schema, named test fixtures, and explicit failure categories reduce ownership concentration. New contributors can understand why a test exists, not just how it is implemented.

A team that uses structured response contract tests well tends to treat the output like an API. That is the correct mental model. The model is not a magic box, it is a component with a contract, drift risk, and a lifecycle.

A minimal checklist for production teams

Before shipping a structured LLM feature, confirm the following:

  • the output schema is versioned and documented
  • critical fields have JSON Schema validation
  • format-sensitive fields have regex or equivalent checks
  • regression fixtures cover the top user flows and the sharp edges
  • failures are categorized clearly in CI
  • fixture updates require review, not silent overwrite
  • model version changes trigger at least a targeted regression pass

If you can answer those items clearly, you are testing the system instead of merely inspecting samples.

Final recommendation

If your team needs to test LLM structured outputs against JSON Schema, regex rules, and golden files, start with the smallest possible contract that still protects downstream code. Use schema for shape, regex for localized format rules, and golden files for regression detection on the fields that really matter. Keep the suite readable, versioned, and split by failure category.

That approach is not glamorous, but it is durable. It gives engineers a way to distinguish between harmless variation and a real contract break, which is exactly what structured output testing should do.