Course:Node.js & Express/
Lesson

You have written unit tests for pure functions. Now it is time to test something more interesting, your actual HTTPWhat is http?The protocol browsers and servers use to exchange web pages, API data, and other resources, defining how requests and responses are formatted. routes. These are integration tests: they exercise your middlewareWhat is middleware?A function that runs between receiving a request and sending a response. It can check authentication, log data, or modify the request before your main code sees it., routing logic, input validation, and response formatting all at once. They are the closest you can get to a real request without opening a browser.

Supertest basics

Supertest is the standard libraryWhat is standard library?A collection of ready-made tools that come built into a language - no install required. Covers common tasks like reading files or making web requests. for testing Express apps. It wraps your Express app in a temporary server, sends a real HTTPWhat is http?The protocol browsers and servers use to exchange web pages, API data, and other resources, defining how requests and responses are formatted. request through it, and gives you the response to assert against. No port conflicts, no manual listen calls.

Install it:

npm install --save-dev supertest

The key insight is that you pass your app object, not the result of app.listen(), to Supertest:

// app.js - export the app WITHOUT calling listen()
import express from 'express';
const app = express();
app.use(express.json());
// ... routes
export default app;

// server.js - this is the entry point that starts the real server
import app from './app.js';
app.listen(3000, () => console.log('Server running'));
// tests/routes/users.test.js
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import app from '../../app.js';

describe('GET /api/users', () => {
  it('returns an array of users', async () => {
    const res = await request(app)
      .get('/api/users')
      .expect(200)
      .expect('Content-Type', /json/);

    expect(res.body.data).toBeInstanceOf(Array);
  });
});
The .expect(200) call on the Supertest chain asserts the status code and throws if it does not match. This is slightly different from Vitest's expect(), it is Supertest's built-in assertion, and it runs before your code even reaches the const res = line.
02

Testing CRUDWhat is crud?Create, Read, Update, Delete - the four basic operations almost every application performs on data. routes

A typical RESTWhat is rest?An architectural style for web APIs where URLs represent resources (nouns) and HTTP methods (GET, POST, PUT, DELETE) represent actions on those resources. resource has five routes: list, show, create, update, delete. Here is how you test each one systematically.

describe('User routes', () => {
  let createdUserId;

  describe('GET /api/users', () => {
    it('returns 200 with an array', async () => {
      const res = await request(app).get('/api/users').expect(200);
      expect(Array.isArray(res.body.data)).toBe(true);
    });
  });

  describe('POST /api/users', () => {
    it('creates a user and returns 201', async () => {
      const res = await request(app)
        .post('/api/users')
        .send({ name: 'Alice', email: 'alice@example.com' })
        .expect(201);

      expect(res.body.data.id).toBeDefined();
      expect(res.body.data.name).toBe('Alice');
      createdUserId = res.body.data.id;
    });

    it('returns 400 when name is missing', async () => {
      const res = await request(app)
        .post('/api/users')
        .send({ email: 'no-name@example.com' })
        .expect(400);

      expect(res.body.error).toMatch(/name/i);
    });
  });

  describe('GET /api/users/:id', () => {
    it('returns the user', async () => {
      const res = await request(app)
        .get(`/api/users/${createdUserId}`)
        .expect(200);

      expect(res.body.data.id).toBe(createdUserId);
    });

    it('returns 404 for unknown id', async () => {
      await request(app).get('/api/users/99999').expect(404);
    });
  });
});

Setting request headers

Many routes require authenticationWhat is authentication?Verifying who a user is, typically through credentials like a password or token.. You can chain .set() to add headers:

it('requires authentication', async () => {
  await request(app).get('/api/profile').expect(401);
});

it('returns profile when authenticated', async () => {
  const res = await request(app)
    .get('/api/profile')
    .set('Authorization', 'Bearer test-token-here')
    .expect(200);

  expect(res.body.data.email).toBeDefined();
});

For cookieWhat is cookie?A small piece of data the browser stores and automatically sends with every request to the matching server, often used for sessions.-based auth, use .set('Cookie', 'session=abc123').

03

Testing error responses

Error handling is where most route tests are missing. Every route that can fail should have tests for those failures. Think about what can go wrong: missing fields, invalid types, duplicate records, unauthorized access.

describe('POST /api/users error cases', () => {
  it('returns 409 when email already exists', async () => {
    // Create user first
    await request(app)
      .post('/api/users')
      .send({ name: 'Bob', email: 'bob@example.com' });

    // Try to create again with same email
    const res = await request(app)
      .post('/api/users')
      .send({ name: 'Bob Again', email: 'bob@example.com' })
      .expect(409);

    expect(res.body.error).toMatch(/already exists/i);
  });

  it('returns 400 for invalid email format', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ name: 'Charlie', email: 'not-an-email' })
      .expect(400);

    expect(res.body.error).toMatch(/email/i);
  });
});
When you test error cases, assert on the error message too, not just the status code. A 400 that says "Something went wrong" is far less useful to an API consumer than one that says "email is required".
04

Test data management

Tests that talk to a real database need to set up and tear down data carefully. A common pattern is to seed a known state before the suite runs, then clean up afterwards:

import { db } from '../../database/config.js';

describe('Users API', () => {
  beforeAll(async () => {
    // Insert test data
    await db('users').insert([
      { id: 1, name: 'Alice', email: 'alice@test.com' },
      { id: 2, name: 'Bob', email: 'bob@test.com' }
    ]);
  });

  afterAll(async () => {
    // Remove test data
    await db('users').whereIn('id', [1, 2]).delete();
  });

  it('lists all users', async () => {
    const res = await request(app).get('/api/users').expect(200);
    expect(res.body.data.length).toBeGreaterThanOrEqual(2);
  });
});
05

Quick reference

Supertest methodWhat it does
.get(url)Send a GET request
.post(url).send(body)Send a POST with a JSON body
.put(url).send(body)Send a PUT request
.delete(url)Send a DELETE request
.set(header, value)Add a request header
.expect(statusCode)Assert on status code (chainable)
.expect('Content-Type', /json/)Assert on response header
res.bodyParsed JSON response body
res.headersResponse headers object
javascript
// Example: Testing with Supertest
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import app from '../app';

describe('API Routes', () => {
  it('GET /api/users returns list', async () => {
    const res = await request(app)
      .get('/api/users')
      .expect(200)
      .expect('Content-Type', /json/);

    expect(res.body.data).toBeInstanceOf(Array);
  });

  it('POST /api/users creates user', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ name: 'John', email: 'john@example.com' })
      .expect(201);

    expect(res.body.data.name).toBe('John');
  });
});