#Testing APIs: unit, integration and contract
AddedA device API has three kinds of bug, and each needs a different test to find it. Logic bugs: the volume check lets 140 through. Wiring bugs: the logic is right, but the HTTP layer around it isn't: the body parser ignores a content type, a route is registered twice, the error handler returns HTML instead of problem details. Promise bugs: the device works and its own tests pass, but it no longer does what the web UI team, the SDK team and partners were told it does, because a field was renamed or a new status code appeared. Unit tests find the first, integration tests the second, contract tests the third.
The mental model is inspecting a house. Unit tests check each part on the bench: this switch clicks, this pipe holds pressure. Integration tests turn on the mains in the finished house and walk through it: does flipping the switch in the hall light the hall? A contract test takes the plans the buyer signed and checks the house against them: three bedrooms were promised, and there are three, with the doors where the drawing says. A house can pass the first two and fail the third, if the builder moved a wall because it seemed better. That is exactly what happens when a developer renames volume to volumeLevel and updates their own tests to match.
On a device platform the third kind matters more than usual, because the API has many consumers you don't control: the on-device admin web UI, a cloud management service, an SDK, partner integrations (room booking, control systems), and scripts customers wrote years ago against firmware that's still in the field. The spec (OpenAPI, see Contract testing) is the one statement all of them rely on, so it's the thing tests should hold the device to.
| Level | What's real | What's faked | Speed | Finds |
|---|---|---|---|---|
| Unit | One function or module | Everything around it | Microseconds per test | Wrong logic, edge cases in validation, merge rules, state transitions |
| Integration (in-process) | The real HTTP app: routing, middleware, parsers, error handling | The hardware and OS services behind it (fakes or a simulator) | Milliseconds per test | Wiring: content types, status codes, headers, auth middleware order |
| Contract | The real responses, compared with the spec | Nothing about the device; the spec is the oracle | Milliseconds per test | Drift: renamed fields, undocumented statuses, wrong media types |
| End-to-end on a simulator or emulator | UI + API + a stand-in device | The hardware | Seconds per test | Flows: reboot, update, reconnect (see Device simulators and emulators) |
| Hardware-in-the-loop | The real device | Only the room around it | Minutes per test | Timing, power loss, peripherals, performance (see Hardware-in-the-loop and lab testing) |
The example below tests one endpoint, PATCH /api/v1/settings, at the first two levels. The validation logic is unit-tested by calling the functions directly; the same app is then integration-tested over real HTTP with supertest, which takes an Express app, binds it to an ephemeral port on 127.0.0.1 for the request, and closes it afterwards (supertest 7.3's README and lib/test.js, read October 2026). The app has a deliberate wiring bug, the kind that's common in device code: the client sends application/merge-patch+json (RFC 7396, the right media type for a partial update, see Partial updates), but express.json() only parses application/json unless told otherwise.
// Unit tests vs integration tests for one device endpoint: PATCH /api/v1/settings.
import express from "express";
import request from "supertest";
// ---- The code under test ----
const RULES = {
name: { type: "string", maxLength: 32 },
volume: { type: "integer", min: 0, max: 100 },
brightness: { type: "integer", min: 0, max: 100 },
};
function validatePatch(patch) {
const errors = [];
for (const [key, value] of Object.entries(patch ?? {})) {
const rule = RULES[key];
if (!rule) errors.push(`unknown setting '${key}'`);
else if (rule.type === "integer" && !Number.isInteger(value)) errors.push(`${key} must be an integer`);
else if (rule.type === "integer" && (value < rule.min || value > rule.max)) errors.push(`${key} must be ${rule.min}–${rule.max}`);
else if (rule.type === "string" && (typeof value !== "string" || value.length > rule.maxLength)) errors.push(`${key} must be a string of at most ${rule.maxLength}`);
}
return errors;
}
const applyPatch = (current, patch) => ({ ...current, ...patch });
function createApp({ fixed }) {
const store = { settings: { name: "Huddle 4", volume: 50, brightness: 80 } };
const app = express();
// The bug: express.json() only parses Content-Type application/json by default.
app.use(express.json(fixed ? { type: ["application/json", "application/merge-patch+json"] } : {}));
app.get("/api/v1/settings", (req, res) => res.json(store.settings));
app.patch("/api/v1/settings", (req, res) => {
const errors = validatePatch(req.body);
if (errors.length) return res.status(422).type("application/problem+json").json({ title: "Invalid settings", status: 422, errors });
store.settings = applyPatch(store.settings, req.body);
res.json(store.settings);
});
return app;
}
// ---- A tiny test runner (prints the same thing every run, unlike timing reporters) ----
async function run(title, tests) {
console.log(title);
let failed = 0;
for (const [name, fn] of tests) {
try { await fn(); console.log(` ✓ ${name}`); }
catch (err) { failed++; console.log(` ✗ ${name}: ${err.message}`); }
}
console.log(` ${tests.length - failed} passed, ${failed} failed`);
}
const expect = (actual, expected, what) => {
if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error(`${what}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
};
const unit = [
["accepts a valid volume", () => expect(validatePatch({ volume: 40 }), [], "errors")],
["rejects volume 140", () => expect(validatePatch({ volume: 140 }), ["volume must be 0–100"], "errors")],
["rejects a misspelt key", () => expect(validatePatch({ volum: 40 }), ["unknown setting 'volum'"], "errors")],
["merges a patch", () => expect(applyPatch({ volume: 50, brightness: 80 }, { volume: 40 }), { volume: 40, brightness: 80 }, "settings")],
];
const integration = (app) => [
["GET returns JSON settings", async () => {
const res = await request(app).get("/api/v1/settings");
expect([res.status, res.type, res.body.volume], [200, "application/json", 50], "status, type, volume");
}],
["PATCH volume 140 → 422 problem details", async () => {
const res = await request(app).patch("/api/v1/settings").send({ volume: 140 });
expect([res.status, res.type, res.body.errors], [422, "application/problem+json", ["volume must be 0–100"]], "status, type, errors");
}],
["PATCH as merge-patch+json changes the volume", async () => {
const res = await request(app).patch("/api/v1/settings").set("Content-Type", "application/merge-patch+json").send(JSON.stringify({ volume: 40 }));
const after = await request(app).get("/api/v1/settings");
expect([res.status, after.body.volume], [200, 40], "status, volume after");
}],
];
await run("Unit tests (functions called directly)", unit);
await run("Integration tests (real Express app over HTTP, buggy)", integration(createApp({ fixed: false })));
await run("Integration tests (real Express app over HTTP, fixed)", integration(createApp({ fixed: true })));Unit tests (functions called directly)
✓ accepts a valid volume
✓ rejects volume 140
✓ rejects a misspelt key
✓ merges a patch
4 passed, 0 failed
Integration tests (real Express app over HTTP, buggy)
✓ GET returns JSON settings
✓ PATCH volume 140 → 422 problem details
✗ PATCH as merge-patch+json changes the volume: status, volume after: expected [200,40], got [200,50]
2 passed, 1 failed
Integration tests (real Express app over HTTP, fixed)
✓ GET returns JSON settings
✓ PATCH volume 140 → 422 problem details
✓ PATCH as merge-patch+json changes the volume
3 passed, 0 failed- The unit tests call
validatePatchandapplyPatchdirectly, with plain objects.{ volume: 140 }produces["volume must be 0–100"], the misspeltvolumis caught as an unknown key, and the merge keepsbrightness: 80while changingvolumefrom50to40. All four pass: the logic is correct. - The integration tests hand the whole Express app to supertest.
request(app).get(...)starts a real HTTP server on an ephemeral port, sends a real request through Express's router and middleware, and gives back status, headers and parsed body. The first test checks all three at once:200,application/json,volume50. - The second test sends
{ volume: 140 }. Supertest's.send(object)setsContent-Type: application/json, whichexpress.json()parses, so validation runs and the response is422withapplication/problem+json— the wiring for errors is right. - The third test sends the body as
application/merge-patch+json, which is what the web UI and the SDK actually send. With the defaultexpress.json(), the content type doesn't match, so the parser skips the body andreq.bodystaysundefined. The reason is in body-parser (2.4.0, the version Express 5.3 installs here):json()defaults itstypeoption to"application/json"and checks it withtype-is, which matches that exact media type, not every+jsontype; for a non-matching request it callsnext()without reading the body, after settingreq.body = undefined(in body-parser 1.x it was{}).type: "application/*+json"would also have fixed it.validatePatch(undefined)iteratespatch ?? {}, finds nothing wrong, and the handler returns200with the unchanged settings. The follow-up GET showsvolumestill50. - That is the worst kind of device bug: a success response for a change that never happened. The unit tests can't see it, because the functions are fine; only a test that goes through the real HTTP stack with the real content type finds it. The fix is one line,
express.json({ type: ["application/json", "application/merge-patch+json"] }), and the third run passes. - If you changed
validatePatchto reject a missing body (if (patch === undefined) return ["request body is required"]), the buggy app would answer422instead of200: still broken, but loudly. Better still, give each failure its own status, as in Methods, status codes and idempotency for device APIs: a content type the endpoint doesn't parse gets415 Unsupported Media Type, a body that isn't valid JSON gets400 Bad Request(that's whatexpress.json()itself raises for a parse error), and well-formed JSON with a bad value, like volume 140, gets422with problem details.
Unit and integration tests are written by the people who wrote the code, so they test what the code does, and when the code changes, the tests are changed to match. A contract test uses a different oracle: the OpenAPI document that consumers were given. For every response the device produces in a test, it looks up the operation in the spec, checks that the status code is documented, that the content type is documented for that status, and that the body validates against the schema. Pact's documentation (docs.pact.io, checked October 2026) defines contract testing as "a technique for testing an integration point by checking each application in isolation" against a shared contract; checking a provider's responses against its OpenAPI document is the provider-side half of that, and the Pact docs point out its limit: it doesn't prove that consumers call the API correctly.
The model below uses the packages installed for this site: yaml to parse the spec, @apidevtools/swagger-parser 13.1 to validate the OpenAPI 3.1 document and resolve every $ref, and Ajv 8.20's 2020-12 build (OpenAPI 3.1 schemas are JSON Schema 2020-12) to validate bodies. It runs the same four requests against two builds: build A as released, and build B, where a developer renamed volume to volumeLevel "for clarity" and added a 409 for duplicate room names.
// A spec-driven contract test: every response the device API gives must be one the OpenAPI spec promises.
import express from "express";
import request from "supertest";
import YAML from "yaml";
import SwaggerParser from "@apidevtools/swagger-parser";
import Ajv2020 from "ajv/dist/2020.js";
const specYaml = `
openapi: 3.1.0
info: { title: Room device API, version: 1.4.0 }
paths:
/api/v1/settings:
get:
responses:
"200": { description: Current settings, content: { application/json: { schema: { $ref: "#/components/schemas/Settings" } } } }
patch:
requestBody: { content: { application/merge-patch+json: { schema: { type: object } } } }
responses:
"200": { description: Updated settings, content: { application/json: { schema: { $ref: "#/components/schemas/Settings" } } } }
"422": { description: Invalid settings, content: { application/problem+json: { schema: { $ref: "#/components/schemas/Problem" } } } }
components:
schemas:
Settings:
type: object
required: [name, volume, brightness]
additionalProperties: false
properties:
name: { type: string, maxLength: 32 }
volume: { type: integer, minimum: 0, maximum: 100 }
brightness: { type: integer, minimum: 0, maximum: 100 }
Problem:
type: object
required: [title, status]
properties: { title: { type: string }, status: { type: integer }, errors: { type: array, items: { type: string } } }
`;
// validate() checks the document against the OpenAPI 3.1 schema and resolves every $ref.
const spec = await SwaggerParser.validate(YAML.parse(specYaml));
const ajv = new Ajv2020({ allErrors: true });
function checkAgainstSpec(method, path, res) {
const op = spec.paths[path]?.[method];
if (!op) return [`${method.toUpperCase()} ${path} is not in the spec`];
const documented = op.responses[String(res.status)];
if (!documented) return [`status ${res.status} is not documented (documented: ${Object.keys(op.responses).join(", ")})`];
const media = documented.content?.[res.type];
if (!media) return [`content type ${res.type} is not documented for ${res.status}`];
const validate = ajv.compile(media.schema);
return validate(res.body) ? [] : validate.errors.map((e) => `body${e.instancePath} ${e.message}${e.params.additionalProperty ? ` '${e.params.additionalProperty}'` : ""}`);
}
// Two builds of the device's API. In build B a developer renamed volume → volumeLevel and added a 409.
function createApp(build) {
let settings = { name: "Huddle 4", volume: 50, brightness: 80 };
const out = (s) => (build === "B" ? { name: s.name, volumeLevel: s.volume, brightness: s.brightness } : s);
const app = express();
app.use(express.json({ type: ["application/json", "application/merge-patch+json"] }));
app.get("/api/v1/settings", (req, res) => res.json(out(settings)));
app.patch("/api/v1/settings", (req, res) => {
if (build === "B" && req.body.name === "") return res.status(409).json({ error: "name in use" });
if (req.body.volume > 100) return res.status(422).type("application/problem+json").json({ title: "Invalid settings", status: 422, errors: ["volume must be 0–100"] });
settings = { ...settings, ...req.body };
res.json(out(settings));
});
return app;
}
// The cases: one request per documented response, plus an edge case.
const cases = [
["get", "/api/v1/settings", null],
["patch", "/api/v1/settings", { volume: 40 }],
["patch", "/api/v1/settings", { volume: 140 }],
["patch", "/api/v1/settings", { name: "" }],
];
for (const build of ["A", "B"]) {
console.log(`Build ${build}`);
const app = createApp(build);
for (const [method, path, body] of cases) {
let req = request(app)[method](path);
if (body) req = req.set("Content-Type", "application/merge-patch+json").send(JSON.stringify(body));
const res = await req;
const problems = checkAgainstSpec(method, path, res);
const label = `${method.toUpperCase()} ${path}${body ? " " + JSON.stringify(body) : ""} → ${res.status}`;
console.log(problems.length ? ` ✗ ${label}\n ${problems.join("\n ")}` : ` ✓ ${label}`);
}
}Build A
✓ GET /api/v1/settings → 200
✓ PATCH /api/v1/settings {"volume":40} → 200
✓ PATCH /api/v1/settings {"volume":140} → 422
✓ PATCH /api/v1/settings {"name":""} → 200
Build B
✗ GET /api/v1/settings → 200
body must have required property 'volume'
body must NOT have additional properties 'volumeLevel'
✗ PATCH /api/v1/settings {"volume":40} → 200
body must have required property 'volume'
body must NOT have additional properties 'volumeLevel'
✓ PATCH /api/v1/settings {"volume":140} → 422
✗ PATCH /api/v1/settings {"name":""} → 409
status 409 is not documented (documented: 200, 422)SwaggerParser.validatefirst checks that the spec itself is a valid OpenAPI 3.1 document (a broken spec would make every later check meaningless), then replaces each$refwith the schema it points to. After that,spec.paths["/api/v1/settings"].get.responses["200"].content["application/json"].schemais the fullSettingsschema object.checkAgainstSpecasks three questions of every response, in order: is this operation in the spec, is this status documented, is this content type documented for this status. Only then does it compile the schema with Ajv and validate the body. Ajv is created withallErrors: trueso one run reports every problem, not just the first.- Build A passes all four. Note the last case:
{ name: "" }is accepted with200, because the spec has nominLengthforname. The contract test doesn't judge whether that's wise; it only checks that the device does what the spec says. If an empty name should be rejected, the spec changes first, then the code. - Build B's GET returns
{ name, volumeLevel, brightness }. Ajv reports both halves of the rename: the requiredvolumeis missing, andadditionalProperties: falserejects the unknownvolumeLevel. Every consumer that readssettings.volume(the web UI's slider, the SDK'sgetVolume(), a partner's room-control script) would now readundefined. The developer's own integration tests, updated in the same commit, would all be green. - The
409is a different kind of drift: the body might even be fine, but clients were never told this status exists, so their error handling can't be ready for it. Adding a status code to a response list is a spec change that has to be reviewed like any other. - What this demonstrates: the spec is an independent oracle. Run in CI next to a spec diff (oasdiff, below), it turns "we accidentally broke partners" into a failed pull request.
The spec doesn't only say what the device returns; it says what the device must refuse. A schema with maximum: 100 implies a test with 101; type: integer implies 0.5 and "100"; additionalProperties: false implies a misspelt key. Tools do this at scale: Schemathesis's documentation (checked October 2026) says it "automatically generates property-based tests from your OpenAPI or GraphQL schema". The idea fits in a few lines: walk the schema, produce the values just outside each rule, send each one, and expect 422.
// Negative tests generated from the spec's schema: every value the schema forbids must get a 422.
import express from "express";
import request from "supertest";
const Settings = {
type: "object", additionalProperties: false,
properties: {
name: { type: "string", maxLength: 32 },
volume: { type: "integer", minimum: 0, maximum: 100 },
brightness: { type: "integer", minimum: 0, maximum: 100 },
},
};
// For each property, the boundary values just outside what the schema allows, plus wrong types.
function invalidPatches(schema) {
const out = [];
for (const [key, s] of Object.entries(schema.properties)) {
if (s.type === "integer") {
out.push([key, s.minimum - 1], [key, s.maximum + 1], [key, s.minimum + 0.5], [key, String(s.maximum)], [key, null]);
}
if (s.type === "string") out.push([key, "x".repeat(s.maxLength + 1)], [key, 42], [key, null]);
}
if (schema.additionalProperties === false) out.push(["volumee", 40]); // a key the schema doesn't define
return out.map(([key, value]) => ({ [key]: value }));
}
// The device's handler, with the kind of hand-written validation that drifts from the spec.
const settings = { name: "Huddle 4", volume: 50, brightness: 80 };
const app = express();
app.use(express.json({ type: "application/merge-patch+json" }));
app.patch("/api/v1/settings", (req, res) => {
for (const [key, v] of Object.entries(req.body)) {
const bad =
key === "name" ? typeof v !== "string" // forgot maxLength
: key === "volume" || key === "brightness" ? typeof v !== "number" || v < 0 || v > 100 // forgot "integer"
: true;
if (bad) return res.status(422).type("application/problem+json").json({ title: "Invalid settings", status: 422, errors: [`bad ${key}`] });
}
Object.assign(settings, req.body);
res.json(settings);
});
const patches = invalidPatches(Settings);
const slipped = [];
for (const patch of patches) {
const res = await request(app).patch("/api/v1/settings").set("Content-Type", "application/merge-patch+json").send(JSON.stringify(patch));
const shown = JSON.stringify(patch).replace(/x{33}/, "x×33");
if (res.status !== 422) slipped.push(`${shown} → ${res.status}`);
}
console.log(`${patches.length} invalid patches generated from the schema`);
console.log(`${patches.length - slipped.length} rejected with 422, ${slipped.length} accepted by mistake:`);
for (const s of slipped) console.log(` ✗ ${s}`);
console.log(`stored settings now: ${JSON.stringify(settings).replace(/x{33}/, "x×33")}`);14 invalid patches generated from the schema
11 rejected with 422, 3 accepted by mistake:
✗ {"name":"x×33"} → 200
✗ {"volume":0.5} → 200
✗ {"brightness":0.5} → 200
stored settings now: {"name":"x×33","volume":0.5,"brightness":0.5}invalidPatchesreads the schema, not the code. For each integer it makes five values:-1(belowminimum),101(abovemaximum),0.5(a number but not an integer),"100"(a string), andnull(in a merge patch,nullmeans "delete this member", which a required setting must refuse). For the string it makes a 33-character name, the number42, andnull. The extra keyvolumeetestsadditionalProperties: false. That's 5 + 5 + 3 + 1 = 14 patches.- The handler's hand-written checks are typical:
typeof v !== "number" || v < 0 || v > 100looks complete, andtypeof v !== "string"looks fine for a name. They catch 11 of the 14. - Three slip through. A 33-character name passes because nobody wrote the
maxLengthcheck, and0.5passes for both volume and brightness because "is a number" isn't "is an integer". The stored settings end up asvolume: 0.5and a name that the device's on-screen label (sized for 32 characters) will truncate or overflow. - Why this matters on devices: invalid values that get stored come back on every boot. A fractional volume that the audio HAL rejects can turn into a crash loop on startup, which is far worse than a
422. - The fix isn't more hand-written ifs; it's validating requests with the same schema the spec publishes (Ajv compiled from
openapi.yaml, see Validating requests and responses against the spec). Then the generated tests mostly prove the wiring, and the spec and the code can't drift apart.
Contract tests check the device against the spec. The other half is checking each change to the spec against the previous version: removing a field, adding a required request property, narrowing an enum, or adding a response status are breaking for some client. oasdiff compares two OpenAPI documents and classifies each change as an error (breaking), a warning or info; its GitHub Action (oasdiff/oasdiff-action/breaking, v0.1.20 released 21 October 2026) fails a pull request when it finds changes at or above the fail-on level. By default the action uploads both specs, encrypted, to oasdiff.com to build a side-by-side review page; for a private device API set review: false so no spec leaves CI (its action.yml documents both). The workflow in CI/CD for web UI, APIs and firmware shows it in place.
For fleets on old firmware, the base to compare against isn't only main: it's every API version still supported in the field. Versioning and how to test it across releases is the subject of Testing compatibility across versions.
Provider-side contract tests answer "does the device match the spec?" They can't answer "does the web UI only use what the spec offers?" or "which fields does anyone actually use?" Consumer-driven contracts reverse the direction: each consumer's own tests record the requests it makes and the parts of each response it reads, as a contract file; the provider's CI replays those requests against the real API. Pact (docs.pact.io, checked October 2026) describes itself as "a code-first consumer-driven contract testing tool", and points out the main benefit: because contracts come from consumer tests, only the parts of the API that consumers actually use are tested, so the provider can change everything else freely.
Where they fit on a device platform:
- Teams you can coordinate with (the web UI, the cloud service, the SDK) can publish Pact contracts. The device team sees exactly which fields each consumer depends on before removing anything.
- Partners and customer scripts won't publish contracts, so the published OpenAPI spec, with breaking-change detection, stays the contract for them.
- The two complement each other: the spec says what's promised, consumer contracts say what's relied on. A field that's in the spec but in no consumer contract is a candidate for deprecation, never for silent removal (partners may use it).
For each bug, name the cheapest test level that reliably catches it: (a) PATCH with brightness: -5 is accepted; (b) the 404 handler returns an HTML page instead of problem details; (c) a developer changes uptimeS from an integer to a string "600" and updates the unit and integration tests; (d) the web UI shows "Saved" but the device's display didn't change brightness until reboot.
Show answer
(a) Unit, (b) integration, (c) contract, (d) end-to-end on a simulator, and finally hardware in the loop.
- (a) is pure logic in the validator:
validatePatch({ brightness: -5 })should return an error. A unit test with boundary values (-1,0,100,101) catches it in microseconds, and the schema-generated negative tests catch it too. - (b) is wiring: the error-handling middleware, not any one function. Only a request through the real app (supertest asking for an unknown path and asserting
res.type === "application/problem+json") shows what the 404 handler really sends. - (c) is drift that the developer's own tests were updated to accept. Only an oracle the developer didn't edit, the published spec where
uptimeSistype: integer, catches it, and breaking-change detection flags it again if they try to edit the spec too. - (d) is behaviour across the UI, the API and the device: the API stored the value but the setting needs to be applied to the display service. A simulator that models "applied vs pending" catches the logic in CI; whether the real display driver applies it immediately can only be proved on real hardware.