Daniel Murillo
Index

Architecture · October 28, 2026 · Daniel Murillo

How to test an API: contract, E2E, and load

The goal is not to have many tests, but fast evidence that the API meets its contract under relevant conditions.

The goal is not to have many tests; it is to have fast, reliable evidence that the API meets its contract under relevant conditions.

Introduction

An API can return 200 OK and still be wrong: incomplete data, broken filtering, an incompatible contract, or collapse under concurrency.

Tests have different goals. Combine levels that catch different failures at a reasonable maintenance cost.

         /\
        /  \     E2E / load  (few, expensive, high integration value)
       /----\
      /      \   Contract / integration
     /--------\
    /          \ Unit / logic  (many, fast)
   --------------

There is no universal pyramid. Adjust based on the cost of failing in production.

1. Types of tests

TypeWhat it verifiesTypical limit
UnitIsolated logicDoes not exercise real SQL or the network
IntegrationComponents together (API + Postgres)Slower; needs an environment
ContractConsumer ↔ provider agreementDoes not test all business logic
E2EFull flowFragile and expensive if overused
LoadBehavior under trafficDoes not replace functional tests
SecurityAuthZ, validation, limitsMust be deliberate, not an afterthought

2. Example: Vitest + Supertest

Export the app without opening the port in the same module as listen.

import request from "supertest";
import { describe, expect, it } from "vitest";
import { app } from "../src/app";

describe("GET /health", () => {
  it("returns a healthy response", async () => {
    const response = await request(app).get("/health");

    expect(response.status).toBe(200);
    expect(response.body).toEqual({ status: "ok" });
  });
});

This validates the route’s in-process HTTP contract, not that Postgres or the deployment work.

3. Integration with a database

When behavior depends on SQL, test against a real, isolated database (e.g. Testcontainers or CI Postgres). Mocks do not verify indexes, constraints, or transactions.

Good practices:

  • Migrations applied in setup.
  • Data isolated per test or reliable cleanup.
  • Do not share a DB across parallel workers without isolation.
  • Test relevant errors and constraints.

4. Contracts (OpenAPI / Pact)

A contract fixes routes, fields, types, and statuses. OpenAPI describes the agreement; Pact models consumer–provider expectations.

paths:
  /users/{id}:
    get:
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
      responses:
        "200":
          description: User found
          content:
            application/json:
              schema:
                type: object
                required: [id, email]
                properties:
                  id:
                    type: string
                  email:
                    type: string
                    format: email
        "404":
          description: User not found

Keep the specification aligned with the implementation (validate in CI). A contract does not guarantee full business logic.

5. E2E

Critical flows: sign up → log in → create a resource → fetch it. They catch sequencing and configuration problems, but they are slow. Do not put all business rules only in E2E.

6. Load with k6

Define a question: “Does this endpoint meet p95 under X ms with Y RPS and an error rate under Z?”

import http from "k6/http";
import { check, sleep } from "k6";

export const options = {
  vus: 10,
  duration: "30s",
  thresholds: {
    http_req_failed: ["rate<0.01"],
    http_req_duration: ["p(95)<300"],
  },
};

export default function () {
  const response = http.get(`${__ENV.BASE_URL}/health`);
  check(response, { "status is 200": (r) => r.status === 200 });
  sleep(1);
}

/health does not represent business load. Distinguish load, stress, spike, and soak. Document hardware, data, and request profile.

7. What to watch under load

  • Throughput and errors
  • Latency p50 / p95 / p99
  • CPU, memory, event loop
  • DB pool and waits
  • External dependencies
  • Queues and retries

8. Security and negative paths

  • Missing, invalid, or expired token
  • User without permission / IDOR (someone else’s resource)
  • Malformed payloads and extra fields
  • Rate limits and sizes
  • Idempotency and retries
  • Dependency timeouts
  • Sensitive data in responses and logs

9. Tools

ToolUse
Vitest / JestUnit and light integration
SupertestHTTP against the Node app
PlaywrightBrowser E2E
PactConsumer–provider contract
OpenAPI toolingHTTP contract
k6Load
TestcontainersReal dependencies in CI
OWASP ZAPDynamic security (with care)

10. CI/CD

  1. Lint and types
  2. Fast unit tests
  3. Integration with isolated deps
  4. Contracts
  5. Build
  6. E2E in a controlled environment
  7. Load as a separate gate on critical changes

Not everything on every commit: fast feedback first; expensive checks when they add value.

Conclusion

Tests are not a competition for case count. They are a strategy to catch failures at the useful level for the right cost.

Unit tests for logic, integration for real collaborations, contracts for agreements, E2E for critical journeys, and load for declared capacity.

In this series

  1. Authentication
  2. Observability
  3. SQL, query builders, and ORMs
  4. How to test an API (this note)
  5. Containers and Twelve-Factor

Related reading

References