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
| Type | What it verifies | Typical limit |
|---|---|---|
| Unit | Isolated logic | Does not exercise real SQL or the network |
| Integration | Components together (API + Postgres) | Slower; needs an environment |
| Contract | Consumer ↔ provider agreement | Does not test all business logic |
| E2E | Full flow | Fragile and expensive if overused |
| Load | Behavior under traffic | Does not replace functional tests |
| Security | AuthZ, validation, limits | Must 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
| Tool | Use |
|---|---|
| Vitest / Jest | Unit and light integration |
| Supertest | HTTP against the Node app |
| Playwright | Browser E2E |
| Pact | Consumer–provider contract |
| OpenAPI tooling | HTTP contract |
| k6 | Load |
| Testcontainers | Real dependencies in CI |
| OWASP ZAP | Dynamic security (with care) |
10. CI/CD
- Lint and types
- Fast unit tests
- Integration with isolated deps
- Contracts
- Build
- E2E in a controlled environment
- 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
- Authentication
- Observability
- SQL, query builders, and ORMs
- How to test an API (this note)
- Containers and Twelve-Factor
Related reading
- Observability
- Benchmarks and architecture decisions
- Optimizing REST APIs
- Tool: which API protocol fits you