DocsPricingPlatformsAPIsBlogFor agents Start for free →

Blog / engineering

engineering

How to Test APIs: A Developer's End-to-End Guide

Learn how to test APIs from end to end. Our guide covers REST/GraphQL, performance, security, CI/CD automation, and the tools you need for robust API testing.

letmepost.dev· June 17, 2026· 21 min read
How to Test APIs: A Developer's End-to-End Guide

You usually start caring about API testing after one of two things happens. A deploy goes out, every smoke test returns 200 OK, and production still breaks. Or a partner changes a field, retries a webhook out of order, and your system behaves incorrectly for hours.

That’s why most advice on how to test APIs feels incomplete. It stops at status codes and sample requests. Real systems fail at the seams: schema drift, auth edge cases, retries, rate limits, and dependencies that don’t fail cleanly.

A production-ready API test strategy treats testing as part of delivery, not cleanup. It checks correctness, but also contract stability, async behavior, abuse resistance, and whether the API still behaves under pressure. If you’re dealing with retries and duplicate side effects, the same engineering mindset behind exactly-once delivery applies here too. Design for repeatability, then test for it.

Beyond 200 OK The Real Goal of API Testing

A passing status code is a weak signal. An endpoint can return 200 OK and still send the wrong shape, leak fields a client shouldn’t see, omit headers your frontend depends on, or succeed only in the happy path while failing on normal user mistakes.

That’s why API testing has shifted from manual spot checks to continuous automation across the delivery lifecycle. Postman recommends running tests “at every stage of the API lifecycle” because waiting until the end lets defects sit around until they’re harder to fix. Their guidance also pushes teams toward dedicated environments, automated schedules, and reusable subtests instead of one-off inspection in a local client, as described in Postman’s API testing guidance.

Testing is about behavior not reachability

An API test should answer questions a pager will care about later:

  • Does the endpoint return the right status and body
  • Does it reject invalid input cleanly
  • Does auth behave correctly for missing expired or insufficient credentials
  • Does the contract stay stable for consumers
  • Does the system behave under retry load or dependency failure

IBM’s description of API testing fits this well. You send requests to endpoints and validate status codes, response bodies, data structures, authentication, authorization, and performance or security expectations. That makes APIs a good fit for automation instead of manual checking.

Practical rule: If a test only proves the server responded, it hasn’t proved the feature works.

The real target is confidence under change

The best API suites are reusable, deterministic, and CI-friendly. They run on every meaningful change, use isolated environments, and encode expected behavior so another engineer can change the service without guessing what matters.

That’s the heart of how to test APIs well. You’re not building a checklist for QA at the end. You’re building a safety net that lets the team ship without wondering which hidden contract they just broke.

Planning Your API Testing Strategy

A release goes out on Friday. Health checks stay green, but a billing endpoint starts creating duplicate charges when clients retry after a timeout. The root problem is rarely “we forgot to test.” It is usually “we tested the wrong things.”

how-to-test-apis-api-testing.jpg

Planning starts with risk. A GET /status route and a POST /payments route should not get the same depth, the same environments, or the same release gate. Teams that skip this step end up with lots of tests around safe reads and very little protection around writes, retries, auth boundaries, and async side effects.

If your team still passes around hand-written request examples and clicks through a client before release, fix the plan before adding more test cases. Good API documentation tools help keep examples and schemas current, but they do not decide what deserves strong coverage, what can be sampled, and what must block deployment.

Start with risk, side effects, and consumer impact

Use three questions for every endpoint:

  1. What breaks if this fails
  2. Does it change data or trigger money, email, or permissions
  3. Who depends on the response shape staying stable

That pushes the suite toward production failure modes instead of tool-driven categories.

  • Integration tests cover your service boundary. Use them for route wiring, serialization, database writes, auth middleware, and idempotency rules.
  • End-to-end tests cover a small set of critical journeys across real dependencies. Use them where queues, third-party APIs, or multiple services can fail together.
  • Contract tests protect consumers from field removals, enum changes, and schema drift. They matter more as soon as another team or customer SDK depends on your API.
  • Performance tests answer capacity questions. Use them on hot paths, expensive queries, and endpoints with bursty traffic or strict latency budgets.
  • Security and resilience tests cover the ways real systems get abused or degraded. Include broken auth checks, malformed input, retry storms, dependency timeouts, and rate-limit behavior.

