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:
spec:
operation: "GET /pets/{id}" # "${METHOD} ${path}"
operationId: getPetById # preferred when presentTruSpec 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
operationstring is matched against the operation'sMETHOD pathkey.
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:
| Category | Meaning | Why 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
requestBodyschema — 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, soid: "{{petId}}"againsttype: integeris 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.
truspec drift --spec openapi.yaml ./apiSpec 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.
truspec drift --spec openapi.yaml ./api --live https://api.staging.example.comFor 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:
{
"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.
truspec coverage --spec openapi.yaml ./apiCoverage: 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 nothingGate on a minimum with --min:
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
{
"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.
truspec contract --spec openapi.yaml ./api --env localContract: 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 astatus/contentType). See File format → schema.
The validator covers the OpenAPI 3 schema subset below and reports the JSON path of every violation.
| Group | Keywords |
|---|---|
| Shape | type (including a 3.1 type array), properties, required, items, nullable, additionalProperties: false |
| Values | enum |
| Numbers | minimum, maximum, exclusiveMinimum, exclusiveMaximum (both the OpenAPI 3.0 boolean spelling and the JSON Schema numeric one), multipleOf |
| Strings | minLength, maxLength (counted in code points, so an emoji is one character), pattern |
| Arrays | minItems, maxItems, uniqueItems |
| Objects | minProperties, maxProperties |
| Composition | allOf, 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: 0againstminimum: 1, a one-element list againstminItems: 2— passed silently. If you have a spec using them, expectcontractto start reporting violations it previously missed.
JSON shape
--json emits a ContractReport:
{
"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.
truspec gen openapi.yaml --out ./apiEach generated file looks like:
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 theMETHOD pathkey). - 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:
# 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 schemasWhen 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
- CLI reference — every flag for
drift,coverage,contract, andgen. - File format → Spec link — how requests reference operations.
- Programmatic API → spec — compute drift and coverage from TypeScript.