API Testing Best Practices: What to Test and Where

API testing best practices for real teams: what belongs in contract, integration, auth and load tests, where each runs in CI, and how to stop flaky tests.

On this page

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.

LayerWhat a failure meansRuns againstWhen
Spec lint and design scoreThe OpenAPI document itself is invalid or inconsistentThe spec fileEvery pull request
Contract testsA request or response no longer matches the specA local build with a throwaway databaseEvery pull request
Integration testsA multi-step flow or a side effect brokeStaging, with real dependenciesAfter merge to main
Authorization testsA user can reach data or actions they should notStaging, with two or more test accountsAfter merge, and before each release
Load testsLatency or error rate regressed under concurrencyStaging sized like productionNightly or before a release
Synthetic checksA critical path is down for real usersProductionEvery few minutes
One question per layer. The timing column is a starting point, not a rule.

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.

JavaScriptload/orders-read.js
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 read-only load test. Point write-heavy load tests at data you can delete afterwards.

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.

YAMLopenapi.yaml
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 user

Reading the spec operation by operation, these are the risks worth a test, and the layer each belongs to:

RiskLayerHow to test it
quantity of 0 or 51 is acceptedContractSend both boundaries and expect 400 with your error schema
201 body drifts from the documented shapeContractValidate the body against the 201 schema on every run
Missing token returns 500 or 200ContractCall without Authorization and expect 401
User B can read user A's orderAuthorizationCreate with token A, read with token B, expect 404 or 403
A retried POST creates two ordersIntegrationSend the same body and Idempotency-Key twice, then count orders
Same key with a different body is acceptedIntegrationReuse the key with quantity changed and expect 409
GET /v1/orders/{id} slows down as orders growLoadNightly k6 run with a p95 threshold
Seven risks, four layers. Every row traces back to a line in the spec.

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.

YAML.github/workflows/api-tests.yml
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 }}
Tokens for the two staging test users live in repository secrets, never in the test code.
  • 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

JavaScript
// 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

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

JavaScripttest/orders.auth.test.mjs
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/);
});
Runs with node --test. TOKEN_A and TOKEN_B belong to two ordinary users with no shared access.

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

ScenarioRecommendationAlternative
The spec has no error responses or untyped fieldsFix the spec first. Contract tests can only check what the spec states.—
No test runs on pull requestsAdd spec lint plus contract tests for the five most-used operations.—
Endpoints take resource IDs and serve more than one userAdd 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 stableAdd 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.

Frequently asked questions

A contract test checks one request and response against the OpenAPI spec: documented status, correct body shape. An integration test runs a sequence of calls and checks the resulting state, such as an order moving from paid to shipped. Contract tests are fast enough to gate pull requests; integration tests usually run against staging after merge.

Both, for different suites. Contract tests run against a local build with a throwaway database so a pull request does not depend on shared infrastructure. Integration, authorization and load tests need real dependencies, so they run against staging.

Start from risks. Every documented status code should be reached by at least one contract test, every operation that takes a resource ID needs a two-user authorization test, and every flow that moves money or deletes data needs an integration test. Beyond that, add a test when a bug escapes to production.

No. Retries hide flaky tests and can create duplicate records when the retried request is a POST. Quarantine a flaky test in a non-blocking job with an owner and a deadline, then fix the cause.

Sources

  1. OWASP API Security Top 10 (2023)OWASP
  2. RFC 6750: OAuth 2.0 Bearer Token Usage (error codes and WWW-Authenticate)IETF
  3. RFC 9110: HTTP Semantics, Section 9.2.2 Idempotent MethodsIETF