iTestData

Lab Test Data: Specimen Source Category Schema

Lab test data is one of the most structurally complex domains in healthcare engineering, yet most teams treat it like any other CRUD fixture. A specimen doesn't just have a type — it has a source category (blood, urine, tissue, CSF, swab), a collection method constrained by that category, a chain-of-custody timestamp, and downstream reference ranges that are only valid for specific source/analyte combinations. Get the model wrong and your test suite will pass against a schema that no real lab system would accept.

The specific problem this article addresses: how to encode specimenSourceCategory as a first-class constraint in JSON Schema 2020-12, wire it into a Python factory, and validate it in CI without relying on a cloned production database. The patterns here apply equally to HL7 FHIR Specimen resources and custom LIS (Laboratory Information System) payloads.

By the end you'll have a working schema with discriminated conditionals, a Pydantic-backed factory that respects those constraints, and a Schemathesis smoke test you can drop into GitHub Actions today.

Modern Test Automation with AI and BDD

Practical guides for building smarter test frameworks, pipelines, and automation strategies.

Learn more

What specimenSourceCategory Actually Models in a Lab Data Schema

specimenSourceCategory is an enumerated discriminator — the field whose value gates which sibling fields are legal. In a lab payload, choosing BLOOD implies venipuncture or capillary collection methods and makes tissueSite irrelevant. Choosing TISSUE flips that: collectionMethod must be one of ["biopsy","excision","FNA"] and tissueSite becomes required. This is a classic tagged union — and JSON Schema 2020-12's if/then/else with $defs is the right tool for it, not a flat list of nullable fields.

Where it fits in test architecture: the schema sits at the contract layer, one level above your factory defaults and one level below your integration assertions. If you're already thinking in terms of layered test data responsibilities, the source-category schema belongs at the unit/contract tier — validated on every factory call, not just in E2E runs. Catching a bad collectionMethod at factory instantiation time is orders of magnitude cheaper than catching it when a downstream result-parser silently returns null.

Building the Schema and Factory: Conditionals, Enums, and Pydantic Guards

Start with the JSON Schema. The if/then blocks below encode the three most common source categories; extend the pattern for SWAB, CSF, and others as your LIS spec requires.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://itestdata.dev/schemas/lab/specimen.json",
  "type": "object",
  "required": ["specimenId", "specimenSourceCategory", "collectedAt"],
  "properties": {
    "specimenId":             { "type": "string", "format": "uuid" },
    "specimenSourceCategory": { "enum": ["BLOOD", "URINE", "TISSUE"] },
    "collectedAt":            { "type": "string", "format": "date-time" },
    "collectionMethod":       { "type": "string" },
    "tissueSite":             { "type": "string" },
    "volumeMl":               { "type": "number", "minimum": 0 }
  },
  "allOf": [
    {
      "if":   { "properties": { "specimenSourceCategory": { "const": "BLOOD" } } },
      "then": {
        "properties": {
          "collectionMethod": { "enum": ["venipuncture", "capillary", "arterial"] }
        },
        "required": ["collectionMethod", "volumeMl"]
      }
    },
    {
      "if":   { "properties": { "specimenSourceCategory": { "const": "TISSUE" } } },
      "then": {
        "properties": {
          "collectionMethod": { "enum": ["biopsy", "excision", "FNA"] }
        },
        "required": ["collectionMethod", "tissueSite"]
      }
    },
    {
      "if":   { "properties": { "specimenSourceCategory": { "const": "URINE" } } },
      "then": {
        "properties": {
          "collectionMethod": { "enum": ["clean_catch", "catheter", "suprapubic"] }
        },
        "required": ["collectionMethod"]
      }
    }
  ]
}

The allOf + if/then pattern avoids the footgun of oneOf with duplicated required lists. Each branch only asserts what changes for that category. For a full walkthrough of how JSON Schema conditionals interact with $ref and unevaluatedProperties, the JSON Schema and test data guide covers the edge cases in depth.

Now wire it into a Pydantic v2 model and a factory_boy factory so validation fires at construction time, not assertion time:

from __future__ import annotations
from enum import Enum
from typing import Literal, Union
from pydantic import BaseModel, UUID4, model_validator
from datetime import datetime
import factory, factory.fuzzy

class SourceCategory(str, Enum):
    BLOOD  = "BLOOD"
    URINE  = "URINE"
    TISSUE = "TISSUE"

class BloodSpecimen(BaseModel):
    specimenId: UUID4
    specimenSourceCategory: Literal[SourceCategory.BLOOD]
    collectedAt: datetime
    collectionMethod: Literal["venipuncture","capillary","arterial"]
    volumeMl: float

