An API test case should describe a situation, the behavior the client can rely on, and the evidence needed to verify it.
For an orders API, a successful response is only one situation. The caller may have an expired session, select a source they cannot access, submit an invalid period, or retry a write after losing the response.
A testing matrix makes these situations visible before they become scattered across test files. It also helps decide where each behavior should be checked: a unit test, an API contract test, or a database integration test.
I use a matrix to make backend testing concrete. A row such as “test pagination” leaves too much interpretation to the person implementing it. “Traverse a fixed dataset containing equal timestamps, returning every expected ID exactly once” describes a behavior that can fail for a useful reason.
The examples below use a small orders API. Its status codes and retry rules are explicit choices for this application. Another API can choose a different contract and still use the same method to design its backend test cases.
Write the API contract first
Start with the two operations a client sees: GET /orders reads a scoped collection, and POST /orders creates one business order.
For GET /orders, the authenticated context determines the tenant. The caller supplies one source, a UTC reporting period, an optional cursor, and a page size between 1 and 100. The service checks source access before querying. Payment time determines membership in the period: the start is included and the end is excluded.
The collection contains paid orders, ordered by paidAt and then id. The ID is the tie-breaker when timestamps match. Cursors belong to the authenticated tenant, source, period, and ordering; changing that scope makes a cursor invalid.
For a fixed dataset, the first page might return:
{
"items": [
{
"id": "order-a1",
"paidAt": "2026-10-07T09:00:00.000Z",
"amountMinor": 10000,
"currency": "TRY"
},
{
"id": "order-a2",
"paidAt": "2026-10-07T09:00:00.000Z",
"amountMinor": 20000,
"currency": "TRY"
}
],
"total": 3,
"revenueMinor": 45000,
"currency": "TRY",
"nextCursor": "opaque-cursor"
}
total and revenueMinor describe the full filtered collection, rather than the current page. This example supports one currency and JSON numbers within JavaScript’s safe integer range. If your amounts or totals can exceed that range, define a string or decimal representation and test it at the HTTP boundary.
For POST /orders, the client supplies a source, an external order reference, and the business fields. This application requires an Idempotency-Key. A successful creation returns 201, an order representation, and a Location header. A completed retry with the same normalized payload returns the original status, body, and resource location. Per-request tracing remains in headers, so a new request can have a new trace identifier without changing the replayed business result.
Define the errors just as carefully:
| Situation | This API’s contract |
|---|---|
| Missing or expired bearer credentials | 401, AUTH_REQUIRED, and a WWW-Authenticate: Bearer challenge. |
| Source missing or inaccessible to the caller | 404, SOURCE_NOT_FOUND, with the same public error shape. |
| Invalid date range, page size, or cursor | 400, with a stable code identifying the invalid input. |
| Same idempotency key with different business fields | 409, IDEMPOTENCY_CONFLICT; no second mutation. |
| Same operation still executing | 409, REQUEST_IN_PROGRESS; retry the same key with bounded backoff. |
| Read dependency temporarily unavailable | 503, DEPENDENCY_UNAVAILABLE; no success-shaped result. |
The source error policy avoids telling a caller whether another tenant’s source exists. Test missing, foreign, and permission-denied sources separately even though they share a public response. HTTP defines the authentication challenge required for 401; the application defines its domain error codes. RFC 9110 supplies the protocol semantics.
Use a consistent error envelope, for example { error: { code, message }, requestId }. Specify validation order too: an unauthenticated caller should receive the authentication response before a source lookup can disclose anything. API design that survives change explains why these details belong in the consumer contract.
Build the matrix around client-visible behavior
The matrix connects each situation to both a promise and a way to challenge it. A status code alone rarely supplies enough evidence.
| Case | Situation | Expected behavior | Evidence |
|---|---|---|---|
| API-01 | Valid scoped read | 200, exact matching orders and collection totals. | Compare IDs, totals, and amounts with the fixture. |
| API-02 | Missing or expired authentication | 401, authentication challenge and stable error. | Inspect the HTTP response; protected data is absent. |
| API-03 | Inaccessible source | The defined 404 access response. | Foreign and unauthorized source records never appear. |
| API-04 | Invalid reporting period | 400, INVALID_PERIOD. | Assert the error code and absence of a collection result. |
| API-05 | No matching orders | 200, empty items, zero totals, null cursor. | Assert the complete empty-result contract. |
| API-06 | Multiple pages with tied timestamps | Every matching ID appears once in stable order. | Traverse all pages and compare the concatenated IDs. |
| API-07 | Retry a completed write with the same key | Return the previous order result. | One committed order and one logical business event. |
| API-08 | Same key, different payload | 409, IDEMPOTENCY_CONFLICT. | Original fields remain; no additional order or event. |
| API-09 | Dependency timeout or lost response | Follow the documented error and recovery policy. | Observe the client result and independently inspect committed state. |
| API-10 | Response serialization | Documented JSON fields and types. | Inspect actual HTTP bytes, headers, and parsed values. |
These are starting cases, not a claim of complete coverage. Rate limits, malformed JSON, expired cursors, concurrent requests, permission changes, and cancellation can add rows when they affect this API’s promises.
For scope tests, keep a positive control: an authorized request must return its expected records. If every query returns an empty list, a check that “all returned rows belong to the tenant” passes without establishing useful behavior. Object authorization also needs checks beyond a valid session; OWASP’s object-level authorization guidance explains that boundary.
The write cases need durable evidence. Two successful responses carrying the same ID are insufficient if the handler created two billing events. Conversely, two outbox delivery attempts can represent one logical event. Name the business effect being protected and inspect it at its own boundary.
Give every row meaningful test data
Choose records that expose a missing rule. For a pagination fixture, use three paid orders in Tenant A’s Store 1: order-a1 and order-a2 share a payment timestamp, while order-a3 is later. With a page size of two, the second page must contain order-a3.
Add records from Tenant B, another source in Tenant A, and exactly at the reporting period’s exclusive end. None belongs in the traversal. Keep all fixture writes complete before the first request and prevent concurrent mutations during this particular test.
The timestamp tie challenges the secondary sort and cursor predicate. A cursor based only on payment time can skip a record when several orders share that time. Use enough tied records to place the tie across a page boundary too: a second fixture with three equal timestamps and a page size of two exposes that failure directly.
The following test runs against an isolated application instance. withOrdersApi is an application-owned harness that starts the real HTTP server against a migrated fixture database, provides its base URL and test credentials, and closes both resources. It is not a library API or a mocked repository.
import assert from "node:assert/strict"
import { test } from "node:test"
import { withOrdersApi } from "./support/orders-api.js"
test("API-06 traverses a fixed collection without gaps", async () => {
await withOrdersApi("three-orders-with-a-timestamp-tie", async (api) => {
const ids: string[] = []
const cursors = new Set<string>()
let cursor: string | null = null
do {
const url = new URL("/orders", api.baseUrl)
url.searchParams.set("sourceId", "store-1")
url.searchParams.set("start", "2026-10-07T00:00:00.000Z")
url.searchParams.set("end", "2026-10-08T00:00:00.000Z")
url.searchParams.set("limit", "2")
if (cursor !== null) url.searchParams.set("cursor", cursor)
const response = await fetch(url, {
headers: { Authorization: `Bearer ${api.tenantAToken}` },
signal: AbortSignal.timeout(5000),
})
assert.equal(response.status, 200)
const body = await response.json()
assert.equal(body.total, 3)
assert.equal(body.revenueMinor, 45000)
assert.equal(body.currency, "TRY")
ids.push(...body.items.map((order: { id: string }) => order.id))
assert.ok(ids.length <= 3, "pagination exceeded the fixture")
cursor = body.nextCursor
if (cursor !== null) {
assert.equal(typeof cursor, "string")
assert.ok(!cursors.has(cursor), "cursor repeated")
cursors.add(cursor)
}
} while (cursor !== null)
assert.deepEqual(ids, ["order-a1", "order-a2", "order-a3"])
assert.equal(new Set(ids).size, ids.length)
})
})
This proves traversal over a fixed population. It does not establish snapshot semantics while orders are being inserted or their payment times are changing. If the product promises a stable snapshot, add mutation cases and a mechanism that maintains that snapshot. A cursor is a position, not a page number develops that distinction.
For writes, use a unique external reference for each isolated case. Reuse that reference and the same key only when testing retries of the same operation. Accidental fixture reuse across workers can turn a concurrency bug into an unreliable test.
Place each case at the right layer
The matrix describes behavior; the test boundary determines what evidence you can collect.
A unit test can challenge date normalization, cursor validation, and payload fingerprinting with small input tables. A real HTTP contract test can verify authentication middleware, response headers, status codes, JSON serialization, and the public error shape. A database integration test can establish query membership, uniqueness constraints, transactions, and committed state.
These boundaries can overlap. An HTTP test backed by PostgreSQL can establish both a client response and persistence. Give it the resources it needs deliberately. Replacing the repository with a stub narrows the evidence to routing and representation; it cannot validate the SQL behind the response.
For API-10, parse the response received through HTTP rather than asserting a handler’s internal object. Dates, database numeric types, omitted fields, and undefined values can change during serialization. Assert required keys, ISO timestamps, the currency, integer amounts, and the absence of private fields such as internal tenant identifiers or credentials.
For API-02 and API-04, table-driven inputs keep variations readable. Each row should have its own test name and isolated setup. Assert error.code rather than matching a human-readable sentence that may change without breaking the contract. Assert that requestId exists and has the documented format without fixing it to a generated value.
Backend Database Testing: Fixtures, Isolation, and Real Queries covers the fixture and isolation mechanics. Testing is a boundary design problem explains how to choose the boundary for the guarantee you need.
Handle uncertain write outcomes
A timeout describes what the client observed. It does not tell the client whether the server committed the order.
For this sample API, a completed idempotency record stores the payload fingerprint and replayable result. Its key namespace includes tenant, authenticated actor, method, and route. The service rechecks authentication and source authorization before returning a saved result. Another caller’s key cannot expose that caller’s order.
The first successful write commits the order, one outbox event, and the completed idempotency result in the same database transaction. A unique key claim serializes competing attempts. A concurrent retry either obtains the completed result or receives REQUEST_IN_PROGRESS while execution remains unresolved. Different normalized business fields produce IDEMPOTENCY_CONFLICT.
The example retains replay records for 24 hours. Beyond that period, a business uniqueness constraint on tenant, source, and external order reference still prevents a second order, but the endpoint may return a business conflict instead of the original HTTP response. State both promises. Stripe’s idempotency documentation is a concrete example of a provider documenting parameter comparison, replay, and retention; your API still needs its own policy.
Test the lost-response case at the precise failure point:
- Submit one creation request through a test proxy.
- Let the application commit the order, event, and replay record.
- Have the proxy discard the response and close the client connection.
- Retry through the normal HTTP path with the same key and payload.
- Assert
201, the original resource location, and the committed order’s ID and fields. - Through a separate database connection, assert one order, one completed key record, and one logical outbox event.
Use an explicit commit observation or a test barrier to trigger the fault. Throwing before the transaction starts tests a different situation. A random delay can place the failure on either side of commit and make the result difficult to interpret.
Keep dependency failure cases separate. If a read dependency fails, this API returns 503 with its dependency error. If a write connection fails around commit, the result may be unknown: the recovery path resolves the original key before another mutation can begin. An exhausted client timeout remains an unresolved outcome until that lookup or reconciliation succeeds.
An outbox also needs its own delivery tests. One logical event can be delivered repeatedly, so a consumer that sends an email or applies a charge needs stable identity and deduplication at that boundary. An order test alone cannot establish the final external effect. Record which part of API-07 each assertion actually verifies.
Keep the matrix useful as the API evolves
Give each row a stable identifier and connect it to executable cases. For example, a backend project might map API-06 to orders-pagination.test.ts, API-07 to orders-idempotency.test.ts, and API-10 to orders-response.test.ts. Those are illustrative locations; the useful part is that a reviewer can find the assertion that protects the promise.
When a bug escapes, add the situation that was missing. If two simultaneous retries create duplicate events, retain the original retry row and add a concurrent variation with controlled scheduling. If a permission change exposes a replayed result, add an authorization-after-creation case. Capture the fixture, request sequence, expected behavior, and actual result while the failure is reproducible.
Review the matrix alongside contract changes. A new sort field changes pagination cases. A new currency changes amount serialization and totals. A changed retry retention period changes recovery expectations. Retire rows only when the corresponding promise has deliberately changed.
I prefer a small matrix whose rows identify real assertions over a long checklist of labels. Its usefulness shows up when someone can explain which client behavior changed and which evidence establishes the change.
A test matrix gives the team a shared description of what the API promises. Its value comes from connecting each promise to an assertion that can fail for a meaningful reason.