Integration tests verify that your API’s pieces work together correctly — routes, database, and business logic — by making real requests and checking real responses. Unlike unit tests (which mock everything), integration tests catch bugs at the boundaries. This guide shows how to write them well.
Unit vs Integration Tests
| Aspect | Unit Test | Integration Test |
|---|---|---|
| Scope | One function in isolation | Multiple parts together (route + DB) |
| Dependencies | Mocked | Real (or test) database |
| Speed | Very fast | Slower (real I/O) |
| Catches | Logic bugs | Integration/wiring bugs |
You want both — unit tests for logic, integration tests to verify the whole request-response flow works.
Node.js: Testing with Supertest
npm install --save-dev jest supertest
// Export your app WITHOUT calling listen() so tests can use it
// app.js
const express = require('express');
const app = express();
app.use(express.json());
app.get('/users/:id', getUser);
app.post('/users', createUser);
module.exports = app; // export the app
// server.js (separate) starts it
const app = require('./app');
app.listen(3000);
// users.test.js
const request = require('supertest');
const app = require('./app');
const db = require('./db');
describe('Users API', () => {
beforeEach(async () => {
await db.reset(); // clean database before each test
});
afterAll(async () => {
await db.close();
});
it('creates a user', async () => {
const res = await request(app)
.post('/users')
.send({ name: 'Alice', email: 'alice@example.com' });
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ name: 'Alice' });
expect(res.body.id).toBeDefined();
});
it('returns 404 for missing user', async () => {
const res = await request(app).get('/users/999');
expect(res.status).toBe(404);
});
it('validates required fields', async () => {
const res = await request(app).post('/users').send({});
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
});
Python: Testing with pytest and TestClient
pip install pytest httpx
# test_users.py (FastAPI example)
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.database import get_test_db
client = TestClient(app)
@pytest.fixture(autouse=True)
def reset_db():
db = get_test_db()
db.reset()
yield
db.close()
def test_create_user():
response = client.post("/users", json={
"name": "Alice", "email": "alice@example.com"
})
assert response.status_code == 201
data = response.json()
assert data["name"] == "Alice"
assert "id" in data
def test_get_missing_user():
response = client.get("/users/999")
assert response.status_code == 404
def test_validation_error():
response = client.post("/users", json={})
assert response.status_code == 422
Using a Test Database
Integration tests need a real (test) database — separate from development and production:
# Spin up a test database with Docker
docker run -d --name test-db -p 5433:5432 \
-e POSTGRES_DB=testdb -e POSTGRES_PASSWORD=test \
postgres:15-alpine
# Point tests at it via environment
DATABASE_URL=postgresql://postgres:test@localhost:5433/testdb
// Fast reset using transactions (rollback after each test)
beforeEach(async () => {
await db.query('BEGIN');
});
afterEach(async () => {
await db.query('ROLLBACK'); // undo all changes — fast and clean
});
Testing Authenticated Endpoints
describe('Protected routes', () => {
let token;
beforeAll(async () => {
const res = await request(app)
.post('/login')
.send({ email: 'test@example.com', password: 'password' });
token = res.body.accessToken;
});
it('allows access with valid token', async () => {
const res = await request(app)
.get('/protected')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
});
it('rejects without token', async () => {
const res = await request(app).get('/protected');
expect(res.status).toBe(401);
});
});
Running Integration Tests in CI
# GitHub Actions with a service database
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: testdb
POSTGRES_PASSWORD: test
ports: ['5432:5432']
options: --health-cmd pg_isready --health-interval 10s
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm run test:integration
env:
DATABASE_URL: postgresql://postgres:test@localhost:5432/testdb
Best Practices
- Isolate tests: Each test should be independent — reset the database between tests
- Test the contract: Verify status codes, response shape, and error cases
- Cover error paths: Test 400s, 401s, 404s, and validation failures, not just the happy path
- Use a real test database: Don’t mock the database in integration tests
- Keep them reasonably fast: Transaction rollback is faster than truncating tables
- Run in CI: Integration tests catch wiring bugs that unit tests miss
Frequently Asked Questions
Q: Should I mock the database in integration tests?
A: No — the point is verifying real interactions, including the database. Use a separate test database. Mocking it turns it into a unit test and misses the integration bugs you’re trying to catch.
Q: How do I keep integration tests fast?
A: Use transaction rollback between tests instead of truncating/recreating tables. Run tests in parallel where isolation allows. Use a containerized database and keep it small.
Q: How many integration tests do I need?
A: Cover each endpoint’s critical paths — success, validation errors, auth failures, and not-found cases. Unit tests handle logic details; integration tests cover the main behaviors of each endpoint.
Q: Integration tests or E2E tests?
A: Integration tests verify the API layer (routes + database). E2E tests verify full user flows through the UI. Both have value — have more integration tests than E2E since they’re faster and more focused.
Q: How do I test external API calls?
A: Mock those specific calls (you don’t want tests hitting third-party services) while keeping your database real. Mock only true external dependencies, not your own database.
Conclusion
Integration tests verify your API works end to end — routes, database, and logic together — catching wiring bugs that unit tests miss. Use Supertest with Jest (Node.js) or pytest with TestClient (Python), point them at a real test database (reset between tests via transaction rollback for speed), and cover success cases, validation errors, auth, and not-found paths. Run them in CI with a service database. Combined with unit tests for logic, integration tests give you confidence your API actually works as clients expect.
✍️ Leave a Comment