API testing best practices come down to putting each check where it is cheapest and most reliable. Lint the spec on every pull request. Run contract tests that compare requests and responses with the OpenAPI document against a local build. Run multi-step integration and authorization tests against a shared staging environment after merge. Run load tests on a schedule, not on every commit. Each test creates its own data, and nothing depends on test order. When a team stops trusting its API tests, the cause is usually one of three things: slow tests on every commit, shared fixtures, or one suite that tries to prove everything at once.
This guide covers strategy: which layer catches which bug, and how to wire the layers into CI. For turning a request schema into individual cases, see generating test cases from an OpenAPI spec. For the mechanics of checking a body against its schema with Ajv, see validating API responses against your OpenAPI schema.
Each test layer answers one question
A test layer is useful when you can say in one sentence what a failure means. If a contract test fails, the server broke the published contract. If an integration test fails, a workflow across several calls or services broke. If a load test fails, the API got slower or less stable under traffic. When one test mixes those questions, a red build tells you something is wrong but not what.
| Layer | What a failure means | Runs against | When |
|---|---|---|---|
| Spec lint and design score | The OpenAPI document itself is invalid or inconsistent | The spec file | Every pull request |
| Contract tests | A request or response no longer matches the spec | A local build with a throwaway database | Every pull request |
| Integration tests | A multi-step flow or a side effect broke | Staging, with real dependencies | After merge to main |
| Authorization tests | A user can reach data or actions they should not | Staging, with two or more test accounts | After merge, and before each release |
| Load tests | Latency or error rate regressed under concurrency | Staging sized like production | Nightly or before a release |
| Synthetic checks | A critical path is down for real users | Production | Every few minutes |
Contract, integration, and load tests in practice
Contract tests
A contract test sends a request and checks that the status code is one the operation documents and that the body matches the schema for that status. It does not care whether the business logic picked the right price. That narrow scope is what makes contract tests fast and stable enough to block a pull request. The API contract is the OpenAPI file, so keep the file in the same repository as the code and review changes to both in one pull request.
There are two directions. Provider-side contract tests, described above, check the server against its own spec. Consumer-driven contract tests, used by tools such as Pact, record what each client actually reads and replay those expectations against the provider. The second kind is worth the setup when several teams own clients you cannot see; for a single frontend and backend in one repository, provider-side tests against the spec usually cover it.
Integration tests
Integration tests exercise a sequence: create an order, pay for it, check that the status changed, check that the confirmation event reached the queue. They catch bugs that no single request shows, such as a cancellation endpoint that returns 200 but never releases reserved stock. They are slower and depend on more moving parts, so keep the set small and pick the flows that cost money or data when they break. Assert on state you can read back through the API, not on log lines.
Load tests
Load tests answer a different question: does the API keep its latency and error rate as concurrency rises? Run them against an environment sized like production, because a load test against a laptop-sized staging database measures the database, not your code. Set thresholds so the run fails on its own instead of producing a chart someone has to read. The k6 script below fails if more than 1% of requests error or if the 95th percentile goes above 400 ms. Those numbers are placeholders; take yours from what your users and your SLOs need.
import http from "k6/http";
import { check } from "k6";
export const options = {
vus: 20,
duration: "2m",
thresholds: {
http_req_failed: ["rate<0.01"],
http_req_duration: ["p(95)<400"],
},
};
export default function () {
const res = http.get(`${__ENV.BASE_URL}/v1/orders/${__ENV.ORDER_ID}`, {
headers: { Authorization: `Bearer ${__ENV.TOKEN}` },
});
check(res, { "status is 200": (r) => r.status === 200 });
}A test plan for a small Orders API
Here is a two-operation API with bearer auth, an idempotency key on order creation, and documented error responses. We ran it through APIForge's design score and it scored 100 out of 100: versioned paths, plural nouns, summaries on every operation, and error responses on both operations. That score says the contract is well designed. It says nothing about whether the server honours it, which is what the test plan is for.
openapi: 3.0.3
info:
title: Orders API
version: 1.0.0
servers:
- url: https://staging.example.com
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
security:
- bearerAuth: []
paths:
/v1/orders:
post:
summary: Create an order
parameters:
- name: Idempotency-Key
in: header
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [sku, quantity]
properties:
sku:
type: string
quantity:
type: integer
minimum: 1
maximum: 50
responses:
"201":
description: Order created
"400":
description: Invalid order
"401":
description: Missing or invalid token
"409":
description: Same Idempotency-Key reused with a different body
/v1/orders/{id}:
get:
summary: Get one order
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"200":
description: The order
"401":
description: Missing or invalid token
"404":
description: Order not found or owned by another userReading the spec operation by operation, these are the risks worth a test, and the layer each belongs to:
| Risk | Layer | How to test it |
|---|---|---|
| quantity of 0 or 51 is accepted | Contract | Send both boundaries and expect 400 with your error schema |
| 201 body drifts from the documented shape | Contract | Validate the body against the 201 schema on every run |
| Missing token returns 500 or 200 | Contract | Call without Authorization and expect 401 |
| User B can read user A's order | Authorization | Create with token A, read with token B, expect 404 or 403 |
| A retried POST creates two orders | Integration | Send the same body and Idempotency-Key twice, then count orders |
| Same key with a different body is accepted | Integration | Reuse the key with quantity changed and expect 409 |
| GET /v1/orders/{id} slows down as orders grow | Load | Nightly k6 run with a p95 threshold |
The 404 on another user's order is a deliberate design choice in this spec: returning 404 instead of 403 avoids confirming that the order ID exists. Whichever you choose, write it down in the spec so the test has something to assert.
Where each suite runs in CI
Put fast, deterministic checks in front of the merge button and slower, environment-dependent checks after it. A pull request should not wait on staging, because staging is shared and a broken deploy from another branch would block unrelated work. The GitHub Actions workflow below splits the suites by trigger: contract tests on every pull request against a local build, integration and authorization tests on every push to main against staging, and the load test once a night.
name: api-tests
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "0 2 * * *"
jobs:
contract:
if: github.event_name != 'schedule'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- name: Lint the spec
run: npx @redocly/cli lint openapi.yaml
- name: Start the API with a throwaway database
run: docker compose -f compose.test.yaml up -d --wait
- name: Contract tests against the local build
run: npm run test:contract
env:
BASE_URL: http://localhost:3000
integration:
if: github.event_name == 'push'
needs: contract
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- run: npm ci
- name: Workflow and authorization tests against staging
run: npm run test:integration
env:
BASE_URL: ${{ vars.STAGING_URL }}
TOKEN_A: ${{ secrets.STAGING_TOKEN_A }}
TOKEN_B: ${{ secrets.STAGING_TOKEN_B }}
load:
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- uses: grafana/setup-k6-action@v1
- name: Nightly load test
run: k6 run load/orders-read.js
env:
BASE_URL: ${{ vars.STAGING_URL }}
TOKEN: ${{ secrets.STAGING_TOKEN_A }}
ORDER_ID: ${{ vars.LOAD_ORDER_ID }}- Fail the contract job on any undocumented status code. An undocumented 500 is still a contract break.
- Give the integration job its own test accounts on staging, so a developer clicking around staging does not change the data your tests read.
- Keep the load job off pull requests. A shared runner's CPU varies between runs, so latency thresholds there produce noise, not signal.
- Store results as JUnit XML or similar so the CI page shows which test failed without opening logs.
Test data that does not leak between tests
Shared fixtures are a common reason API tests pass alone and fail together. Two tests that touch the same seeded order now depend on the order they run in, and parallel runs make that order random. The fix is to have each test create the records it changes, through the API, with identifiers that include the CI run ID so you can find and delete them later.
AvoidShared seeded record
// Every test reuses one seeded order and one seeded user.
const ORDER_ID = "ord_seed_001";
test("cancel an order", async () => {
const res = await api.post(`/v1/orders/${ORDER_ID}/cancellation`);
assert.equal(res.status, 200);
});
test("read an order", async () => {
const res = await api.get(`/v1/orders/${ORDER_ID}`);
assert.equal(res.body.status, "pending"); // fails whenever "cancel" ran first
});The read test passes only if it runs before the cancel test. In a parallel run it fails some of the time, which is the definition of a flaky test.
PreferEach test owns its data
// Each test creates what it needs, tagged with the run ID.
const runId = process.env.GITHUB_RUN_ID ?? Date.now().toString(36);
test("cancel an order", async () => {
const order = await createOrder({ key: `${runId}-cancel` });
const res = await api.post(`/v1/orders/${order.id}/cancellation`);
assert.equal(res.status, 200);
});
test("read an order", async () => {
const order = await createOrder({ key: `${runId}-read` });
const res = await api.get(`/v1/orders/${order.id}`);
assert.equal(res.body.status, "pending");
});Each test creates its own order, so order and parallelism no longer matter. The run ID in the key makes leftovers easy to clean up with a nightly job.
Seeded data still has a place for read-only reference data such as product catalogues and country lists. The rule is that no test writes to a seeded record. Clean up in two ways: delete what a test created at the end of the test, and run a sweeper that removes anything tagged with a run ID older than a day, because tests that crash skip their own cleanup.
Test authentication and authorization separately
Authentication tests check that the API rejects missing, expired and malformed credentials. Authorization tests check that a valid user cannot reach someone else's data or an admin-only action. They fail in different ways, so write them separately. RFC 6750 says a request with a rejected bearer token gets 401 with a WWW-Authenticate: Bearer header, and a token without enough scope gets 403. The OWASP API Security Top 10 (2023) lists Broken Object Level Authorization as API1, the top risk, so the two-user test below is the first authorization test to write.
import test from "node:test";
import assert from "node:assert/strict";
const BASE = process.env.BASE_URL;
const runId = Date.now().toString(36);
async function createOrder(token, suffix) {
const res = await fetch(`${BASE}/v1/orders`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"Idempotency-Key": `test-${runId}-${suffix}`,
},
body: JSON.stringify({ sku: "SKU-TEST-1", quantity: 1 }),
});
assert.equal(res.status, 201);
return res.json();
}
test("user B cannot read user A's order", async () => {
const order = await createOrder(process.env.TOKEN_A, "bola");
const res = await fetch(`${BASE}/v1/orders/${order.id}`, {
headers: { Authorization: `Bearer ${process.env.TOKEN_B}` },
});
assert.ok([403, 404].includes(res.status), `expected 403 or 404, got ${res.status}`);
});
test("a request with no token gets 401 and a Bearer challenge", async () => {
const res = await fetch(`${BASE}/v1/orders/ord_does_not_matter`);
assert.equal(res.status, 401);
assert.match(res.headers.get("www-authenticate") ?? "", /^Bearer/);
});Repeat the two-user pattern for every operation that takes an ID in the path, including updates and deletes, where the damage is worse than a read. Add a test per role for admin-only operations, which covers Broken Function Level Authorization (API5). The API security best practices guide covers fixing these flaws on the server side.
Find and fix flaky API tests
A flaky test passes and fails on the same code. Once a team learns to click re-run, real failures get re-run too. The usual causes are few:
- Shared or leftover data. Fix it with per-test data, as above.
- Eventual consistency. A search index or read replica lags behind the write. Poll the read endpoint until a deadline, such as 10 seconds, instead of a fixed sleep, and fail with the last response in the message.
- Time. Tests that compare timestamps with the local clock, or that run across midnight or a month boundary. Pass explicit dates, or freeze time in the code under test.
- Rate limits. A parallel suite can hit its own rate limit and get 429. Give test accounts a higher limit or run fewer workers against staging.
- Unsafe retries. RFC 9110 defines PUT, DELETE and safe methods such as GET as idempotent, so a client can retry them after a dropped connection. POST is not idempotent. A test helper that silently retries POST can create duplicates, which is why the Orders API takes an idempotency key.
- Third-party calls. Payment, email and SMS providers fail on their own schedule. Use the provider's sandbox in integration tests and a stub in contract tests.
When a test flakes, quarantine it: move it to a non-blocking job, open an issue with an owner, and set a date to fix or delete it. Automatic retries hide the problem and slow every run. If a test has sat in quarantine for a month with nobody fixing it, delete it.
What to automate first
Pick the next suite to build
| Scenario | Recommendation | Alternative |
|---|---|---|
| The spec has no error responses or untyped fields | Fix the spec first. Contract tests can only check what the spec states. | — |
| No test runs on pull requests | Add spec lint plus contract tests for the five most-used operations. | — |
| Endpoints take resource IDs and serve more than one user | Add two-user authorization tests for every ID-taking operation. | Single-tenant internal APIs can start with authentication tests only. |
| Contract and auth suites are green and stable | Add integration tests for the flows that move money or delete data, then a nightly load test. | — |
Run the first checks in APIForge
Start with the spec. The API quality checker scores the document from 0 to 100 and lists missing error responses, verbs in paths and missing summaries, which are the gaps that make contract tests weak. Then open the API testing workbench, load the spec, and send real requests with bearer, basic or API-key auth. You can validate each response against its schema, generate a set of valid, invalid and edge cases for an endpoint or tag, run them to see which return the expected status, and export them as cURL or a Postman collection to use as the starting point for your CI suite. Browser requests that fail with CORS errors are covered in Swagger UI Failed to Fetch, and APIForge vs Postman compares this workflow with a collection-based client.