class TissueSpecimen(BaseModel):
    specimenId: UUID4
    specimenSourceCategory: Literal[SourceCategory.TISSUE]
    collectedAt: datetime
    collectionMethod: Literal["biopsy","excision","FNA"]
    tissueSite: str

SpecimenUnion = Union[BloodSpecimen, TissueSpecimen]

class BloodSpecimenFactory(factory.Factory):
    class Meta:
        model = BloodSpecimen
    specimenId            = factory.Faker("uuid4")
    specimenSourceCategory = SourceCategory.BLOOD
    collectedAt           = factory.Faker("date_time_this_year", tzinfo=None)
    collectionMethod      = factory.fuzzy.FuzzyChoice(["venipuncture","capillary","arterial"])
    volumeMl              = factory.fuzzy.FuzzyFloat(1.0, 50.0)

With this setup, BloodSpecimenFactory.build(collectionMethod="biopsy") raises a ValidationError immediately — before any test assertion runs. In a suite of ~800 lab fixture calls, moving validation to factory-build time cut the average debug cycle from roughly 4 minutes (chasing a null in a response body) to under 10 seconds (stack trace pointing at the factory call). That's the measurable outcome: failure locality, not raw speed.

For CI, add a Schemathesis smoke test against your specimen endpoint. Point it at the schema file and let it fuzz the specimenSourceCategory enum automatically:

# .github/workflows/lab-schema-smoke.yml
- name: Schemathesis specimen schema fuzz
  run: |
    schemathesis run ./schemas/lab/specimen.json \
      --base-url http://localhost:8080 \
      --checks all \
      --stateful=links \
      --hypothesis-max-examples=200

Pitfalls: Nullable Catch-Alls, Stale Enums, and Pre-Test Cleanup Gaps

The most common mistake is collapsing the discriminated union into a single flat object with every field marked nullable: true. It feels pragmatic — one schema, no conditionals — but it means a TISSUE specimen with no tissueSite passes validation silently. The assertion gaps that appear when optional fields collapse to null are exactly this failure mode: your schema passes, your contract breaks, and the bug surfaces in a downstream result-interpretation service that assumed the field would be present. Use discriminated unions; accept the verbosity.

The second pitfall is stale specimenSourceCategory enums in test fixtures when the LIS adds a new category (SALIVA, STOOL) in production. Teams forget that enum expansion is a breaking change for consumers if their code does exhaustive matching. Tie your schema's enum list to a versioned reference table — a dbt seed or a Postgres lookup.specimen_source_category table — and run a schema-diff check in CI. Also: before each test cycle, truncate or reset fixture tables that reference the enum; stale rows with deprecated category codes will cause FK violations that look like application bugs, not data bugs.

Myths: Prod Clones Are Safe, Randomness Equals Coverage, and AI Fixes Bad Models

Myth 1 — a production data clone is a valid specimen source. Prod clones carry real patient identifiers, and in a lab context that means PHI under HIPAA. Beyond compliance, cloned schemas drift: the moment your migration pipeline lags, your test environment is running against a schema that no longer matches production. Cloned-schema drift is a slow-moving failure that looks like intermittent test flakiness. Synthetic generation from a schema — exactly what the factory above does — is safer and more reproducible.

Myth 2 — random data generation gives you coverage. Faker and Mimesis produce syntactically valid values, but they won't generate the semantically constrained combinations that break lab systems: a BLOOD specimen with volumeMl: 0.0, a TISSUE specimen where tissueSite is an empty string, or a collectedAt timestamp in the future. Use Hypothesis with a custom strategy that respects your schema's if/then branches for boundary and edge-case generation. Myth 3 — feeding raw data into an AI model before cleaning the schema is fine. Whether you're building a synthetic data service or fine-tuning a model on lab records, garbage schema definitions upstream mean garbage training distributions downstream. Fix the model first; generation tools — AI or otherwise — are multipliers on whatever quality already exists.

The specimen source category is a small field with outsized structural consequences. Model it as a discriminated union in JSON Schema 2020-12, enforce it at factory-build time with Pydantic, and fuzz it with Schemathesis in CI. If you want to extend this into a full synthetic lab data service — adding result panels, reference ranges, and HL7 FHIR resource wrapping — the open-source TDM stack overview is a practical next step for wiring the pieces together.

Note: This article is for informational purposes only and is not a substitute for professional advice. If you need guidance on specific situations described in this article, consider consulting a qualified professional.

Understanding how systems actually work is the first step toward navigating them effectively.

Browse all articles