A good strategy mixes these by endpoint criticality. It does not try to maximize one test type across the whole API.

API Test Types Compared

Test TypePrimary GoalWhen to Use
IntegrationVerify endpoint behavior inside your app boundaryOn every pull request and local development
End-to-endValidate real workflows across servicesBefore release, on staging, and for critical journeys
ContractProtect consumers and providers from schema driftWhenever multiple services or third-party clients depend on your API
PerformanceMeasure latency, throughput, and failures under realistic trafficBefore major releases and on endpoints with meaningful load
SecurityCheck authorization, input validation, and abuse controlsContinuously in CI and before exposing new endpoints publicly

How much coverage is enough

Line coverage is a weak planning tool for APIs. It can look healthy while your highest-risk behaviors still have no protection. What matters more is behavior coverage on endpoints that can cost money, corrupt data, expose data, or break consumers.

A practical order looks like this:

  1. Critical writes first. Create, update, delete, charge, send, import, export, and permission-changing endpoints get automated early.
  2. Failure paths second. Invalid payloads, expired tokens, duplicate requests, conflicts, missing resources, and forbidden access usually catch more real bugs than another happy-path assertion.
  3. Cross-service contracts third. Add schema and webhook verification where another service, partner, or frontend relies on exact field names and event shapes.
  4. Capacity and abuse cases next. Test rate limits, retries, backoff, timeout handling, and burst traffic where real usage can stress the system.

This is usually enough to answer the question teams ask in practice: do we have the right tests to ship without guessing? If the suite can catch contract drift, duplicate side effects, bad auth decisions, and common failure paths before deployment, coverage is doing its job.

One rule helps keep the plan honest. Every endpoint that mutates state should have a clear test owner, expected failure modes, and a defined place in CI. If nobody can say where that endpoint is verified, it is under-tested.

Writing Core API Functional Tests

Functional tests are the floor. If these are flaky or shallow, the rest of the suite won’t save you. The core job is simple: prove the API behaves correctly for expected requests and fails predictably for bad ones.

StackHawk’s guidance is the right baseline here. High-quality API tests should validate both positive and negative paths, including correct success codes, graceful handling of invalid input, and checks around authentication, authorization, headers, formats, and edge cases, as outlined in StackHawk’s guide to API test cases.

REST tests with Jest and Supertest

Here’s a minimal Express app test setup using jest and supertest. The examples focus on behavior, not just status codes.

// app.test.js
const request = require('supertest');
const app = require('../app');

describe('GET /users/:id', () => {
  test('returns a user payload for an existing user', async () => {
    const res = await request(app)
      .get('/users/123')
      .set('Accept', 'application/json');

    expect(res.status).toBe(200);
    expect(res.headers['content-type']).toMatch(/json/);
    expect(res.body).toEqual(
      expect.objectContaining({
        id: '123',
        email: expect.any(String),
        name: expect.any(String),
      })
    );
  });

  test('returns 404 for a missing user', async () => {
    const res = await request(app)
      .get('/users/missing-id')
      .set('Accept', 'application/json');

    expect(res.status).toBe(404);
    expect(res.body).toEqual(
      expect.objectContaining({
        error: expect.any(String),
      })
    );
  });
});

describe('POST /users', () => {
  test('creates a user with valid input', async () => {
    const payload = {
      email: 'dev@example.com',
      name: 'Dev Example'
    };

    const res = await request(app)
      .post('/users')
      .send(payload)
      .set('Content-Type', 'application/json');

    expect(res.status).toBe(201);
    expect(res.headers.location).toBeDefined();
    expect(res.body).toEqual(
      expect.objectContaining({
        id: expect.any(String),
        email: 'dev@example.com',
        name: 'Dev Example',
      })
    );
  });

  test('rejects invalid input with 400', async () => {
    const res = await request(app)
      .post('/users')
      .send({ email: 'not-an-email' })
      .set('Content-Type', 'application/json');

    expect(res.status).toBe(400);
    expect(res.body).toEqual(
      expect.objectContaining({
        error: expect.any(String),
      })
    );
  });
});

