How to Write API Integration Tests

How to Write API Integration Tests

Integration tests earn their value at the boundaries where unit tests stop telling the truth about production behavior. That makes them less about coverage counts and more about proving that real workflows survive real system constraints.

Where API Failures Escape Unit Tests

An API can have excellent unit test coverage and still fail in production.

Endpoints pass happy-path assertions. Services are mocked cleanly. CI stays green. Then the real system starts behaving differently: duplicate requests create duplicate writes, auth rules fail for one role or tenant boundary, schema changes break older clients, retries expose race conditions that local tests never exercised, or a database transaction commits only part of the expected state.

These failures are rarely caused by a complete absence of tests. More often, they happen because the tests validated code in isolation while production depends on behavior across boundaries.

That is where API integration tests matter.


What API Integration Tests Are Actually For

API integration tests are not meant to replace unit tests. They exist to verify that the important parts of a request actually work together: HTTP routing and request parsing, authentication and authorization, input validation, database reads and writes, transaction boundaries, serialization and response shape, and interactions with queues, caches, or external services.

The goal is not to test every branch through the API layer. The goal is to catch bugs that only appear when multiple components are wired together in a realistic way.

If unit tests protect local correctness, integration tests protect system behavior.


The Common Mistake

Many teams say they have API integration tests, but what they actually have is one of two weaker patterns: controller tests with most dependencies mocked, or end-to-end smoke tests that cover a few paths but explain little when they fail.

The first misses real boundary problems. The second is often too slow and too broad to guide design.

A useful API integration test sits between them. It should exercise the real request path and real persistence layer while controlling only the dependencies that are genuinely outside the system boundary.

For most backend teams, that means running against a real test database, executing the real HTTP handler or application server, using real auth middleware, and stubbing only truly external systems such as payment providers, email services, or other third-party APIs.


What Good API Integration Tests Should Verify

A strong API integration test usually validates one or more of these concerns.

1. The request creates the correct persistent state

Do not stop at 200 OK or 201 Created. Check what was written.

If POST /orders succeeds, confirm that the row exists in the database, related records were created correctly, default fields were populated as expected, and invalid partial state was not left behind.

Many production bugs live behind successful status codes.

2. The API enforces real authorization rules

Auth bugs often survive unit tests because permissions are split across middleware, handlers, and data access rules.

Integration tests should prove that one user can access their own resource, another user cannot, tenant boundaries are enforced, and role-based actions are correctly allowed or rejected.

This is especially important for admin actions, billing flows, and any endpoint that mutates shared state.

3. The response contract matches what clients depend on

A passing test that checks only status code misses compatibility regressions.

Validate field presence, nullability expectations, enum values, pagination shape, and error response structure.

If you evolve APIs over time, this should connect directly to your compatibility strategy. That is closely related to the concerns in API Versioning Without Breaking Clients.

4. Failure paths leave the system in a safe state

This is where many API test suites are weak.

You should verify what happens when the database operation fails halfway through, a downstream dependency times out, validation passes but business rules reject the action, or duplicate requests arrive with the same idempotency key.

Production reliability depends on these cases more than on clean demos.

This is also where many teams discover the gap described in Why Tests Pass but Production Still Breaks.

Schema changes belong in this category too. If the application is reading and writing across old and new database shapes during a rollout, integration tests should prove that mixed-state data still behaves correctly. That rollout pattern is covered in Safe Database Migrations in Production.

5. Concurrency does not break correctness

Some of the highest-value API integration tests involve two requests, not one.

Examples:

  • two clients attempt the same reservation at the same time
  • two retries hit the same payment endpoint concurrently
  • two updates race on the same record

These are not exotic scenarios. They are ordinary production behavior once traffic and retries exist. If you want the system-design view of why these tests matter, see How to Prevent Race Conditions in Backend Systems.

If those tests fail, the next question is often not just "is the handler wrong?" but "what concurrency guarantees does the database actually provide?" For that layer, see SQL Isolation Levels Explained.

If your API supports safe retries, your tests should reflect the patterns discussed in Idempotency Keys for Duplicate API Requests.


A Practical Example

Imagine an endpoint:

POST /api/orders

The request should require authentication, validate item availability, create an order row, create order items, and publish an event after commit.

A useful integration test would verify more than the returned JSON.

it('creates an order and persists related state', async () => {
  const user = await createUser();
  const product = await createProduct({ stock: 3 });

  const response = await request(app)
    .post('/api/orders')
    .set('Authorization', `Bearer ${signToken(user)}`)
    .send({
      items: [{ productId: product.id, quantity: 2 }],
    });

  expect(response.status).toBe(201);
  expect(response.body.status).toBe('created');

  const order = await db.order.findFirst({
    where: { userId: user.id },
    include: { items: true },
  });

  expect(order).toBeTruthy();
  expect(order?.items).toHaveLength(1);
  expect(order?.items[0].quantity).toBe(2);

  const updatedProduct = await db.product.findUnique({
    where: { id: product.id },
  });

  expect(updatedProduct?.stock).toBe(1);
});

