Skip to content

Expert · Testing and API documentation · topic 45 of 54 · 25 min

Integration testing: checking parts work together

Explanation

An integration test checks that several parts work together: the route, the handler, the validation and the database. You send real-looking requests one after another (create, then read, then delete) and check every response. Each test starts with fresh data so tests never depend on each other, and they run automatically in CI on every push.

Unit, integration and end-to-end

Unit tests prove each piece works alone. But bugs also live in the joins: the handler expects "title" while the database column is "name", or the route sends the wrong status code. An integration test checks several real parts together. An end-to-end (E2E) test goes further: it drives the real app the way a user does, often by clicking through a browser with a tool like Playwright.

Think of it like this: Think of a scooter factory. Testing each part alone (brakes, engine, lights) is unit testing. Fitting the engine to the frame and checking that turning the throttle moves the wheel is integration testing. Taking the finished scooter for a ride on a real road is end-to-end testing. You need all three, but you do far more part checks than road rides.
KindWhat it checksSpeedHow many
UnitOne function, no database or networkMillisecondsMany (hundreds)
IntegrationRoutes + handlers + validation + a test databaseTens of milliseconds to secondsSome (dozens)
End-to-endThe whole app through a browser or real HTTPSeconds eachFew (the key user journeys)

This shape is called the testing pyramid: a wide base of fast unit tests, a middle layer of integration tests and a small top of slow end-to-end tests. E2E tests are valuable but slow and sometimes flaky (they fail now and then for no real reason), so keep them for the journeys that matter most, like sign up, login and checkout.

Testing a handler with an in-memory store

A simple way to start: build the app with a function createApp() that holds its own data store, for example a Map in memory. The test creates a fresh app, sends several requests in order and checks each response. Because the store lives inside createApp, every test gets clean, empty data.

javascript
function createApp() {
  const notes = new Map();
  let nextId = 1;
  return {
    handle(req) {
      if (req.method === 'POST' && req.path === '/api/notes') {
        if (!req.body || !req.body.title) return { status: 400, body: { error: 'title is required' } };
        const note = { id: nextId++, title: req.body.title };
        notes.set(note.id, note);
        return { status: 201, body: note };
      }
      return { status: 404, body: { error: 'Not found' } };
    },
  };
}

// A test: fresh app, then requests in sequence
const app = createApp();
const created = app.handle({ method: 'POST', path: '/api/notes', body: { title: 'Buy milk' } });
console.log(created.status, created.body); // 201 { id: 1, title: 'Buy milk' }
The same request-in, response-out shape as a real server, so it is easy to test without starting one.

A real HTTP test with supertest

javascript
import request from 'supertest';
import { describe, it, expect, beforeEach } from 'vitest';
import { buildApp } from '../src/app.js';

let app;
beforeEach(async () => {
  app = await buildApp({ databaseUrl: process.env.TEST_DATABASE_URL });
  await app.db.query('TRUNCATE notes RESTART IDENTITY');
});

it('creates a note and reads it back', async () => {
  const res = await request(app).post('/api/notes').send({ title: 'Call Ravi' });
  expect(res.status).toBe(201);
  const got = await request(app).get('/api/notes/' + res.body.id);
  expect(got.body.title).toBe('Call Ravi');
});
Runs on your computer with Node.js. supertest sends HTTP requests to an Express app without opening a real port.

Which database for tests?

  • SQLite in memory: a whole database that lives in RAM and disappears when the test ends. Very fast, but it is not exactly the same as Postgres, so some queries behave differently.
  • Postgres in Docker: run the same database version as production on your own machine with one docker run command. Slower to start, but it behaves exactly like production.
  • Testcontainers: a library that starts a fresh Docker database from inside your test code and stops it at the end. Good for teams, because nobody has to start the database by hand.
Careful: Never point tests at a production or shared database. Tests delete and reset data on purpose. Use a separate TEST_DATABASE_URL that can only reach a throwaway test database.

Keep tests independent

Each test must work alone and in any order. If test B only passes because test A created a user first, then running B alone fails, and the error is confusing. Give each test fresh data: create a new app, empty the tables in beforeEach (a hook that runs before every test), or wrap each test in a transaction and roll it back at the end.

Run tests in CI

CI means continuous integration: a service that runs your tests automatically on every push or pull request, for example GitHub Actions. If a test fails, the pull request shows a red cross and nobody merges broken code. A typical CI job installs dependencies, starts a test database, runs the tests and reports the result.

Tip: Make your tests fast and reliable before adding more. A suite that takes 30 minutes or fails randomly will be ignored by the team, and then it protects nothing.

Watch the lesson

Integration testing: checking parts work together · 1/6

Integration testing

Unit tests check pieces alone. Integration tests check that the pieces work together.

Prefer a video by a teacher? Find free videos on “integration testing node js api supertest tutorial” on YouTube ↗

In real life: A college results site before results day

A college results site has an API to upload marks and another to fetch a result by roll number. Each part had unit tests, and all passed. But the upload handler saved roll numbers as numbers (so 0042 became 42) while the fetch handler searched for the text "0042". Only an integration test that uploads a result and then fetches it by roll number catches this: the second request returns 404. Running that test in CI on every pull request means the bug is found on a Tuesday afternoon, not on results day.

Practice (0/3 done)

Task 1 · Code · Javascript

createApp() already handles POST /api/notes. Add GET /api/notes/:id: return { status: 200, body: note } when the note exists, and { status: 404, body: { error: "Note not found" } } when it does not. The id in the path is text, so turn it into a number before looking in the Map. The tests create a note and then read it back.

Task 2 · Code · Javascript

This app has two problems. 1) The notes Map is outside createApp, so every app shares the same data and tests leak into each other. Move the store and the id counter inside createApp. 2) DELETE /api/notes/:id is missing: it should delete the note and return { status: 204, body: null }, or return 404 with { error: "Note not found" } if there is no such note. The tests run create, get, delete, get in order, and also check that two apps do not share data.

Task 3 · Practice

Write a GitHub Actions workflow (a .github/workflows/test.yml file) that runs your Node.js tests on every push and pull request, with a Postgres service for the integration tests. Explain each part in one line.

Quick quiz

Question 1

1.What does an integration test check that a unit test does not?

Question 2

2.In the testing pyramid, which kind of test should you have the MOST of?

Question 3

3.Test B passes only when test A runs before it. What is wrong?

Question 4

4.Which database is safe to use for integration tests?

Question 5

5.What does CI do with your tests?

Read more (free)

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

← Previous: Unit testing: checking one piece at a time