A few things matter here:

  • Assert headers when clients depend on them.
  • Use partial body matching so tests don’t become brittle because of unrelated fields.
  • Name the failure clearly. “rejects invalid input with 400” is better than “bad request test.”
  • Keep fixtures intentional. Random payload generation is useful, but not when it hides what the test is proving.

If your API triggers downstream work like publishing, notifications, or queued jobs, test the API boundary first and verify side effects separately. Don’t turn every route test into a giant integration scenario. That’s how fast suites become slow and untrusted. The same discipline matters when building APIs for content workflows and posting to social media through APIs.

GraphQL tests that catch real regressions

GraphQL needs a slightly different mindset. You’re usually checking data, errors, and whether the returned structure matches what the client asked for.

// graphql.test.js
const request = require('supertest');
const app = require('../app');

describe('GraphQL query', () => {
  test('returns user data for a valid query', async () => {
    const query = `
      query {
        user(id: "123") {
          id
          email
          name
        }
      }
    `;

    const res = await request(app)
      .post('/graphql')
      .send({ query })
      .set('Content-Type', 'application/json');

    expect(res.status).toBe(200);
    expect(res.body.errors).toBeUndefined();
    expect(res.body.data.user).toEqual(
      expect.objectContaining({
        id: '123',
        email: expect.any(String),
        name: expect.any(String),
      })
    );
  });

  test('returns an error for an invalid field selection', async () => {
    const query = `
      query {
        user(id: "123") {
          id
          fieldThatDoesNotExist
        }
      }
    `;

    const res = await request(app)
      .post('/graphql')
      .send({ query })
      .set('Content-Type', 'application/json');

    expect(res.status).toBe(400);
    expect(res.body.errors).toBeDefined();
    expect(Array.isArray(res.body.errors)).toBe(true);
  });
});

describe('GraphQL mutation', () => {
  test('creates a user through mutation', async () => {
    const query = `
      mutation {
        createUser(input: {
          email: "dev@example.com",
          name: "GraphQL Dev"
        }) {
          id
          email
          name
        }
      }
    `;

    const res = await request(app)
      .post('/graphql')
      .send({ query })
      .set('Content-Type', 'application/json');

    expect(res.status).toBe(200);
    expect(res.body.errors).toBeUndefined();
    expect(res.body.data.createUser).toEqual(
      expect.objectContaining({
        id: expect.any(String),
        email: 'dev@example.com',
        name: 'GraphQL Dev',
      })
    );
  });
});

A common mistake in GraphQL tests is checking only that data exists. That misses partial failures, resolver auth issues, and schema changes that still produce a response envelope.

Test GraphQL errors deliberately. Many regressions still return HTTP success while failing at the resolver or schema layer.

What strong functional assertions look like

Good functional tests check more than success. They verify:

  • Status behavior for success, validation failure, unauthorized access, forbidden access, and missing resources
  • Payload shape including required fields and excluding fields that shouldn’t leak
  • Headers and content type when caching, pagination, auth, or client parsing depends on them
  • State transitions such as create then fetch, or delete then verify absence
  • Idempotent behavior where retries shouldn’t create duplicate side effects

Weak tests tend to check one route, one happy path, one status code. Strong tests encode the API contract other systems depend on.

Verifying Contracts and Asynchronous Webhooks

Most ugly API failures happen between systems, not inside one route handler. A provider removes a field it thought nobody used. A webhook receiver retries the same event. A consumer assumes ordering that the sender never promised.

That’s where contract tests and webhook verification earn their keep.

Contract tests stop silent integration breakage

Integration tests prove your service works with itself. Contract tests prove your service still speaks the language another service expects.

If you have a frontend, another internal service, or external consumers pinned to your schema, contract tests should run whenever the provider changes response fields, request requirements, or enum values. Tools like Pact are useful because they encode consumer expectations into something the provider can verify automatically.