This test validates HTTP behavior, auth integration, database write correctness, and related entity updates.

That is already much closer to production than a mocked controller test.


The Highest-Value Test Cases Most Teams Skip

If you want API integration tests that catch real production bugs, prioritize the cases where a write can happen twice, permissions can leak across users or tenants, a partial failure can leave state behind, or a change in one client-facing contract can break existing consumers.

Duplicate request handling

For write endpoints, verify that the same idempotency key does not create two side effects. This matters most for payments, order creation, subscription changes, and webhook consumers.

For the webhook-specific production failure modes worth testing, see Webhook Idempotency and Retries in Production.

Transaction rollback behavior

If part of the operation fails, prove that the database does not keep partial writes.

This catches missing transaction boundaries, out-of-order side effects, and hidden coupling between repositories.

Authorization edge cases

Test users with different roles, organizations, and ownership relationships.

A surprising number of severe bugs are simple permission gaps that never appeared in happy-path tests.

Compatibility checks for existing clients

If the endpoint changed recently, add tests that ensure older request or response expectations still work where promised.

Query-count or performance-sensitive paths

For hot endpoints, integration tests can enforce rough guardrails such as keeping query count from exploding, ensuring pagination behaves predictably, and catching obvious N+1 behavior when fixtures get larger.

This is not full performance testing, but it can catch regressions early. It complements the database-focused issues covered in N+1 Query Problem in ORMs and How to Find and Fix Slow SQL Queries in Production.


What Not to Mock

If you mock too aggressively, integration tests collapse back into unit tests.

Avoid mocking your database layer, authentication middleware, serialization code, transaction handling, or repository code that is part of your application boundary.

Mock only what is outside your system boundary or too unstable to call directly in tests, such as third-party APIs, email providers, SMS gateways, payment processors, or external event consumers.

The rule is simple: mock external dependencies, not your own core behavior.


Common Mistakes That Make Integration Tests Look Better Than They Are

Using unrealistic fixtures

Tiny, clean datasets hide issues with ordering, nullability, duplicates, and authorization scope.

Asserting only status codes

A 201 without database verification often proves very little.

Running tests against a fake persistence layer

An in-memory substitute may behave differently from your real database in transactions, constraints, and query semantics.

Ignoring concurrency

Single-request tests miss races, duplicate writes, and retry behavior.

Testing too much in one scenario

If a test covers auth, validation, billing logic, side effects, and notifications all at once, failures become hard to diagnose.

Keep each integration test focused on one system behavior.


A Practical Testing Strategy for Backend Teams

A workable API testing pyramid usually combines unit tests for local business logic, API integration tests for request boundaries and persistence correctness, and a small number of end-to-end tests for full-system confidence.

For integration tests specifically:

  1. Cover the highest-risk write endpoints first.
  2. Use a real test database.
  3. Verify persisted state, not just responses.
  4. Add failure-path and authorization cases early.
  5. Add concurrency or idempotency tests where duplicates are costly.
  6. Keep the suite fast enough to run in CI.

This approach catches a large class of real bugs without turning the test suite into a slow simulation of production.


A Simple Coverage Matrix You Can Reuse

One useful way to keep API integration tests grounded is to think in terms of coverage dimensions instead of endpoints alone.

For an important write endpoint, ask whether the suite covers success path, validation failure, authorization failure, persistence correctness, rollback behavior, duplicate request handling, concurrency or race behavior, and response contract.

You do not need every dimension for every endpoint. But if an endpoint changes important state, several of them should be covered.

A lightweight matrix for a POST /orders endpoint might look like this:

ConcernExample test
Success pathauthenticated user creates a valid order
Authorizationuser cannot create an order for another tenant
Persistenceorder row and items are committed correctly
Rollbackfailed inventory update leaves no partial order
Idempotencyduplicate retry with same key does not create a second order
Contractresponse includes fields clients rely on

This kind of matrix is useful for junior developers because it makes "what should we test?" more concrete. It is also useful for experienced teams because it exposes which risk classes still have no coverage.


How to Decide Whether a New Integration Test Is Worth Adding

A good candidate usually matches at least one of these conditions: the endpoint mutates important business data, the logic spans multiple tables or services, authorization mistakes would be costly, retries or concurrency can create duplicate effects, the endpoint has broken in production before, or multiple clients depend on response compatibility.

If none of these are true, a unit test may be enough. If several are true, an integration test is usually the right investment.


A Short Review Checklist Before You Merge

Before calling an API integration test "done," check that it:

  • exercises the real request boundary
  • uses real persistence for the behavior being asserted
  • verifies state changes, not only HTTP status
  • keeps external dependencies stubbed at the edge
  • names the business behavior clearly
  • stays focused enough that failures are easy to interpret

That last point matters. An integration test that proves five different things at once is usually much harder to trust when it breaks.


Closing Reflection

API integration tests are valuable not because they increase coverage numbers.

They are valuable because they verify the place where production bugs usually live: between components, across boundaries, under assumptions that unit tests do not fully model.

The best integration tests do not try to copy production exactly. They focus on the contracts, state changes, and failure modes that matter most when the system is real.