CodingNic

Integration Testing an API

Unit vs Integration Tests with Supertest

Integration Testing an API 15 min read

Unit vs Integration Tests with Supertest

Objectives

By the end of this lesson, you should be able to:

  • Explain the difference between a unit test and an integration test
  • Send a real HTTP request to an Express app in a test with Supertest
  • Assert on a response’s status code and body together

💡 Why this matters: Module 7’s mocked registerUser test proves the function’s own logic is correct, it says nothing about whether the actual /api/v1/auth/register route, with real Express routing and middleware in front of it, is wired up correctly. This lesson tests that.

⚠️ A note on verification: every snippet and every output in this lesson was actually run.

Unit Tests vs Integration Tests

A unit test (Module 7) checks one function in isolation, mocking away its dependencies, fast, and precise about exactly what broke when it fails. An integration test checks multiple pieces working together, in this case, an Express app’s routing, middleware, and handler logic, all running for real, against a real (in-memory, for testing) request and response. Neither replaces the other: a unit test can pass while the route that calls that function is still wired up wrong, an integration test can pass while a rarely-hit internal branch of a function has a bug a unit test would catch more precisely. Both are used together in a real project.

Setting Up an App to Test

Supertest works directly against an Express app, no server actually needs to be listening on a port:

javascript
// app.js
const express = require('express');
const bcrypt = require('bcryptjs');

const app = express();
app.use(express.json());

const users = [];

app.post('/api/v1/auth/register', async (req, res) => {
  const { email, password } = req.body;
  if (!email || !password) {
    return res.status(400).json({ error: 'ValidationError', message: 'email and password are required' });
  }
  if (users.find(u => u.email === email)) {
    return res.status(409).json({ error: 'DuplicateEmail', message: 'Email already registered' });
  }
  const passwordHash = await bcrypt.hash(password, 10);
  users.push({ id: users.length + 1, email, passwordHash });
  res.status(201).json({ email });
});

module.exports = app;

module.exports = app is what makes this testable, the same app instance a real server.js would call app.listen() on can be imported directly into a test file instead.

Writing an Integration Test

javascript
// app.test.js
const request = require('supertest');
const app = require('./app');

describe('POST /api/v1/auth/register', () => {
  it('creates a new user', async () => {
    const res = await request(app).post('/api/v1/auth/register').send({ email: 'erin@example.com', password: 'sunshine123' });
    expect(res.status).toBe(201);
    expect(res.body).toEqual({ email: 'erin@example.com' });
  });

  it('rejects a duplicate email', async () => {
    await request(app).post('/api/v1/auth/register').send({ email: 'jordan@example.com', password: 'sunshine123' });
    const res = await request(app).post('/api/v1/auth/register').send({ email: 'jordan@example.com', password: 'sunshine123' });
    expect(res.status).toBe(409);
  });

  it('rejects a missing password', async () => {
    const res = await request(app).post('/api/v1/auth/register').send({ email: 'nopass@example.com' });
    expect(res.status).toBe(400);
  });
});

request(app).post(path).send(body) builds and sends a real HTTP request in-process, no port, no real network involved, res.status and res.body are the actual response Express sent back, real routing, real middleware (express.json() parsing the body), real handler logic, all exercised together.

Running the Tests

text
PASS integrationtest/app.test.js
  POST /api/v1/auth/register
    ✓ creates a new user (107 ms)
    ✓ rejects a duplicate email (78 ms)
    ✓ rejects a missing password (3 ms)

Three tests, the success path and two distinct, correctly rejected failure paths, this is exactly the manual verification pattern used throughout this course, request(app).post(...).send(...), now captured as a permanent, automatically-rerunnable test instead of a one-off script.

Try It

  1. Build app.js and app.test.js above, and confirm all three tests pass.
  2. Add a fourth test confirming a third registration with a different new email succeeds with 201, after the duplicate-email test has already run.
  3. Explain, in one or two sentences, what an integration test for this route can catch that a unit test of an isolated registerUser(data, db) function (Module 7) wouldn’t.
  4. Explain, in one or two sentences, a case where a unit test would catch a bug an integration test might miss.

Recap

  • Unit tests check one function in isolation, integration tests check real routing, middleware, and handlers working together, both matter, neither replaces the other.
  • Supertest sends real HTTP requests directly to an exported Express app, no server needs to be listening on a real port.
  • res.status and res.body are the actual response Express produced, assertions on them confirm the whole request pipeline behaves correctly, not just one function.

Next lesson: testing an authenticated route, logging in within a test, then using the result against a protected endpoint.