Express REST API Integration Testing Strategies
Unit tests verify that your middleware logic is correct in isolation. Integration tests verify that your Express routes work correctly end-to-end: against a real database, with real request parsing, and producing real HTTP responses. This post covers the patterns that separate fragile integration tests from tests that actually protect you in production.
The Integration Test Philosophy
Integration tests are slower and more expensive than unit tests. They're worth the cost because they catch a class of bugs that unit tests physically cannot: schema mismatches, ORM quirks, query builder edge cases, and the interaction between middleware and route handlers. The goal is not to replace unit tests but to verify the contract your API makes with the outside world.
For Express REST APIs, integration tests should:
- Run against a real database (not mocks)
- Reset to a known state before each test
- Cover status codes, response shapes, and headers
- Test the happy path and the failure modes
Database Setup and Teardown
The most robust integration test setup uses a real test database with transaction rollbacks. Here's a pattern using Knex (but the principle applies to any query builder or ORM):
// test/helpers/database.js
const knex = require('../../db/knex');
let trx;
async function beginTransaction() {
trx = await knex.transaction();
return trx;
}
async function rollbackTransaction() {
if (trx) {
await trx.rollback();
trx = null;
}
}
// Override the module's db instance for tests
function getTestDb() {
return trx || knex;
}
module.exports = { beginTransaction, rollbackTransaction, getTestDb };// jest.config.js
module.exports = {
globalSetup: './test/globalSetup.js',
globalTeardown: './test/globalTeardown.js',
setupFilesAfterFramework: ['./test/setupTests.js'],
};// test/globalSetup.js
const knex = require('../db/knex');
module.exports = async () => {
// Run migrations on the test database
await knex.migrate.latest();
};// test/globalTeardown.js
const knex = require('../db/knex');
module.exports = async () => {
await knex.destroy();
};For tests that need isolation without transaction rollbacks (e.g., testing bulk operations that span connections), use a seeding approach instead:
// test/helpers/seed.js
const knex = require('../../db/knex');
async function seedUsers(users) {
await knex('users').insert(users);
return knex('users').whereIn('email', users.map(u => u.email));
}
async function cleanTable(tableName) {
await knex(tableName).del();
}
async function resetDatabase() {
// Order matters — respect foreign key constraints
await knex('order_items').del();
await knex('orders').del();
await knex('products').del();
await knex('users').del();
}
module.exports = { seedUsers, cleanTable, resetDatabase };Fixtures: Building Consistent Test Data
Fixtures are the foundation of readable integration tests. Hardcoded values in tests are a maintenance nightmare — fixtures give you named, reusable data sets:
// test/fixtures/users.js
const { v4: uuidv4 } = require('uuid');
function buildUser(overrides = {}) {
return {
id: uuidv4(),
email: `user-${Date.now()}@example.com`,
name: 'Test User',
role: 'member',
created_at: new Date().toISOString(),
...overrides,
};
}
const fixtures = {
adminUser: () => buildUser({ email: 'admin@example.com', role: 'admin' }),
memberUser: () => buildUser({ email: 'member@example.com', role: 'member' }),
inactiveUser: () => buildUser({ email: 'inactive@example.com', active: false }),
};
module.exports = { buildUser, fixtures };// test/fixtures/products.js
function buildProduct(overrides = {}) {
return {
id: require('uuid').v4(),
name: 'Test Product',
price: 999, // cents
stock: 100,
category: 'electronics',
active: true,
created_at: new Date().toISOString(),
...overrides,
};
}
module.exports = { buildProduct };Testing CRUD Endpoints
With fixtures and database helpers in place, writing integration tests becomes straightforward. Here's a complete test suite for a users resource:
// routes/__tests__/users.integration.test.js
const request = require('supertest');
const { createApp } = require('../../app');
const knex = require('../../db/knex');
const { buildUser, fixtures } = require('../fixtures/users');
describe('Users API', () => {
let app;
beforeAll(() => {
app = createApp();
});
beforeEach(async () => {
await knex('users').del();
});
describe('GET /api/users/:id', () => {
it('returns 200 with user data when user exists', async () => {
const user = buildUser({ name: 'Alice Smith' });
await knex('users').insert(user);
const response = await request(app).get(`/api/users/${user.id}`);
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
id: user.id,
name: 'Alice Smith',
email: user.email,
role: 'member',
});
// Password hash must never be in the response
expect(response.body.password_hash).toBeUndefined();
});
it('returns 404 when user does not exist', async () => {
const response = await request(app).get('/api/users/nonexistent-id');
expect(response.status).toBe(404);
expect(response.body).toMatchObject({
error: expect.stringContaining('not found'),
});
});
});
describe('POST /api/users', () => {
it('creates a user and returns 201 with the created resource', async () => {
const payload = { name: 'Bob Jones', email: 'bob@example.com', password: 'secure123' };
const response = await request(app)
.post('/api/users')
.send(payload)
.set('Content-Type', 'application/json');
expect(response.status).toBe(201);
expect(response.body).toMatchObject({
id: expect.any(String),
name: 'Bob Jones',
email: 'bob@example.com',
});
expect(response.headers.location).toBe(`/api/users/${response.body.id}`);
// Verify the user actually exists in the database
const dbUser = await knex('users').where({ id: response.body.id }).first();
expect(dbUser).toBeDefined();
expect(dbUser.email).toBe('bob@example.com');
});
it('returns 409 when email already exists', async () => {
const existing = buildUser({ email: 'duplicate@example.com' });
await knex('users').insert(existing);
const response = await request(app)
.post('/api/users')
.send({ name: 'Other', email: 'duplicate@example.com', password: 'pass123' });
expect(response.status).toBe(409);
expect(response.body).toMatchObject({ error: expect.stringContaining('email') });
});
it('returns 422 for invalid email format', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'Test', email: 'not-an-email', password: 'pass123' });
expect(response.status).toBe(422);
expect(response.body.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({ field: 'email' })
])
);
});
});
});Testing Pagination
Pagination is one of the most commonly broken features in REST APIs. Tests need to verify page sizes, offsets, total counts, and navigation links:
// routes/__tests__/products.pagination.test.js
const request = require('supertest');
const { createApp } = require('../../app');
const knex = require('../../db/knex');
const { buildProduct } = require('../fixtures/products');
describe('Products API — Pagination', () => {
let app;
beforeAll(async () => {
app = createApp();
await knex('products').del();
// Insert 25 products with predictable names for ordering tests
const products = Array.from({ length: 25 }, (_, i) =>
buildProduct({ name: `Product ${String(i + 1).padStart(2, '0')}`, price: (i + 1) * 100 })
);
await knex('products').insert(products);
});
afterAll(async () => {
await knex('products').del();
});
it('returns first page with default page size', async () => {
const response = await request(app).get('/api/products');
expect(response.status).toBe(200);
expect(response.body.data).toHaveLength(10); // default page size
expect(response.body.pagination).toMatchObject({
total: 25,
page: 1,
per_page: 10,
total_pages: 3,
});
expect(response.body.pagination.next_page).toBe(2);
expect(response.body.pagination.prev_page).toBeNull();
});
it('returns correct items for page 2', async () => {
const page1 = await request(app).get('/api/products?page=1&per_page=10');
const page2 = await request(app).get('/api/products?page=2&per_page=10');
const page1Ids = page1.body.data.map(p => p.id);
const page2Ids = page2.body.data.map(p => p.id);
// No overlap between pages
const overlap = page1Ids.filter(id => page2Ids.includes(id));
expect(overlap).toHaveLength(0);
expect(page2.body.pagination.next_page).toBe(3);
expect(page2.body.pagination.prev_page).toBe(1);
});
it('returns partial last page', async () => {
const response = await request(app).get('/api/products?page=3&per_page=10');
expect(response.status).toBe(200);
expect(response.body.data).toHaveLength(5); // 25 total, 10+10+5
expect(response.body.pagination.next_page).toBeNull();
});
it('returns empty array for page beyond total', async () => {
const response = await request(app).get('/api/products?page=10&per_page=10');
expect(response.status).toBe(200);
expect(response.body.data).toHaveLength(0);
});
it('enforces maximum per_page limit', async () => {
const response = await request(app).get('/api/products?per_page=1000');
expect(response.status).toBe(200);
expect(response.body.data.length).toBeLessThanOrEqual(100); // max allowed
});
});Testing Filtering and Sorting
Filtering and sorting bugs are common because they require correct query building. Test each filter parameter independently, then test combinations:
// routes/__tests__/products.filtering.test.js
describe('Products API — Filtering and Sorting', () => {
beforeAll(async () => {
await knex('products').del();
await knex('products').insert([
buildProduct({ name: 'Laptop', price: 99900, category: 'electronics', active: true }),
buildProduct({ name: 'Phone', price: 59900, category: 'electronics', active: true }),
buildProduct({ name: 'Desk', price: 29900, category: 'furniture', active: true }),
buildProduct({ name: 'Chair', price: 19900, category: 'furniture', active: false }),
buildProduct({ name: 'Keyboard', price: 8900, category: 'electronics', active: true }),
]);
});
describe('filtering', () => {
it('filters by category', async () => {
const response = await request(app)
.get('/api/products?category=electronics');
expect(response.status).toBe(200);
expect(response.body.data).toHaveLength(3);
response.body.data.forEach(p => {
expect(p.category).toBe('electronics');
});
});
it('filters by active status', async () => {
const response = await request(app)
.get('/api/products?active=false');
expect(response.status).toBe(200);
expect(response.body.data).toHaveLength(1);
expect(response.body.data[0].name).toBe('Chair');
});
it('filters by price range', async () => {
const response = await request(app)
.get('/api/products?min_price=20000&max_price=70000');
expect(response.status).toBe(200);
const prices = response.body.data.map(p => p.price);
prices.forEach(price => {
expect(price).toBeGreaterThanOrEqual(20000);
expect(price).toBeLessThanOrEqual(70000);
});
});
it('combines multiple filters with AND logic', async () => {
const response = await request(app)
.get('/api/products?category=electronics&max_price=60000');
expect(response.status).toBe(200);
// Phone (59900) and Keyboard (8900) — not Laptop (99900)
expect(response.body.data).toHaveLength(2);
});
});
describe('sorting', () => {
it('sorts by price ascending', async () => {
const response = await request(app)
.get('/api/products?sort=price&order=asc');
const prices = response.body.data.map(p => p.price);
for (let i = 1; i < prices.length; i++) {
expect(prices[i]).toBeGreaterThanOrEqual(prices[i - 1]);
}
});
it('sorts by name descending', async () => {
const response = await request(app)
.get('/api/products?sort=name&order=desc');
const names = response.body.data.map(p => p.name);
for (let i = 1; i < names.length; i++) {
expect(names[i].localeCompare(names[i - 1])).toBeLessThanOrEqual(0);
}
});
it('rejects invalid sort fields to prevent SQL injection', async () => {
const response = await request(app)
.get('/api/products?sort=; DROP TABLE products;--');
expect(response.status).toBe(422);
});
});
});Testing Response Shape Contracts
Response shapes are part of your API contract. Use toMatchObject for partial matching and custom matchers for complex shapes:
// test/matchers/api.js
expect.extend({
toBeValidPaginatedResponse(received) {
const pass =
Array.isArray(received.data) &&
typeof received.pagination === 'object' &&
typeof received.pagination.total === 'number' &&
typeof received.pagination.page === 'number' &&
typeof received.pagination.per_page === 'number' &&
typeof received.pagination.total_pages === 'number';
return {
pass,
message: () => pass
? `Expected response not to be a valid paginated response`
: `Expected response to be a valid paginated response, got: ${JSON.stringify(received, null, 2)}`,
};
},
toHaveApiError(received, expectedCode) {
const hasError = typeof received.error === 'string' || Array.isArray(received.errors);
const hasCode = !expectedCode || received.code === expectedCode;
return {
pass: hasError && hasCode,
message: () => `Expected response to have API error${expectedCode ? ` with code ${expectedCode}` : ''}`,
};
},
});// In your test files:
require('../matchers/api');
it('returns valid paginated shape', async () => {
const response = await request(app).get('/api/products');
expect(response.body).toBeValidPaginatedResponse();
});Transaction Rollback Pattern for Complete Isolation
For maximum test isolation, wrap each test in a transaction that gets rolled back:
// test/helpers/txWrapper.js
const knex = require('../../db/knex');
function withTransaction(testFn) {
return async () => {
const trx = await knex.transaction();
// Temporarily override the knex instance used by your app
const originalKnex = require('../../db/knex');
jest.mock('../../db/knex', () => trx);
try {
await testFn(trx);
} finally {
await trx.rollback();
jest.unmock('../../db/knex');
}
};
}
module.exports = { withTransaction };Note: The transaction override approach works cleanly when your app uses dependency injection. If your routes import knex directly, you'll need jest.mock at the module level or a connection pool approach instead.
Practical Tips for Sustainable Integration Tests
Keep tests deterministic. Never rely on database auto-increment IDs for ordering — rows returned without ORDER BY can come back in any order.
Clean state, not shared state. Each test should set up exactly the data it needs. Tests that depend on other tests running first are the brittlest tests in any test suite.
Test status codes explicitly. A 200 that should be a 201 is a real bug. A 404 that silently returns 500 is a real bug. Assert the exact code.
Verify side effects in the database. If a POST should create a record, query the database and confirm it exists. If a DELETE should cascade, confirm the child records are gone.
Use toMatchObject not toEqual for response bodies. As your API adds optional fields, toEqual tests will start failing for the wrong reason. toMatchObject asserts the subset you care about.