
How to Write API Integration Tests
API integration tests prove that a real API request works across the boundaries that unit tests usually replace: routing, authentication, validation, persistence, transactions, response serialization, and failure handling.
That is why good API integration testing is not about adding a few slow tests around every controller. It is about choosing the endpoints where production bugs are most likely to hide, then proving the behavior that matters through the real request path.
For the broader set of API correctness problems around idempotency, race conditions, webhooks, versioning, and tests, see the API Correctness hub. For the delivery path from test coverage to contract safety, staged releases, and live migrations, see Testing And Software Delivery.
What API Integration Tests Should Prove
An API integration test should answer a more practical question than "does this function return the right value?"
It should answer:
When a real request reaches this API boundary, does the system produce the correct durable behavior?
For backend APIs, that usually means verifying:
- HTTP routing and request parsing
- authentication and authorization
- validation and error shape
- database reads and writes
- transaction boundaries
- response contract
- duplicate request handling
- concurrency behavior
- external dependency behavior at the edge
This is different from a unit test and different from a full end-to-end test.
| Test type | Main question | Typical boundary |
|---|---|---|
| Unit test | Does this small piece of logic work? | Function, class, helper, domain service |
| API integration test | Does a real API request produce correct system behavior? | HTTP handler, middleware, database, app-owned dependencies |
| End-to-end test | Does the whole user journey work through deployed systems? | Browser or client through full stack |
The integration test lives in the middle. It should be realistic enough to catch wiring, auth, persistence, and transaction bugs, but focused enough that a failure tells you what behavior broke.
Start With Risk, Not Endpoint Count
The most common mistake is deciding that every endpoint needs the same number of integration tests. That creates slow suites full of low-value tests and still misses the dangerous cases.
Start with risk instead.
Write API integration tests first for endpoints that:
- mutate important business data
- cross authentication or tenant boundaries
- write more than one table
- rely on a transaction
- call a queue, email provider, payment service, or webhook handler
- must tolerate retries or duplicate requests
- have response shapes external clients depend on
- have failed in production before
A read-only endpoint that returns a small static list may need only a couple of tests. A payment, order, subscription, access-control, or webhook endpoint deserves much more careful coverage.
The goal is not equal coverage. The goal is risk-weighted confidence.
A Concrete Example: POST /api/orders
Imagine an API endpoint that creates an order.
POST /api/orders
Authorization: Bearer <token>
Idempotency-Key: order_01HZX5K7M2
Content-Type: application/json
{
"items": [
{
"productId": "prod_123",
"quantity": 2
}
],
"shippingAddressId": "addr_456"
}
A successful response:
{
"id": "ord_789",
"status": "created",
"total": 4200,
"currency": "USD",
"items": [
{
"productId": "prod_123",
"quantity": 2,
"unitPrice": 2100
}
]
}
This endpoint looks simple from the outside. Inside the system, it may need to:
- authenticate the user
- verify the shipping address belongs to the user or tenant
- validate inventory
- calculate price
- create the order row
- create order item rows
- reduce available inventory
- store an idempotency record
- publish an
order.createdevent after commit - return a stable response contract
That is exactly where API integration tests are valuable. The risk is not that one helper function adds numbers incorrectly. The risk is that the real request crosses several boundaries and one of them behaves differently in production than it did in isolated tests.
Build A Test Matrix Before Writing Code
For a cornerstone endpoint, write a small test matrix before writing individual tests.
| Concern | Test case | What it proves |
|---|---|---|
| Success path | Authenticated user creates a valid order | Request, auth, validation, persistence, response shape |
| Persistence | Order, items, and inventory changes are committed correctly | State matches the business operation |
| Authorization | User cannot use another tenant's address | Access rules run through the real request path |
| Validation | Missing item quantity returns a useful error | Bad input does not reach business writes |
| Rollback | Inventory failure leaves no partial order | Transaction boundary is correct |
| Idempotency | Same key does not create a second order | Retry-safe write behavior |
| Concurrency | Two requests cannot reserve the last unit twice | Race condition is controlled |
| Contract | Response keeps fields clients depend on | Client-facing compatibility is protected |
This matrix prevents two common failures.
First, it keeps the suite from becoming a random pile of tests. Second, it makes missing risk visible. If there is no test for duplicate requests, tenant isolation, or rollback behavior, the gap is obvious before production finds it for you.
Use The Real Boundary And Stub The Edge
A useful REST API integration test should exercise the application boundary, not a mocked controller.
Prefer real:
- HTTP route handler or app server
- request parsing
- auth middleware
- validation middleware
- persistence layer
- database constraints
- transaction behavior
- response serialization
Stub or fake only the systems outside your ownership boundary:
- payment processor
- email provider
- SMS provider
- third-party API
- external event consumer
- object storage, if calling the real service would make tests slow or flaky
The practical rule:
Mock external dependencies, not your own application behavior.
If the database is mocked, the test probably cannot catch transaction, constraint, query, or persistence bugs. If auth middleware is bypassed, the test probably cannot catch permission bugs. If serialization is skipped, the test probably cannot catch response contract regressions.
A Minimal Test Harness
The examples below use TypeScript-shaped pseudocode with a request(app) style test client. The exact library is less important than the boundary.
The test should create realistic fixtures, call the API through HTTP, and inspect durable state afterward.
async function createOrderRequest({
user,
body,
idempotencyKey = 'test-key-1',
}: {
user: TestUser
body: unknown
idempotencyKey?: string
}) {
return request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${signTestToken(user)}`)
.set('Idempotency-Key', idempotencyKey)
.send(body)
}
The setup should make each test independent.
beforeEach(async () => {
await db.test.reset()
paymentProvider.reset()
eventBus.reset()
})
The reset strategy can be transaction rollback, truncation, schema recreation, or isolated test databases. Choose the method that keeps the suite reliable and fast enough for CI. If this is where the suite already hurts, the companion guide Flaky Integration Tests in CI walks through shared state, per-worker isolation, timing assumptions, and service readiness in more detail.
The important part is determinism. An integration test that passes only when run alone is not a safety net.
Test The Successful Write Path
The first test should prove the real workflow works from request to persisted state.
it('creates an order and persists related state', async () => {
const user = await fixtures.user()
const address = await fixtures.address({ userId: user.id })
const product = await fixtures.product({ stock: 3, price: 2100 })
const response = await createOrderRequest({
user,
body: {
items: [{ productId: product.id, quantity: 2 }],
shippingAddressId: address.id,
},
})
expect(response.status).toBe(201)
expect(response.body).toMatchObject({
status: 'created',
total: 4200,
currency: 'USD',
})
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].productId).toBe(product.id)
expect(order?.items[0].quantity).toBe(2)
const updatedProduct = await db.product.findUnique({
where: { id: product.id },
})
expect(updatedProduct?.stock).toBe(1)
})
This test proves more than "the endpoint returns 201." It proves the request crossed auth, validation, business logic, persistence, and serialization in one coherent workflow.
That is the point of API integration testing.
Test Authorization Through Real Request State
Authorization bugs often survive unit tests because the rule is split across middleware, request context, route handlers, and database filters.
Test the boundary where those pieces meet.
it('rejects a shipping address from another tenant', async () => {
const user = await fixtures.user({ tenantId: 'tenant-a' })
const otherUser = await fixtures.user({ tenantId: 'tenant-b' })
const otherAddress = await fixtures.address({ userId: otherUser.id })
const product = await fixtures.product({ stock: 3, price: 2100 })
const response = await createOrderRequest({
user,
body: {
items: [{ productId: product.id, quantity: 1 }],
shippingAddressId: otherAddress.id,
},
})
expect(response.status).toBe(403)
const orders = await db.order.findMany({
where: { userId: user.id },
})
expect(orders).toHaveLength(0)
})
This catches a class of bugs a mocked service test often misses. The user may be authenticated correctly, but the data access path still has to enforce ownership or tenant scope.
For APIs that carry client compatibility promises, authorization tests should also protect response shape and error semantics. That connects directly to API Versioning Without Breaking Clients.
Test Validation And Error Contracts
Validation tests should prove two things:
- invalid input is rejected before unsafe writes happen
- the error shape is stable enough for clients to handle
it('rejects an item without a positive quantity', async () => {
const user = await fixtures.user()
const product = await fixtures.product({ stock: 3, price: 2100 })
const response = await createOrderRequest({
user,
body: {
items: [{ productId: product.id, quantity: 0 }],
shippingAddressId: 'addr_123',
},
})
expect(response.status).toBe(422)
expect(response.body).toMatchObject({
error: {
code: 'invalid_request',
fields: {
'items.0.quantity': 'must be greater than 0',
},
},
})
expect(await db.order.count()).toBe(0)
})
The database assertion matters. A validation error that still creates partial state is not a harmless client error. It is a correctness bug with a nice response.
Test Rollback And Partial Failure
Many production incidents happen when an API returns an error but leaves behind state that future requests must now interpret.
For an order endpoint, inventory update failure should not leave a committed order.
it('does not persist a partial order when inventory update fails', async () => {
const user = await fixtures.user()
const address = await fixtures.address({ userId: user.id })
const product = await fixtures.product({ stock: 1, price: 2100 })
inventoryService.failNextReservation()
const response = await createOrderRequest({
user,
body: {
items: [{ productId: product.id, quantity: 1 }],
shippingAddressId: address.id,
},
})
expect(response.status).toBe(409)
expect(response.body.error.code).toBe('inventory_unavailable')
expect(await db.order.count()).toBe(0)
expect(await db.orderItem.count()).toBe(0)
const unchangedProduct = await db.product.findUnique({
where: { id: product.id },
})
expect(unchangedProduct?.stock).toBe(1)
})
This test is valuable because it validates the transaction boundary, not just the error branch.
If the code writes the order before reserving inventory and forgets to roll back, this test catches the bug. If the code emits an event before commit, this style of test can catch that too by asserting the fake event bus received nothing.
Test Idempotency For Retry-Safe Writes
Any important write endpoint that clients may retry should have an idempotency test.
The risk is simple: the first request may succeed, but the client times out before receiving the response. If the client retries, the API must not create the same business effect twice.
it('replays the same order result for the same idempotency key', async () => {
const user = await fixtures.user()
const address = await fixtures.address({ userId: user.id })
const product = await fixtures.product({ stock: 5, price: 2100 })
const body = {
items: [{ productId: product.id, quantity: 2 }],
shippingAddressId: address.id,
}
const first = await createOrderRequest({
user,
body,
idempotencyKey: 'order-key-1',
})
const second = await createOrderRequest({
user,
body,
idempotencyKey: 'order-key-1',
})
expect(first.status).toBe(201)
expect(second.status).toBe(201)
expect(second.body.id).toBe(first.body.id)
const orders = await db.order.findMany({
where: { userId: user.id },
})
expect(orders).toHaveLength(1)
})
That test protects the API boundary where HTTP, storage, and business side effects meet.
If the same key is reused with a different payload, test that too:
it('rejects an idempotency key reused with a different request body', async () => {
const user = await fixtures.user()
const address = await fixtures.address({ userId: user.id })
const product = await fixtures.product({ stock: 5, price: 2100 })
await createOrderRequest({
user,
idempotencyKey: 'order-key-2',
body: {
items: [{ productId: product.id, quantity: 1 }],
shippingAddressId: address.id,
},
})
const response = await createOrderRequest({
user,
idempotencyKey: 'order-key-2',
body: {
items: [{ productId: product.id, quantity: 2 }],
shippingAddressId: address.id,
},
})
expect(response.status).toBe(409)
expect(response.body.error.code).toBe('idempotency_key_conflict')
})
For the storage model behind this behavior, see API Idempotency Keys: Prevent Duplicate Requests Safely.
Test Concurrency When Timing Matters
Single-request tests cannot prove that a write endpoint behaves correctly when requests overlap.
For example, if only one unit is in stock, two concurrent requests should not both create successful orders.
it('does not sell the last item twice under concurrent requests', async () => {
const user = await fixtures.user()
const address = await fixtures.address({ userId: user.id })
const product = await fixtures.product({ stock: 1, price: 2100 })
const body = {
items: [{ productId: product.id, quantity: 1 }],
shippingAddressId: address.id,
}
const [first, second] = await Promise.all([
createOrderRequest({ user, body, idempotencyKey: 'race-1' }),
createOrderRequest({ user, body, idempotencyKey: 'race-2' }),
])
const statuses = [first.status, second.status].sort()
expect(statuses).toEqual([201, 409])
expect(await db.order.count()).toBe(1)
const updatedProduct = await db.product.findUnique({
where: { id: product.id },
})
expect(updatedProduct?.stock).toBe(0)
})
This is not a perfect substitute for production load testing. It is still a powerful guardrail because it exercises the real storage mechanism.
If this test is flaky, do not ignore it. Flakiness around overlapping writes is often a signal that the underlying concurrency control is unclear. The broader design problem is covered in How to Prevent Race Conditions in Backend Systems.
Test Response Contracts Deliberately
API integration tests are a good place to protect the response fields clients actually depend on.
That does not mean snapshotting huge JSON payloads. Large snapshots often hide important changes inside noise.
Prefer focused contract assertions:
expect(response.body).toMatchObject({
id: expect.any(String),
status: 'created',
total: 4200,
currency: 'USD',
items: [
{
productId: product.id,
quantity: 2,
unitPrice: 2100,
},
],
})
Also test error contracts:
expect(response.body).toMatchObject({
error: {
code: 'inventory_unavailable',
message: expect.any(String),
},
})
Clients often build logic around error codes, enum values, nullable fields, pagination shape, and response nesting. Those are contract details. If they change accidentally, integration tests should make the break visible before deployment.
For a dedicated compatibility gate around OpenAPI diffs, breaking-change rules, and consumer examples, use the workflow in API Contract Testing: Prevent Breaking Clients Before Release.
What Not To Mock
Mocking is not bad. Mocking the wrong boundary is bad.
Avoid mocking:
- your database layer
- transaction handling
- authentication middleware
- authorization filters
- request validation
- response serialization
- repository code that belongs to your application
- idempotency storage
These are exactly the boundaries the integration test is meant to prove.
Good candidates to stub:
- payment providers
- email providers
- SMS providers
- third-party APIs
- external webhook consumers
- object storage clients
- analytics sinks
When an external dependency is stubbed, make the stub behavior explicit. It should be able to return success, validation failure, timeout, duplicate callback, or provider error when those cases matter.
Keep The Suite Fast Enough To Matter
API integration tests are more expensive than unit tests. That is fine. But they still need to run often enough to influence engineering behavior.
Practical techniques:
- keep fixtures small but realistic
- create fixture helpers that express business objects, not raw rows
- reset database state deterministically
- run external dependencies as stubs at the process edge
- avoid sleeping in tests unless timing itself is the behavior under test
- keep each test focused on one risk
- split very slow cross-system tests into a separate job
Do not make every integration test a full end-to-end scenario. If a test needs a browser, a real payment provider, a real email account, and a production-like deployment, it is probably not an API integration test anymore.
A Reusable API Integration Test Checklist
Use this checklist when adding or reviewing tests for an important API endpoint:
- Does the test hit the real API route or handler?
- Does it use real auth middleware?
- Does it use the real database behavior for the state being asserted?
- Does it verify persisted state, not only status code?
- Does it assert the response contract clients depend on?
- Does it cover authorization boundaries?
- Does it cover validation and stable error shape?
- Does it prove rollback or no-partial-write behavior?
- Does it cover duplicate requests if clients can retry?
- Does it cover concurrency if two requests can compete?
- Are external dependencies stubbed only at the edge?
- Is the test focused enough that a failure is easy to interpret?
This is the difference between "we have integration tests" and "we know this API behavior survives the boundaries production will exercise."
When Not To Add Another Integration Test
Not every behavior needs an API integration test.
Prefer a unit test when:
- the logic is pure calculation
- the behavior does not depend on HTTP, auth, persistence, or transactions
- the same branch is already covered through a higher-value integration test
- the endpoint is low risk and read-only
Prefer an end-to-end test when:
- the browser or mobile client behavior matters
- several services must be deployed together
- the risk is in cross-service orchestration rather than one API boundary
- the test proves a critical user journey rather than a single endpoint
Good test suites are layered. API integration tests should cover the request boundaries where local correctness is not enough. They should not become a slow replacement for every other kind of test.
The Short Version
Good API integration tests prove that the real request path produces the correct durable behavior.
For important endpoints, that means testing more than 200 OK:
- authenticate through the real middleware
- validate real request parsing and error shape
- write to a real test database
- assert durable state
- prove rollback behavior
- protect response contracts
- test duplicate requests and concurrency where they matter
- stub only dependencies outside your system boundary
API integration testing is valuable because production bugs often live between components. The best tests aim directly at those boundaries.