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.
📋 Table of Contents
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.
📚 You might also like
🔗 Share this article




✍️ Leave a Comment