Skip to content

Spec sync: drift & coverage ​

This is the feature TruSpec exists for. Your OpenAPI document is the source of truth; your collection is measured against it. Three checks — drift, coverage, and contract — keep them honest, run fully offline, and fail the build the moment your collection (or your API) rots away from your spec.

A fourth command, gen, closes the loop by generating a request stub for every operation so you start at full coverage.


The model ​

A request links to a spec operation with a spec block:

yaml
spec:
  operation: "GET /pets/{id}"   # "${METHOD} ${path}"
  operationId: getPetById       # preferred when present

TruSpec parses your OpenAPI 3 document into a flat list of operations (keyed METHOD path, e.g. GET /pets/{id}), then matches each request to an operation:

  • If both have an operationId, they match on it.
  • Otherwise the request's operation string is matched against the operation's METHOD path key.

Everything downstream — drift, coverage, the live probe — is computed from that matching.


Drift ​

truspec drift diffs your collection against the spec and exits non-zero when they've diverged. It reports four categories:

CategoryMeaningWhy it matters
Untracked (added)In the spec, but no request references it.A new endpoint shipped with no test.
Stale (removed)Referenced by a request, but gone from the spec.A request points at an endpoint that no longer exists.
Changed (changed)Matched, but the request no longer satisfies the spec.The contract tightened — e.g. a parameter became required — and the request didn't keep up.
Missing from live API (liveMissing)With --live, a spec operation a running API doesn't serve.The deployed API and the spec disagree.

Stale and Changed entries name the request file they came from — fixing drift means editing a file, and the report already read it to notice:

Stale — not in the spec (1):
  - GET /search  (api/legacy.tspec.yaml)

Changed (2):
  ~ GET /products: missing required query param 'limit'  (api/list.tspec.yaml)

(--json carries the same as sources, keyed by the entry text. Untracked has no file to name: that is the point of it.)

"Changed" fires when:

  • the spec marks a query parameter as required and the request doesn't include it,
  • the spec marks a header parameter as required and the request doesn't set it (matched case-insensitively, as HTTP does),
  • the spec marks the request body as required and the request has no body, or
  • the request's JSON body no longer satisfies the operation's requestBody schema — a property that became required, a literal value of the wrong type, or one that violates a constraint the spec sets. Validated with the same response validator, so every keyword in its table applies.
Changed (2):
  ~ POST /pets: body missing required property 'species'   (api/create.tspec.yaml)
  ~ POST /pets: body/id expected integer, got string       (api/create.tspec.yaml)

That last check is the one that catches a field becoming required — the most ordinary breaking change an API makes, and one nothing else here reports: contract validates responses, and the mock's --validate needs a server running.

A {{template}} is never drift. A body is authored with variables still in it, so id: "{{petId}}" against type: integer is not a finding — nor is anything inside a templated container. A missing required property survives that rule by construction: its path names a key that is not there, and no template could supply one. A non-JSON body is not inspected at all; guessing would be worse than silence.

bash
truspec drift --spec openapi.yaml ./api
Spec operations: 4   Collection operations: 3

Untracked in collection (1):
  + GET /users/{id}

Drift detected: 1 untracked, 0 stale, 0 changed.

The drift check passes (exit 0) only when all four categories are empty.

Probing a live API (--live) ​

Add --live <baseUrl> to also check a running API for operations it doesn't serve. This catches the case where the spec and the deployment have diverged — the spec promises an endpoint that returns 404/405, or the host is unreachable.

bash
truspec drift --spec openapi.yaml ./api --live https://api.staging.example.com

For safety against a production target, the live probe only sends GET and HEAD requests; mutating operations are skipped (and reported as skipped). Path parameters are filled with 1 to form a concrete URL. An operation is flagged "missing from live API" when the probe returns 404, 405, or the host is unreachable. Use --timeout <ms> to bound each probe.

JSON shape ​

--json emits a DriftReport:

json
{
  "specOperations": 4,
  "collectionOperations": 3,
  "added": ["GET /users/{id}"],
  "removed": [],
  "changed": [],
  "liveMissing": [],
  "ok": false
}

Coverage ​

truspec coverage reports what share of spec operations are exercised by a request that also has assertions — a request with no assertions doesn't count, because it asserts nothing about the contract.

That rule is easy to trip over, so the report says which case each uncovered operation is: an operation nothing points at needs a request written, while one a request does point at but never asserts on needs an assertion added to a file you already have. The second is named, with its request and file, rather than left looking identical to the first.

bash
truspec coverage --spec openapi.yaml ./api
Coverage: 75% (3/4 operations tested)

Uncovered (1):
  ✗ GET /users/{id}

An operation whose request exists but asserts nothing is called out in place:

Coverage: 50% (1/2 operations tested)

Uncovered (1):
  ✗ GET /health  — "Health" (api/health.tspec.yaml) has no assertions; a request that asserts nothing tests nothing

Gate on a minimum with --min:

bash
truspec coverage --spec openapi.yaml ./api --min 80   # exit 1 if below 80%

percent is round(covered / total * 100); an empty spec reports 100%. The command exits 0 when percent >= --min (default 0, i.e. report-only).

JSON shape ​

json
{
  "total": 4,
  "covered": ["GET /pets", "GET /pets/{id}", "POST /pets"],
  "uncovered": ["GET /users/{id}"],
  "percent": 75,
  "ok": true
}

Response validation (contract) ​

drift and coverage are static — they reason about whether a request references an operation and supplies its required inputs. They never look at what the API actually returns. truspec contract closes that gap: it runs the collection and validates each response body against the spec's response schema for the matched operation.

