Expert · Testing and API documentation · topic 45 of 54 · 25 min
Integration testing: checking parts work together
Explanation
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.
| Kind | What it checks | Speed | How many |
|---|---|---|---|
| Unit | One function, no database or network | Milliseconds | Many (hundreds) |
| Integration | Routes + handlers + validation + a test database | Tens of milliseconds to seconds | Some (dozens) |
| End-to-end | The whole app through a browser or real HTTP | Seconds each | Few (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.
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' }A real HTTP test with supertest
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');
});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.
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.
Watch the lesson
Integration testing: checking parts work together · 1/6
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)
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.
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.
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
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.