API Testing
Testing HTTP and message interfaces directly — the level most teams under-invest in relative to its value, and how it replaces most slow end-to-end tests.
2 min read · updated 19 September 2026
The API is the contract. Most of what a system promises — status codes, error shapes, pagination, idempotency, authorization — is expressed there, and it can all be tested an order of magnitude faster and more reliably than through a browser.
A great deal of what teams write as end-to-end tests is a slow, fragile restatement of an API assertion.
#The shape
// TypeScript, supertest. In-process: no port, no deployment, ~10ms.
import request from 'supertest';
import { app } from '../src/app';
describe('POST /orders', () => {
it('creates an order and returns its location', async () => {
const response = await request(app)
.post('/orders')
.set('Idempotency-Key', 'ORD-test-1')
.send({ sku: 'book-1', quantity: 2 })
.expect(201)
.expect('Content-Type', /json/);
expect(response.headers.location).toMatch(/^\/orders\/[0-9a-f-]{36}$/);
// Read it back through the API, not the database: the test then
// describes the contract rather than the schema.
await request(app).get(response.headers.location).expect(200).expect(({ body }) => {
expect(body).toMatchObject({ sku: 'book-1', quantity: 2, status: 'reserved' });
});
});
it('is idempotent: the same key returns the same order', async () => {
const key = 'ORD-test-2';
const first = await request(app).post('/orders').set('Idempotency-Key', key)
.send({ sku: 'book-1', quantity: 1 }).expect(201);
const second = await request(app).post('/orders').set('Idempotency-Key', key)
.send({ sku: 'book-1', quantity: 1 }).expect(200);
expect(second.headers.location).toBe(first.headers.location);
});
it('rejects an unknown sku with a useful error', async () => {
await request(app).post('/orders').send({ sku: 'nope', quantity: 1 })
.expect(422)
.expect(({ body }) => {
expect(body).toMatchObject({ error: 'unknown_sku', field: 'sku' });
});
});
});// Java, RestAssured against a running service
@Test
void rejectsAnOrderForAnUnknownSku() {
given()
.contentType(ContentType.JSON)
.body(Map.of("sku", "nope", "quantity", 1))
.when()
.post("/orders")
.then()
.statusCode(422)
.body("error", equalTo("unknown_sku"))
.body("field", equalTo("sku"));
}# Python, httpx + FastAPI's TestClient
def test_pagination_is_stable_across_pages(client, seeded_orders):
first = client.get("/orders?limit=2").json()
second = client.get(f"/orders?limit=2&cursor={first['next']}").json()
assert len(first["items"]) == 2
assert {o["id"] for o in first["items"]} & {o["id"] for o in second["items"]} == set()#What to cover, in priority order
1. Status codes and error shapes. The most commonly wrong and least
commonly tested part of any API. 400 versus 422 versus 409 is a real
behavioural difference callers depend on, and error bodies drift constantly
because nothing asserts on them.
2. Authorization. The same request as two different users. This is the highest-value API test there is and it is almost universally missing.
it.each([
['owner', 200],
['other-tenant', 404], // 404, not 403: do not confirm the resource exists
['anonymous', 401]
])('GET /orders/:id as %s returns %i', async (who, expected) => {
await request(app).get(`/orders/${order.id}`).set(authFor(who)).expect(expected);
});3. Validation boundaries. Empty, zero, negative, maximum length, wrong type, missing required field, unexpected extra field.
4. Idempotency and concurrency. Two identical requests. Two concurrent conflicting requests. Both are where real systems break.
5. Pagination. Stable ordering, no duplicates or omissions across pages, behaviour when data changes mid-pagination.
6. Content negotiation and versioning, if you support them.
#Schema validation
Asserting field by field is laborious and misses additions. Validate the response against the schema you publish:
import Ajv from 'ajv';
import openapi from '../openapi.json';
const ajv = new Ajv({ strict: false });
const validateOrder = ajv.compile(openapi.components.schemas.Order);
it('returns a body matching the published Order schema', async () => {
const { body } = await request(app).get(`/orders/${order.id}`).expect(200);
expect(validateOrder(body)).toBe(true);
expect(validateOrder.errors).toBeNull();
});This catches the drift where the implementation and the documentation disagree, which is otherwise invisible until a client integrates.
#Message-based interfaces
The same discipline applies to queues and topics — the interface is the message, not an HTTP path.
it('publishes OrderPlaced with the fields consumers rely on', async () => {
await placeOrder({ sku: 'book-1', quantity: 2 });
const [message] = await broker.drain('orders');
expect(JSON.parse(message.body)).toMatchObject({
type: 'OrderPlaced',
version: 1,
orderId: expect.any(String),
sku: 'book-1'
});
});For an evented system, this is where the real contract lives. Event shapes are append-only in practice — a consumer somewhere depends on every field you ever published — which makes a test over the published shape more valuable than almost any other assertion in the system.
#Where it sits
An API test that starts the whole service with its real database and doubles for external dependencies is a component test. One that checks two services still agree without deploying both is contract testing. One that runs against a deployed environment is a smoke test and should be a handful of cases, not a suite.
The practical advice is simple and rarely followed: before writing an end-to-end test, ask whether the same risk can be covered at the API level. It usually can, at a hundredth of the cost.
Common questions
- What is the difference between API testing and integration testing?
- Scope and direction. An integration test exercises your code against a dependency; an API test exercises your service through the interface its callers use. In practice a good API test is a component test — the whole service, its real database, doubles for anything beyond its boundary.
- Should API tests run against a deployed environment?
- A small smoke subset can, as a deployment gate. The main suite should run against the service started by the test process, because that makes it fast, deterministic, and runnable on a laptop.
- Do I need Postman for API testing?
- No. Postman and similar tools are excellent for exploration and for handing a collection to someone non-technical. For an automated suite, tests in the same language and repository as the service are easier to review, refactor and run in CI.
Runnable samples for this page
last test results ↗- TypeScript
typescript/src/platforms/api-testing
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Component TestingTesting one deployable in isolation through its real interface, with its own dependencies containerised and everything beyond its boundary stubbed.
- Contract TestingProving two services still agree without deploying both — consumer-driven contracts with Pact, provider verification in CI, and where contract testing beats end-to-end.
- Authorization TestingThe highest-value security testing most teams are not doing — proving that the user who should not be able to do a thing genuinely cannot.
- End-to-End TestingWhat belongs in an end-to-end suite and what does not, how many journeys are enough, and the practices that keep a browser suite from becoming the thing everyone ignores.
- WireMockA programmable HTTP server for tests — stubbing responses, injecting failures, verifying requests, and recording real traffic to replay.
- Performance TestingLatency, throughput and what breaks first — designing a performance test that answers a question, reading percentiles honestly, and putting a check in the pipeline.
- Security TestingWhat automated security testing can and cannot find, the scans worth having in every pipeline, and why business-logic flaws remain a human problem.
- Integration Testing with StubsTesting against a dependency you control — WireMock, MSW and in-process servers — so error paths, timeouts and rate limits become testable instead of theoretical.