AI agents

Schema Tests for Approval Requests: Fixtures That Catch Edits

Antonio Evans11 min read

A developer's desk with two monitors: one showing a JSON schema diff with a red X over a removed field, the other showing a test runner with green checkmarks next to fixture files named card_v1.json, card_v2.json.
Key points

Key points

  1. 1An approval card is a stored contract, so a schema edit that looks harmless in review can make old cards unreadable or invisible in queues.
  2. 2Versioned fixture files hold real card and queue payloads for each schema version, and the build replays all of them against the current schema.
  3. 3Round-trip tests catch lossy serialization, and negative tests prove that dangerous edits (new required fields, tightened enums, type changes) are rejected rather than merged.
  4. 4A CI gate that treats any stored-fixture failure as a build failure is what stops the breaking edit, not the review checklist.

The compatibility contract for approval cards

An approval card is not just a payload. It is a message that a human must understand, with a requester, an approver, a decision state, an amount or scope, and a record of what was known when the decision was made. The same is true of queue items: a pending approval sits in someone's queue with an SLA and an escalation path, and its fields drive routing, reminders and audit reporting. Architecture guidance for human-in-the-loop approval workflows makes the same point about the decision record: approval gates exist to give the organization traceability for who approved what and why, with the evidence that justified the decision attached the StackAI approval workflow guide.

Once you accept that, the schema stops being an implementation detail and becomes a contract with three groups of readers:

  1. Old cards already stored. A card created under version 1 sits in the database, in a queue index, in an archive, or in a message topic. It will be read by code running the current schema.
  2. Old code still running. During a rollout, two application versions can run against the same data. A rename or a type change deployed on one side can break the other ByteByteGo on schema evolution.
  3. Downstream consumers. Reporting jobs, notification workers, dashboards and replay tools read the same records and often lag behind the producer by several versions.

The practical question for an approval system is narrower than general schema design: if I change a rule on this card, which stored object stops working, and how would I know before merge? ByteByteGo's account of schema evolution is blunt about the failure mode: migrations look clean in staging, then unrelated components fail in production because data written under one version is read under another ByteByteGo.

A useful contract to write down, and then encode in tests, has four clauses:

ClauseRuleTypical breaking editTest that catches it
RequiredA field added as required only after every producer writes itAdding costCenter to required in one releaseOld fixture fails current schema validation
OptionalFields may be added as optional with a safe defaultMaking a formerly required field optional without a defaultRound-trip test loses no value
TypesTypes may widen, never narrow or change meaningamount from integer cents to float dollarsNegative test asserts the old fixture is rejected
EnumsNew values may be added; existing values may not be removed or renamedDropping escalated from status enum while cards carry itOld fixture with that status fails validation

That table is the whole contract in miniature. Notice what is absent: nothing about field order, nothing about JSON formatting, nothing about which database column stores the field. Compatibility is about meaning and validation, and the fixtures should test exactly that.

Fixtures that represent real cards, not toy objects

The most common reason schema tests miss a breaking edit is that the fixtures are too clean. A fixture generated by serializing a freshly constructed object in a unit test contains only fields the current code sets. It never contains the legacy fields, the null values, the enum member that was removed, or the nested decision history that real cards accumulate.

Build fixtures from the opposite direction: take real shapes and freeze them. Three sources work well.

  • Production samples, scrubbed. Export a sample of stored cards and queue items per schema version, replace personal data with synthetic values, and commit them as JSON files. Keep the structure and the field values that matter (statuses, amounts, timestamps, nesting).
  • Queue snapshots. Approval queues often live in a separate index or materialized store with fields the card itself does not have: queue position, assigned approver, SLA deadline, escalation level. Those fields break independently of the card body, so they need their own fixtures.
  • Edge cases you already know about. A card with an empty approver list, a card whose amount is zero, a card approved after a reassignment, a card returned for correction and resubmitted. The approval workflow versioning analysis points out that returned documents and substitute approvers are exactly where migration logic goes wrong, because they cross the boundary between old and new rules the approval rule versioning article.