A practical contract test workflow looks like this:

  1. Consumer defines expectations for request and response shape.
  2. Provider verifies those expectations against the current implementation.
  3. CI blocks merges when the provider breaks an agreed contract.

This is much cheaper than discovering breakage through a staging environment that only exercises one path.

Webhook tests need signature retry and idempotency checks

Webhooks are just APIs with delayed consequences. Teams often test only whether a payload arrived. That’s not enough.

A useful webhook test suite should verify:

  • Signature validation so you reject spoofed requests
  • Payload parsing for required fields and versioned schemas
  • Retry handling so duplicate deliveries don’t duplicate side effects
  • Out-of-order events if the sender doesn’t guarantee sequence
  • Failure responses so your receiver returns the right code when processing fails

For HMAC-signed events, write tests against the raw body, not the parsed JSON. Signature verification often fails in production because middleware mutates the body before you compute the digest.

const crypto = require('crypto');

function signPayload(secret, rawBody) {
  return crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
}

test('accepts a webhook with a valid signature', async () => {
  const rawBody = JSON.stringify({
    event: 'order.created',
    id: 'evt_123',
    data: { orderId: 'ord_1' }
  });

  const signature = signPayload(process.env.WEBHOOK_SECRET, rawBody);

  const res = await request(app)
    .post('/webhooks/provider')
    .set('Content-Type', 'application/json')
    .set('x-signature', signature)
    .send(rawBody);

  expect(res.status).toBe(200);
});

test('rejects a webhook with an invalid signature', async () => {
  const rawBody = JSON.stringify({
    event: 'order.created',
    id: 'evt_123',
    data: { orderId: 'ord_1' }
  });

  const res = await request(app)
    .post('/webhooks/provider')
    .set('Content-Type', 'application/json')
    .set('x-signature', 'bad-signature')
    .send(rawBody);

  expect(res.status).toBe(401);
});

You also want an idempotency test:

test('processing the same webhook twice does not create duplicate side effects', async () => {
  const payload = { eventId: 'evt_abc', type: 'invoice.paid' };

  await processWebhook(payload);
  await processWebhook(payload);

  const records = await db.payments.findMany({ eventId: 'evt_abc' });
  expect(records).toHaveLength(1);
});

A reliable webhook consumer treats retries as normal traffic, not as an exceptional event.

If you’re exposing webhook endpoints publicly, publish a clear verification model and test fixture examples. Good webhook docs reduce support load, and a concrete reference like the letmepost webhooks documentation shows what a testable webhook surface should look like.

Measuring API Performance and Load Capacity

An API can be functionally correct and still be broken. If it falls apart under ordinary traffic, times out on key writes, or starts rate-limiting unpredictably, users don’t care that the JSON schema is perfect.

Performance testing works when you stop treating it like a mystery and start measuring a few signals consistently.

how-to-test-apis-api-performance.jpg

Measure the right signals

A practical workflow is to establish a baseline, then validate response time, throughput, and error rate under realistic traffic. Gravitee also recommends focusing on 95th and 99th percentile latency instead of averages, because those percentiles reflect user experience more accurately than a single mean value, as described in Gravitee’s guide to API performance metrics and load strategies.

For load and rate-limit testing, LogicMonitor recommends starting at expected average traffic, then testing at peak load and again at 2–3× peak load as a safety margin. It also recommends using realistic traffic profiles, pre-generating auth tokens so auth overhead doesn’t skew results, and tracking requests per second, the percentage of requests that hit limits, and the count of 429 responses during rate-limit validation, as explained in LogicMonitor’s API performance testing guide.

That gives you a concrete checklist:

  • Latency at baseline and during load
  • Throughput as concurrency rises
  • Error rate correlated with latency
  • 429 behavior when rate limiting should kick in
  • Critical endpoint coverage, not just the happy path

A practical k6 script

Here’s a basic k6 test that checks a read endpoint and a write endpoint, while also counting 429 responses.

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

