Skip to content

Integration Testing in Node.js

Integration tests verify that multiple pieces, routes, middleware, and (often) a real or test database, work correctly together. This lesson covers testing an Express API's actual HTTP behavior using Supertest.

What Integration Tests Cover

Where unit tests isolate a single function, integration tests exercise a real request going through your actual middleware pipeline, route handlers, and (typically) a real or test database, verifying the pieces work correctly together, not just individually.

Supertest is the standard tool for this in Express apps: it lets you send real HTTP requests directly to your app object in memory, without needing to start an actual network listener, and assert on the response status, headers, and body.

import { test } from 'node:test';
import assert from 'node:assert';
import request from 'supertest';
import app from '../app.js';

test('GET /health returns 200 and status ok', async () => {
  const response = await request(app).get('/health');

  assert.strictEqual(response.status, 200);
  assert.deepStrictEqual(response.body, { status: 'ok' });
});

This is exactly why the earlier Express Basics lesson recommended exporting app separately from listen(), Supertest needs the app object, not a running server.

Common Supertest Assertions

const response = await request(app).post('/users').send({ name: 'Ada' });

response.status         // HTTP status code
response.body            // parsed JSON response body
response.headers          // response headers
  • request(app).get/post/put/delete(path) sends a real HTTP request to your app in memory.
  • .send(data) attaches a JSON body to POST/PUT requests.
  • .set(header, value) sets custom request headers, like an Authorization token.
  • Assertions run against the actual response your app produced, not a mocked shortcut.

Integration Testing Cheatsheet

Common Supertest patterns for testing Express routes.

Task Code
GET request request(app).get('/users')
POST with a body request(app).post('/users').send({...})
Set a header request(app).get('/profile').set('Authorization', 'Bearer ...')
Check status assert.strictEqual(response.status, 201)
Check response body assert.deepStrictEqual(response.body, {...})

Using a Dedicated Test Database

Integration tests that touch a database should run against a dedicated test database, not production or shared development data, so tests remain repeatable and don't corrupt real data or fail due to unrelated data changes.

  • Use a separate DATABASE_URL for the test environment (NODE_ENV=test).
  • Reset or seed the test database before each test run for consistent starting conditions.
  • Consider an in-memory or containerized database for fully isolated CI runs.

Testing Authentication-Protected Routes

Testing a protected route means obtaining valid credentials first, either by logging in through the API itself within the test, or by directly signing a test JWT, then attaching it to subsequent requests.

test('GET /profile requires authentication', async () => {
  const loginRes = await request(app)
    .post('/login')
    .send({ email: 'test@example.com', password: 'password123' });

  const token = loginRes.body.token;

  const profileRes = await request(app)
    .get('/profile')
    .set('Authorization', `Bearer ${token}`);

  assert.strictEqual(profileRes.status, 200);
});

Common Mistakes

  • Running integration tests against a production or shared development database.
  • Not resetting test data between test runs, causing tests to interfere with each other.
  • Forgetting to export the app object separately from listen(), blocking Supertest from working cleanly.
  • Testing only successful requests and skipping error responses (401, 404, 400) in integration tests.

Key Takeaways

  • Integration tests verify how routes, middleware, and (often) a database work together.
  • Supertest sends real HTTP requests to your Express app in memory, without a running network listener.
  • Use a dedicated test database, reset between runs, for reliable and repeatable results.
  • Test both success and failure responses, not just the happy path, for protected and unprotected routes alike.

Pro Tip

Keep a small helper function that logs in a test user and returns a valid token, then reuse it across every integration test that needs authentication. It keeps individual test files focused on what they're actually verifying.