Store them in a directory keyed by version, alongside the schema version they were written under:

fixtures/
  approvals/
    card-v1-pending.json
    card-v1-escalated.json
    card-v1-returned-resubmitted.json
    card-v2-dual-approval.json
  queues/
    queue-v1-sla-breach.json
    queue-v2-reassigned.json

Every file carries the schema version in its name or in a header field, so the test runner can partition fixtures by origin. Do not "upgrade" old fixture files when the schema changes. The point of a fixture is that it stays frozen at the version it was written under. A small manifest, or a filename convention, is enough to record the lineage.

One caution on size: these fixtures are a data corpus with a validation check, not a property-based fuzzer. A dozen carefully chosen real shapes per version will catch more than a thousand generated objects, because the generated ones share the current code's assumptions.

Round-trip tests: serialize, deserialize, compare

A round-trip test reads a stored fixture with the current code, writes it back, and compares the result with the original. If a field is dropped, renamed, coerced, or given a different default, the comparison fails. This is the cheapest test to write and it catches the most common lossy edits.

The comparison rule matters more than the test framework. Compare parsed structures, not raw text: key order and whitespace are irrelevant, but a value that changes from "amount": 1250 to "amount": 12.5 is a real failure. Use a canonical form, either exact equality on the parsed object or an explicit tolerance rule for timestamps and floats.

The structured schema validation features matter here. When you add optional fields with dependencies, the round trip is where you find out whether the new schema's conditionals behave as intended. JSON Schema's dependentRequired keyword, for example, makes one field required when another is present, without requiring it otherwise the JSON Schema conditionals reference. That is useful for approval cards, where a rejection reason should be required only when the decision is rejected. But it also means an old card that has decision: rejected and no reason will fail the new schema unless you backfill or relax the rule, and the round-trip test over an old fixture will tell you immediately.

A minimal round-trip test in Python looks like this. It is intentionally short, because the hard part is the fixture corpus, not the code.

import json
from pathlib import Path
import pytest
from approvals.schema import Card

FIXTURES = Path("fixtures/approvals")

@pytest.mark.parametrize("path", sorted(FIXTURES.glob("card-*.json")), ids=lambda p: p.name)
def test_card_round_trip(path):
    original = json.loads(path.read_text())
    card = Card.from_dict(original)
    serialized = card.to_dict()
    assert serialized == original, f"{path.name} is not stable under the current schema"

Two refinements make this test earn its place.

First, validate the original fixture against the historical schema as well as the current one. If the fixture no longer validates against its own version's schema, the fixture has been tampered with and its evidence value is gone. This is a fixture hygiene check, and it fails loudly when someone edits an old file to make the build green, which is exactly the behavior the gate exists to prevent.

Second, run the round trip through the real serialization path. If cards move between services as Avro or Protobuf, test at that boundary, not at the ORM. A compatibility check that exercises a hand-written dict conversion but not the wire format proves less than you think. The warning from schema compatibility practice is that schema files, serializer settings and wire-format expectations travel together, and a producer can connect fine while its serializer points at the wrong schema AutoMQ on schema compatibility policy.

Negative tests: prove the dangerous edit is rejected

Round-trip tests prove the happy path. Negative tests prove the gate has teeth. They are written from the perspective of the reviewer: for each edit you never want to reach production, there is a test that constructs the edited schema and asserts that a stored fixture fails against it.

This is where teams often stop too early. Validating fixtures against the current schema catches breakage only if the current schema is actually checked in and loaded by the test. A negative test makes the rule explicit and survives refactoring, because it names the edit.

Cover at least these five edits:

Edit under testMechanismExpected result on a v1 fixtureWhy it matters for approvals
New required fieldField added to requiredValidation error naming the fieldOld cards have no value for it; UI and routing code assume presence
Narrowed enumValue removed from enum listValidation error naming the valueStored cards with that status become unreadable or are coerced
Type changestring to number (e.g. amount)Validation errorSilent corruption in dashboards and reports
Renamed fieldOld name removed, new name addedValidation error or round-trip mismatchQueues keyed on the old name silently empty
Conditional tighteningNew dependentRequired or if/then ruleValidation error on the old fixture that violates itRejection cards without reasons fail validation after deploy

Each negative test needs a way to assert the failure is the one you intended, not any failure. Assert on the error's location and keyword, not just on "invalid". In JSON Schema validators, the reported error path and keyword identify the rule that fired. A test that asserts only is_valid == False will pass when the validator is misconfigured or the schema is broken in a different way.

The conditional keywords deserve their own tests because their behavior surprises people. The if/then/else construction in JSON Schema is a useful way to express "when the decision is rejected, a reason is required," but practitioners repeatedly hit cases where the then branch does not fire as expected because the if subschema matches more broadly than intended this JSON Forms discussion on if/else validation. A negative test that pins the rule against a stored rejected card converts that surprise into a build failure.

There is a broader lesson from schema registry practice: the compatibility mode determines which changes are allowed in which direction, and the same change can be safe in one mode and breaking in another the Confluent schema evolution reference. Approval cards have readers that lag, so the safe default is full compatibility for the card body: add optional fields with defaults, never remove required ones. If your team deliberately chooses a narrower mode, write the choice down next to the tests, because the negative tests are the only place it becomes enforceable.

The CI gate that blocks the pull request

Tests only prevent breakage if they run on every pull request and block merge when they fail. The gate has four parts:

  1. Schema diff detection. Run the full fixture suite whenever any schema file, serializer, or shared model changes, and run it on a schedule against the main branch to catch drift.
  2. Fixture corpus execution. Every stored fixture is validated against the current schema and round-tripped through the real serializer.
  3. Negative suite execution. The edited schema is checked against the prohibited-change tests.
  4. A single required status. Name it clearly (approvals-contract) and make it a required check on the default branch. A gate that reports but does not block is a dashboard, not a gate.

The pipeline shape is the same one that database teams use for schema changes: versioned migrations, automated validation against a disposable environment, a reviewable diff, and a gate before promotion the database CI CD guide. GitHub Actions is a fine default because the required-check wiring is native:

name: approvals-contract
on:
  pull_request:
    paths:
      - "approvals/schema/**"
      - "approvals/models/**"
      - "fixtures/approvals/**"
      - "fixtures/queues/**"
jobs:
  contract:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements.txt
      - name: Validate every stored fixture against current schema
        run: pytest tests/contract/test_fixtures.py -q
      - name: Round trip through the real serializer
        run: pytest tests/contract/test_round_trip.py -q
      - name: Prohibited edits must be rejected
        run: pytest tests/contract/test_negative.py -q

Three rules make the gate trustworthy in practice.