bash
truspec contract --spec openapi.yaml ./api --env local
Contract: 2/3 tested operations conform to the spec
  ✓ GET /posts
  ✓ GET /posts/{id}

Violations (1):
  ✗ POST /posts  →  schema: 1 violation(s) — /author/id: missing required property 'id'

Untested — no request exercises these (1, see `coverage`):
  – GET /users/{id}

Contract violations: 1.

Because it sends requests, contract takes the same knobs as run — --env for the base URL and secrets, --timeout, --json. Point it at a mock or a real API. It exits non-zero only on a schema violation; untested and status-undocumented operations are reported but don't fail the gate (that's coverage/drift's job).

Because of that, contract is not a substitute for run. An operation it could not validate is listed under Not validated, with which of the two reasons applies:

Contract: 0/1 tested operations conform to the spec

Not validated (1):
  ~ GET /pets/{id}  — the request failed (500) — see `truspec run`

No operation was validated against the spec.

The summary never claims conformance that wasn't established: with nothing validated it says so outright, and a partial result is reported as partial.

This is behavioral drift — the kind the structural drift check can't catch. A handler that returns a string id where the spec promises an integer, drops a required field, or adds an undeclared status passes every existing status/jsonpath assertion but fails contract.

Validated without a separate command ​

You don't need the contract command to get response validation. Two lighter paths:

  • truspec run --spec openapi.yaml ./api — every spec-linked request is validated against its response schema inline, as an extra assertion in the normal run output.
  • { type: schema } assertion — opt a single request in (or pin a status / contentType). See File format → schema.

The validator covers the OpenAPI 3 schema subset below and reports the JSON path of every violation.

GroupKeywords
Shapetype (including a 3.1 type array), properties, required, items, nullable, additionalProperties: false
Valuesenum
Numbersminimum, maximum, exclusiveMinimum, exclusiveMaximum (both the OpenAPI 3.0 boolean spelling and the JSON Schema numeric one), multipleOf
StringsminLength, maxLength (counted in code points, so an emoji is one character), pattern
ArraysminItems, maxItems, uniqueItems
ObjectsminProperties, maxProperties
CompositionallOf, oneOf, anyOf, $ref

Every keyword present is an independent constraint, so minimum is checked whether or not the schema also says type: integer, and a keyword that does not apply to the value's type is inapplicable rather than a violation. format is treated as an annotation (not enforced), matching JSON Schema's default.

The bound, length and count keywords were added in a later release. Before that, a response of the right shape carrying out-of-range values — id: 0 against minimum: 1, a one-element list against minItems: 2 — passed silently. If you have a spec using them, expect contract to start reporting violations it previously missed.

JSON shape ​

--json emits a ContractReport:

json
{
  "specOperations": 4,
  "conformed": ["GET /posts", "GET /posts/{id}"],
  "violations": [
    {
      "op": "POST /posts",
      "status": 201,
      "message": "schema: 1 violation(s) — /author/id: missing required property 'id'",
      "filePath": "api/create-post.tspec.yaml"
    }
  ],
  "skipped": [],
  "untested": ["GET /users/{id}"],
  "ok": false
}

Scaffolding from a spec ​

truspec gen writes a request stub for every operation in your spec — each with a status: 200 assertion and a spec link already filled in. It's the fastest way to take a brand-new spec to a fully drift-tracked collection.

bash
truspec gen openapi.yaml --out ./api

Each generated file looks like:

yaml
tspec: "0.1"
name: getPetById
method: GET
url: "{{baseUrl}}/pets/{{id}}"
assertions:
  - { type: status, equals: 200 }
spec:
  operation: "GET /pets/{id}"
  operationId: getPetById
  • Path parameters become template variables ({id} → {{id}}).
  • The base-URL variable defaults to baseUrl; override with --base-url-var.
  • File names are slugified from the operationId (or the METHOD path key).
  • Operations with an unsupported HTTP method are skipped and reported.

A scaffolded request carries what the spec says the operation cannot work without: the success status it documents, its path parameters as {{variables}}, every required query parameter (optional ones are left out — scaffolding them all buries the request in noise), and a JSON body when the operation requires one, generated from the request-body schema. That is the invariant gen and drift owe each other: truspec drift against the spec a collection was generated from reports no drift.

After gen, flesh out the stubs (real assertions, bodies, auth) and your drift count drops to zero. Agents can do this too — see the truspec_scaffold_from_spec MCP tool.


A complete workflow ​

Put the three together and your API contract becomes a build gate:

bash
# 1. Bootstrap a collection from the spec.
truspec gen openapi.yaml --out ./api

# 2. Fill in assertions / bodies, then run against a mock or a real API.
truspec mock openapi.yaml &
truspec run ./api --env local

# 3. Gate CI on the contract.
truspec drift    --spec openapi.yaml ./api              # no new/removed/changed endpoints
truspec coverage --spec openapi.yaml ./api --min 80     # enough of the surface is tested
truspec contract --spec openapi.yaml ./api --env local  # responses match the spec's schemas

When someone adds an endpoint to the spec, drift fails until a request covers it. When someone makes a parameter required, drift flags the now-incomplete request. When the deployed API and the spec disagree, drift --live catches it. When a handler returns a shape the spec doesn't allow, contract catches it. That's the whole point: the collection — and the API behind it — can't silently fall out of sync.

See the CI/CD guide to wire these into GitHub Actions and other pipelines.


See also ​

Released under the MIT License.