Skip to content
End To End Tester
Platformsfoundation

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
// 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
// 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
# 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.

typescript
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:

typescript
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.

typescript
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 ↗

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?