No fixture edits in schema pull requests. If a schema change requires changing an old fixture, that is the signal to stop and redesign the change. Allow fixture addition (a new version's fixtures) only in a separate path, and require a second reviewer for any deletion or modification of an existing fixture file. This is the same principle as treating a schema compatibility policy as an operational contract rather than a suggestion AutoMQ.

Report the failing object, not the failing test. When the gate fails, the error message should name the fixture file, the schema version it was written under, and the path within the object that broke. Developers fix what they can see. A message that says "3 fixtures failed" gets re-run until it passes; a message that says card-v1-escalated.json (v1): /decision missing required property reason gets fixed.

Keep a compatibility budget visible. A useful number for the gate: the count of distinct schema versions present in stored fixtures. If that number grows with every release and never shrinks, the migration is not happening. The gate can warn when a version has had no live fixture activity for a defined window, which is the moment to schedule its retirement, not the moment to delete its fixtures.

Worked example: an edit that should fail and one that should pass

Consider a card schema version 1 with these rules: id (string, required), requester (string, required), amountCents (integer, required), decision (enum: pending, approved, rejected, escalated, required), and reason (string, optional). A stored fixture carries decision: "escalated" and no reason.

Edit A (should fail the gate): a developer adds reason to required so every decision has an explanation. The schema validates, the new code sets a reason on every card it creates, and unit tests pass. But the stored escalated card has no reason, and the round-trip validation fails at /reason: required property missing. The negative test that asserts "adding a required field breaks the v1 fixture" fires too. The fix is to backfill reasons in a migration, keep reason optional in the schema, and only tighten it after the corpus of old cards has been repaired.

Edit B (should pass the gate): the same developer makes reason conditionally required when decision is rejected, using if/then or dependentRequired the JSON Schema reference. The escalated card has decision: "escalated", so the conditional does not apply to it and it still validates. A rejected card fixture that already has a reason still passes. The negative test suite includes a case where a revised schema makes reason required for all decisions, and that case fails as intended, so the gate catches the over-broad rewrite.

That contrast is the decision rule worth taking away: a change is safe if every stored fixture still validates and round-trips, and every prohibited-edit test still fails for the right reason. If either half is false, the change is not ready, regardless of how clean the diff looks or how well the new code's unit tests pass.

There is a governance wrinkle with in-flight items. When approval rules change, pending cards may need to continue under the old rules or be explicitly migrated, and the choice should be recorded per item rather than assumed the approval rule versioning article. Your fixtures should reflect that: keep at least one fixture for each migration state you support, and test that the migration path converts it correctly rather than silently dropping it.

For teams that need to run these checks against many model outputs as part of the same pipeline, such as classifying rejection reasons or extracting fields from attached documents, Quanta Inference (https://quantaintelligence.ai/inference) exposes text, image and embedding models through one OpenAI-compatible API, which keeps test and evaluation calls in the same billing and key model as the rest of the build.

FAQ

FAQ

What is the minimum set of fixtures an approval system should keep?

At least one representative card and one queue item per schema version, plus fixtures for the states where rules interact: rejected with a reason, escalated without one, returned for correction and resubmitted, and reassigned to a substitute approver. The states matter more than the count, because those are the shapes that cross the boundary between old and new rules.

Should old fixture files ever be edited?

Only to scrub personal data before first commit, and then never again. An edited old fixture no longer proves that stored data from that version still works. If a schema change makes an old fixture fail, the correct response is to change the schema or write a migration, not to update the fixture to match.

How do round-trip tests differ from schema validation tests?

Validation tests ask whether a stored object conforms to the current schema. Round-trip tests ask whether the stored object survives a read and write without losing or changing values. A field can be optional and still be silently dropped during serialization, which validation alone will not catch but a round-trip comparison will.

Which compatibility mode should an approval card use?

Full compatibility for the card body is the safe default, since old cards, in-flight queues and lagging consumers all read the same records. Narrower modes are defensible only when the team writes the choice down and encodes it in the negative tests, so the allowed and prohibited edits are explicit rather than remembered.

How do we handle fixtures for data that is legally sensitive?

Scrub at export time and keep the structure: replace names, emails and identifiers with synthetic values of the same type and length, and preserve enum values, amounts, dates and nesting exactly. Store the scrubbing script next to the fixtures so the transformation is reviewable, and keep the original exports out of the repository entirely.

Sources

Sources

  1. Human-in-the-Loop AI Agents: How to Design Approval ...
  2. Schema Evolution: Changing the Contract Without ...
  3. When approval rules change, should in-flight documents ...
  4. Conditional schema validation - JSON Schema
  5. Breaking Change Week: How Schema Evolution Goes ...
  6. Json Schema validation using If-Else - "conditionally choosing required ...
  7. Schema Evolution and Compatibility for Schema Registry ...
  8. Database CI/CD: How to Build a Schema Change Pipeline
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles