Testing REST APIs
Testing an endpoint's status codes and response schema, plus checking idempotent methods.
What's different about testing a REST API specifically
Testing an endpoint is more than checking it "works" for one happy-path input. A REST API makes a set of promises — the right status code for every outcome, a response body matching an agreed shape, correct behavior for idempotent methods, sensible error responses — and a real test suite checks that the contract described on the OpenAPI & Documentation page is actually what the running API delivers, not just what a handful of manual curl calls happened to show once.
A complete example: testing an endpoint's status codes and schema
Using Jest and Supertest (the same tools covered for general Express testing in this app's Node.js track's Testing with Jest page) against a small users API:
// app.js
import express from 'express';
export function createApp(usersRepository) {
const app = express();
app.use(express.json());
app.get('/users/:id', async (req, res) => {
const user = await usersRepository.findById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.status(200).json(user);
});
app.post('/users', async (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(422).json({ error: 'name and email are required' });
}
const user = await usersRepository.create({ name, email });
res.status(201).location(`/users/${user.id}`).json(user);
});
return app;
}
// users-api.test.js
import request from 'supertest';
import { createApp } from './app';
const userSchema = {
type: 'object',
required: ['id', 'name', 'email'],
properties: {
id: { type: 'number' },
name: { type: 'string' },
email: { type: 'string' },
},
additionalProperties: false,
};
describe('GET /users/:id', () => {
test('200: returns a user matching the expected schema', async () => {
const repo = { findById: jest.fn().mockResolvedValue({ id: 1, name: 'Ali Raza', email: 'ali@example.com' }) };
const app = createApp(repo);
const response = await request(app).get('/users/1');
expect(response.status).toBe(200);
expect(response.body).toMatchSchema(userSchema); // schema validation, detailed below
});
test('404: returns a clear error body when the user does not exist', async () => {
const repo = { findById: jest.fn().mockResolvedValue(null) };
const app = createApp(repo);
const response = await request(app).get('/users/999');
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: 'User not found' });
});
});
describe('POST /users', () => {
test('201: creates a user and sets the Location header', async () => {
const repo = { create: jest.fn().mockResolvedValue({ id: 2, name: 'Zara Khan', email: 'zara@example.com' }) };
const app = createApp(repo);
const response = await request(app)
.post('/users')
.send({ name: 'Zara Khan', email: 'zara@example.com' });
expect(response.status).toBe(201);
expect(response.headers.location).toBe('/users/2');
expect(response.body).toMatchSchema(userSchema);
});
test('422: rejects a request missing a required field', async () => {
const app = createApp({});
const response = await request(app).post('/users').send({ name: 'Zara Khan' }); // no email
expect(response.status).toBe(422);
});
});
Validating the response shape, not just the status code
Checking response.status alone is a weak test — an endpoint can return 200 with a completely malformed body and a status-only assertion still passes. Validating the response against a JSON Schema (the same schema language OpenAPI's components.schemas uses) catches shape regressions a status-code check alone would miss entirely — an accidentally removed field, a type that silently changed from a number to a string, an unexpected extra field leaking into the response:
// A small helper wiring a JSON Schema validator (ajv) into Jest's expect() —
// this is what powers the toMatchSchema() calls used above
import Ajv from 'ajv';
const ajv = new Ajv();
expect.extend({
toMatchSchema(received, schema) {
const validate = ajv.compile(schema);
const valid = validate(received);
return {
pass: valid,
message: () => `Expected response to match schema. Errors: ${JSON.stringify(validate.errors)}`,
};
},
});
Because the schema here is the same shape already captured in the OpenAPI spec, the strongest version of this pattern loads the schema directly from openapi.yaml instead of duplicating it inline in the test file — at that point, the test suite is verifying the running API actually honors its own published contract, which is the essence of a contract test for a single API's own consumers, distinct from the cross-service consumer-driven contracts covered on the microservices track's Testing Microservices page.
Testing idempotency
REST's status-codes page establishes that PUT and DELETE are supposed to be idempotent — calling them repeatedly should leave the resource in the same end state as calling them once. That's a genuine, testable property, not just a design intention:
test('DELETE is idempotent — calling it twice does not error', async () => {
const repo = { delete: jest.fn().mockResolvedValue(undefined) };
const app = createApp(repo);
const first = await request(app).delete('/users/1');
const second = await request(app).delete('/users/1');
expect(first.status).toBe(204);
expect([200, 204, 404]).toContain(second.status); // "already gone" is a valid idempotent outcome
});
The second call is allowed to answer differently from the first (a 404 for "already deleted" is a perfectly valid idempotent response) — what actually matters, and what this test is really checking, is that calling it twice doesn't error out or leave the system in a different state than calling it once did.
What to cover: a checklist by test type
| Test type | What it checks |
|---|---|
| Status code correctness | Every success and error path returns the status this track's Status Codes & Headers page describes for that situation |
| Schema validation | The response body's shape matches the documented contract exactly — no missing, renamed, or extra fields |
| Header correctness | Location on 201, Retry-After on 429 (Rate Limiting & Throttling), Content-Type on every response with a body |
| Idempotency | Repeating a PUT/DELETE leaves the same end state, without erroring |
| Error-path coverage | Every documented error response (400, 404, 422, 429) has an actual test, not just the happy path |
Common mistakes
- Testing only the happy path and never a single error response — a suite that never sends malformed input, an unknown ID, or a duplicate resource has no idea whether the API's error handling actually matches what it claims to do.
- Asserting on the exact JSON string of a response body instead of its structure/schema — this makes tests brittle to harmless changes (key ordering, added whitespace) while still missing genuine structural regressions that a schema check would catch cleanly.
- Never testing idempotent methods for actual idempotency — calling
PUT/DELETEexactly once in every test and never checking what a second call does misses one of REST's core, explicitly testable guarantees. - Letting the test suite's expected schema drift out of sync with the OpenAPI spec by maintaining two separate, hand-written copies of "what the response looks like" — pulling the schema directly from the spec file keeps both in lockstep and turns the test suite into a genuine check that the implementation matches its documentation.
Interview questions
Q: Why is asserting only the HTTP status code an insufficient test for a REST endpoint?
A 200 or 201 status code says nothing about whether the response body actually has the right shape — a field could be missing, renamed, or the wrong type, and a status-only test would still pass. Validating the response against a JSON Schema (ideally the same one published in the API's OpenAPI spec) catches structural regressions that a status-code check alone would completely miss.
Q: How would you actually test that a DELETE endpoint is idempotent?
Call it twice against the same resource and check that the second call doesn't error and doesn't leave the system in a different end state than the first call did — the second response's exact status code is allowed to differ (a 404 for "already gone" is a valid idempotent outcome), since idempotency is about the resulting state being consistent, not about every response body being byte-identical.