export const options = {
  scenarios: {
    steady_load: {
      executor: 'ramping-vus',
      stages: [
        { duration: '1m', target: 20 },
        { duration: '3m', target: 20 },
        { duration: '1m', target: 60 },
        { duration: '3m', target: 60 },
      ],
    },
  },
  thresholds: {
    http_req_failed: ['rate<0.05'],
    http_req_duration: ['p(95)<500'],
  },
};

const BASE_URL = __ENV.BASE_URL;
const TOKEN = __ENV.API_TOKEN;

export default function () {
  const params = {
    headers: {
      Authorization: `Bearer ${TOKEN}`,
      'Content-Type': 'application/json',
    },
  };

  const getRes = http.get(`${BASE_URL}/users/123`, params);
  check(getRes, {
    'GET returned success or rate limit': (r) => [200, 429].includes(r.status),
  });

  const payload = JSON.stringify({
    title: 'Load test item',
    body: 'Created during performance test',
  });

  const postRes = http.post(`${BASE_URL}/posts`, payload, params);
  check(postRes, {
    'POST returned expected status': (r) => [201, 429].includes(r.status),
  });

  sleep(1);
}

This script is intentionally small. The point is to build from a realistic request mix, not brute-force one endpoint with uniform traffic forever.

What to look for in results

Don’t read latency in isolation. If p95 climbs while throughput flattens and error rate rises, you’ve likely found contention, saturation, or weak downstream dependencies.

Use performance runs to answer operational questions:

  • Where does latency bend sharply
  • Which endpoints degrade first
  • Do retries amplify the problem
  • Are rate limits stable or noisy
  • Does auth dominate latency because tokens are generated inside the run

If you only test a single GET route with no auth, no write path, and no realistic concurrency mix, you’re measuring a lab demo, not your API.

Testing for Security and Resilience

Security testing is not optional for public APIs. If the endpoint is reachable from the internet, attackers will test it whether you do or not.

The priority areas are well known. The 2025 OWASP API Security Top 10 continues to emphasize risks like broken object level authorization and unrestricted resource consumption, which keeps authorization flaws and abuse controls near the top of any serious API test plan, as summarized in StackHawk’s API security best practices overview.

how-to-test-apis-api-security.jpg

Start with authorization and abuse paths

The fastest way to find dangerous bugs is to test what a user should not be able to do.

For BOLA, create two users and verify one user cannot fetch, update, or delete the other user’s resources by swapping identifiers. Don’t stop at direct reads. Test nested resources, attachment URLs, and admin-looking endpoints that rely on hidden client assumptions.

For resource abuse, test whether one client can consume disproportionate capacity through oversized payloads, aggressive pagination, repeated expensive queries, or rapid retries. You’re checking whether your controls are enforced consistently, not just configured.

A minimal security test matrix should include:

  • Authentication failures for missing malformed and expired credentials
  • Authorization checks across resource ownership and role boundaries
  • Input validation for malformed JSON, unexpected types, and oversized fields
  • Rate limiting for bursty repeated access
  • Error handling so stack traces and internals don’t leak to clients

Resilience tests that find ugly failures early

Resilience testing sits next to security because production incidents rarely arrive one variable at a time. Bad input, partial outages, and retries happen together.

Use fuzzing to send malformed payloads, strange unicode, unexpected nesting, and invalid content types. You’re not looking for elegant failures. You’re looking for crashes, timeouts, and parser behavior that bypasses normal validation.

Inject dependency failures too:

  • Return timeouts from a downstream service
  • Return malformed JSON from an upstream dependency
  • Force partial database unavailability
  • Retry the same request after a timeout boundary
  • Send the same idempotency key twice with conflicting payloads

Then check the API’s response behavior. It should fail predictably, avoid duplicate side effects, and avoid exposing internals.

Security bugs and resilience bugs often share the same root cause. The service trusted an assumption it should have verified.

One more thing matters here. Avoid reducing security to a scanner report. Static and dynamic tools help, but a human-designed abuse test usually finds the bugs that hurt the most because it follows the actual business flow, not just the syntax.

Automating API Tests in a CI/CD Pipeline

A pipeline earns its keep the first time it catches a breaking change before production does. A handler still returns 200 in local testing, but the staging deploy shows the webhook signature check is broken, the contract no longer matches what consumers expect, or a retry path starts creating duplicate records. CI is where those failures should surface.

