API testing and schema validation
Faster, steadier and closer to the logic than driving a browser. Asserting against a schema rather than a hand-written body is what stops the suite from breaking every time a field is added, and catches the changes that actually matter.
API testing exercises an interface directly, without a browser. You send a request, you read the response, and you assert on what came back.
Schema validation is asserting the shape rather than the values. Field names, types, whether something is nullable, whether an unexpected field appeared. A schema is a machine-readable description of that shape, usually JSON Schema or OpenAPI.
The two together are the best value in automated testing. They run in milliseconds and fail for one reason. They also catch the defect that most often reaches production: a response that changed shape while still returning 200.
The terms you will hear
- Status code. The HTTP result. Necessary and nowhere near sufficient.
- Schema. A description of the response shape, in JSON Schema or OpenAPI.
- Additional properties. Whether unexpected fields are allowed. Set this to false and you catch drift.
- Nullable. Whether a field may be null. The most common source of client crashes.
- Idempotency. Whether repeating a request is safe. Critical for anything that moves money.
- Contract. The agreement between a producer and a consumer about that shape.
What to assert, beyond the status
For each endpoint, four groups of assertion.
- Status and headers. The right code for the right situation. 201 on create, 409 on conflict, 422 on validation, not 200 with an error object inside.
- Values. The specific numbers and strings that matter, from a real oracle rather than from whatever the code returns today.
- Shape. Validated against a schema, with additional properties rejected so a new field is a visible change.
- Errors. Bad input, missing auth, wrong customer, conflicting state. This is usually where the untested half of an API lives.
Why it matters
Because this is where defects are cheapest to catch and where coverage is cheapest to hold.
For example, a checkout journey through the browser takes 20 seconds, breaks for environmental reasons, and tells you that something in a long chain went wrong. The equivalent API test takes 200 milliseconds, runs on every commit, and tells you exactly which endpoint returned the wrong total.
You still want the browser test. You want one of them, not forty, which is the shape argument from pyramid or trophy.
How to build a good set
- Start from the endpoints that move money or data. Not from the endpoint list in alphabetical order.
- One happy path per endpoint, asserting values and shape. Both, always.
- Then the error matrix. Missing field, wrong type, wrong customer, no auth, expired auth, conflicting state. Six cases per endpoint is normal and they find real defects.
- Validate against a schema, and reject unknown fields. Generate the schema from your OpenAPI document if you have one, so it stays in step.
- Test idempotency where it matters. Send the same payment request twice and assert that one charge exists. This is the double-write case in API form.
- Use builders for payloads, so a test states only the field under test.
- Keep them fast and hermetic. Own data per test, no shared fixtures, no shared state.
A worked set
For example, here is the set for one endpoint, and what it caught.
import { test, expect } from './fixtures'
import { z } from 'zod'
// The shape, declared once. `.strict()` rejects unknown fields, which is
// what turns a silent addition into a failing test.
const RedeemResponse = z
.object({
orderTotal: z.string(),
giftCardApplied: z.string(),
remainingToPay: z.string(),
giftCardBalance: z.string(),
paymentLines: z.array(z.object({ method: z.string(), amount: z.string() })),
})
.strict()
test('applies the card and charges the remainder', async ({ api, activeCard, order }) => {
const res = await api.post('/orders/redeem', {
data: { orderId: order.id, code: activeCard.code },
})
expect(res.status()).toBe(200)
const body = await res.json()
// Shape first: a drifted response fails here with a clear message.
RedeemResponse.parse(body)
// Then the values, from the policy document rather than from the code.
expect(body.remainingToPay).toBe('10.99')
expect(body.giftCardBalance).toBe('0.00')
expect(body.paymentLines).toHaveLength(2)
})
test.describe('errors', () => {
test('409 when the card is already spent', async ({ api, spentCard, order }) => {
const res = await api.post('/orders/redeem', {
data: { orderId: order.id, code: spentCard.code },
})
expect(res.status()).toBe(409)
expect((await res.json()).error).toBe('gift_card_spent')
})
test('403 for a card belonging to another customer', async ({ api, otherCustomerCard, order }) => {
const res = await api.post('/orders/redeem', {
data: { orderId: order.id, code: otherCustomerCard.code },
})
expect(res.status()).toBe(403)
})
test('the same request twice charges once', async ({ api, activeCard, order }) => {
const body = { orderId: order.id, code: activeCard.code }
await api.post('/orders/redeem', { data: body })
const second = await api.post('/orders/redeem', { data: body })
expect(second.status()).toBe(409)
const balance = await api.get(`/gift-cards/${activeCard.code}`)
expect((await balance.json()).balance).toBe('0.00')
})
})Three of those four tests are error cases, and that ratio is normal for a well-tested endpoint. The idempotency test at the bottom is the one that found the double-spend defect, and no value assertion on a happy path would ever have caught it.
How to show you know it
- An error matrix per endpoint. Six cases, named. It is the fastest way to show you test more than the happy path.
- A strict schema. Rejecting unknown fields, with a story about the drift it caught.
- An idempotency test. Especially on anything touching money.
- A speed comparison. "Twenty seconds in the browser, two hundred milliseconds here, same defect."
Questions
Is a 200 not enough?
No. A 200 with the wrong total, a null where a value belongs, or an error object in the body all pass a status check. Status is the first assertion, never the only one.
Where should the schema live?
With the API definition if you have OpenAPI, generated into the tests so it cannot drift. Otherwise declared in the test suite and reviewed when the API changes.
Should I test third-party APIs?
Test your integration with them, not their behaviour. Stub them for the suite and keep a small scheduled check against the real sandbox, which is the split in determinism.
How many API tests is enough?
Enough that a browser failure is rare news rather than the first sign of trouble. In practice most teams are under-tested here and over-tested in the browser.