Coverage numbers can still serve as a rough guardrail, but they are a poor release decision on their own. The key question is simpler. Does each change trigger the tests that cover your highest-risk behavior: core business flows, contracts, auth rules, webhooks, failure handling, and deployment-specific checks? If the answer is no, a high percentage will not save you.

how-to-test-apis-api-pipeline.jpg

Split the pipeline by feedback speed

The fastest way to make API CI useless is to dump every test into one slow job. Teams stop trusting it, then start bypassing it.

Split jobs by runtime and by purpose:

  • Pull request checks should run in minutes. Keep unit, integration, schema, and contract tests here.
  • Post-merge or staging checks can afford more setup. Run end-to-end flows, performance smoke tests, webhook delivery verification, and selected security scans.
  • Scheduled jobs should cover the checks that are too expensive or too noisy for every commit, such as dependency drift, long-running workflow validation, and periodic abuse tests.

That split answers the practical “how much is enough” question better than a blanket target. Put fast, high-signal tests in front of every merge. Put slower environment and deployment checks where they catch release risk without slowing daily work.

Secrets handling matters here too. CI failures caused by expired tokens or copied credentials waste hours, and leaked test keys become real incidents. Use managed injection, short-lived credentials, and rotation policies like the ones described in secrets management best practices for CI and production systems.

Later in the pipeline, it helps to add a visual reminder of the flow your team is enforcing.

embed

A GitHub Actions example

Here’s a basic pipeline that runs integration and contract tests on pull requests, then runs a performance smoke test after deployment to staging.

name: api-tests

on:
  pull_request:
  push:
    branches: [main]

jobs:
  integration:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: app_test
        ports:
          - 5432:5432
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run migrate:test
      - run: npm run test:integration

  contract:
    runs-on: ubuntu-latest
    needs: integration
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run test:contract

  deploy-staging:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    needs: contract
    steps:
      - run: echo "deploy to staging here"

  performance-smoke:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    needs: deploy-staging
    steps:
      - uses: actions/checkout@v4
      - run: docker run --rm -i grafana/k6 run -e BASE_URL=${{ secrets.STAGING_API_URL }} -e API_TOKEN=${{ secrets.STAGING_API_TOKEN }} - < perf/smoke.js

This layout is a solid starting point, but production teams usually add two more controls. First, publish test reports and artifacts so a failed run shows the request, response, logs, and contract diff without forcing someone to reproduce it locally. Second, gate deployments on the checks that match the environment. A contract failure should block merge. A staging smoke failure should block promotion.

Stable CI depends on test data and mocks

Flaky pipelines usually come from state problems. The assertions are often fine.

Three habits prevent most of that pain:

  1. Create isolated test data per run. Factories, unique IDs, and database transactions keep jobs from colliding with each other.
  2. Reset state aggressively. If the suite leaves records, queues, or cached values behind, the next run will fail for the wrong reason.
  3. Mock external dependencies only when that makes the test more truthful. If you are testing your API’s validation or retry logic, stub the vendor. If you are testing the actual integration contract, hit the actual dependency in a controlled environment.

That last trade-off matters. Over-mocking gives you fast green builds that miss production breakage. Under-mocking gives you slow, noisy CI tied to another company’s uptime. Use mocks for determinism. Use real integrations where the integration itself is the risk.

For HTTP dependency mocking, msw works well in JavaScript-heavy stacks, and WireMock is useful when you need a standalone stub server with strict request matching.

If your team is asking how to test APIs at scale, this is usually the answer. Build a pipeline that gives fast feedback on every change, verifies the contracts and runtime behaviors that break releases, and keeps test state under control.

If you’re building product features that publish content across platforms, letmepost gives you a single API for multi-platform posting, scheduling, idempotency, and HMAC-signed webhooks. It’s open source, works for hosted or self-hosted setups, and fits cleanly into the kind of testable, automation-first API workflow described above.

Publish everywhere from one POST.

Free during alpha. Connect an account and send your first post in ninety seconds.

Start for free →