Every part of the catapult passed inspection. The rope holds, the mechanism releases, the counterweight weighs exactly what it should. You assemble the machine - and the projectile flies three paces. Why? Because the rope turned out to be a metre too short for that arm. Every part was sound on its own, but nobody checked whether they fit together.
That is the gap a unit test cannot see by design - in it we replaced every neighbouring part with a double. An integration test fills the gap: it assembles several real elements and checks whether they talk to each other the way we assumed.
Before we write such a test, let's place it among the others. Tests form a pyramid - the most numerous at the bottom, the rarest at the top:
The pyramid's shape is not arbitrary: the higher you go, the more confidence a test gives, but the more it costs in time and the more readily it breaks for trivial reasons. An inverted pyramid - a handful of unit tests and hundreds of E2E ones - gives you a suite that runs for a quarter of an hour and fails whenever a button's label changes.
The same order governs running them:
npm run test (unit, seconds) → test:watch (the same, looping while you write) → test:cov (unit plus coverage counting) → test:e2e (the slowest, last).The distinction comes down to a single question: how many real elements take part in the test?
A unit test examines one component and replaces all its dependencies with doubles. An integration test examines several working together - a controller with its service, a service with its repository.
That does not mean doubles disappear in integration tests. You still swap out whatever lies beyond the boundary of the slice under test: payments, email delivery, somebody else's API. You are only moving the boundary - from one class to several cooperating ones.
An integration test builds a module much like a unit test, but instead of listing individual providers it imports the whole application module:
1const module = await Test.createTestingModule({
2 imports: [
3 TypeOrmModule.forRoot({
4 type: 'sqlite',
5 database: ':memory:',
6 entities: [Legion],
7 synchronize: true,
8 }),
9 LegionsModule,
10 ],
11}).compile();LegionsModule comes in whole - with its controller, service and repository, exactly as in the real application. The new part is the database configuration: type: 'sqlite' with database: ':memory:' creates a database existing only in the process's memory. It appears when the test starts and vanishes when it ends, so you need no server at all, and every run begins with an empty table.synchronize: true tells TypeORM to build the tables straight from the entities. In a production application that option is forbidden - migrations rule there - but in a database that lives three seconds it is exactly what you want.Since the module contains a controller, we can knock on it the way a client would - with an HTTP request:
1describe('LegionsController (integration)', () => {
2 let app: INestApplication;
3
4 beforeAll(async () => {
5 const module = await Test.createTestingModule({
6 imports: [/* ... */],
7 }).compile();
8
9 app = module.createNestApplication();
10 await app.init();
11 });
12
13 afterAll(async () => {
14 await app.close();
15 });
16
17 it('POST /legions saves a legion and returns it with an id', async () => {
18 const response = await request(app.getHttpServer())
19 .post('/legions')
20 .send({ name: 'Legio X' })
21 .expect(201);
22
23 expect(response.body.id).toBeDefined();
24 expect(response.body.name).toBe('Legio X');
25 });
26});Three new elements are worth naming.
module.createNestApplication() turns the compiled module into a running application - one able to handle a request. app.init() starts it; without that call the server will accept nothing.Then
request(app.getHttpServer()) - this is the supertest library. getHttpServer() pulls the HTTP server out of the application, and request(...) sends it a real request without opening a port. The traffic happens in memory, so the test occupies no network and will not collide with another process.The chain reads like a description of the request:
.post('/legions') picks the method and address, .send({...}) adds the body, .expect(201) checks the response code. The returned response.body we then examine with ordinary assertions.And here you see what this test gives beyond a unit one: the projectile travelled the whole road - through routing, body validation, the service, the repository, into the database and back. Had the controller expected a
title field while the service saved name, unit tests of both classes would pass without blinking; this one fails.A real database, even one in memory, remembers everything written to it - including from the previous test:
1afterEach(async () => {
2 await app.get(getRepositoryToken(Legion)).clear();
3});clear() empties the table after each test. Without it, a test counting legions will also see those created earlier and will start depending on execution order - exactly the trap beforeEach defused in unit tests.Note the choice of hooks: we build the application in
beforeAll (once, because it is expensive) and clear the data in afterEach (every time, because it is cheap). Closing with app.close() in afterAll is mandatory - otherwise Jest hangs with an open database connection.The machine is assembled and fired - for real this time:
test, test:watch, test:cov, test:e2e,imports: [Module], replacing the database with sqlite :memory: and synchronize: true,createNestApplication() turns a module into a running application, app.init() starts it,request(app.getHttpServer()) from the supertest library sends a real request without opening a port,.post().send().expect(201) describes the request, and you examine response.body with ordinary assertions,clear() in afterEach empties the table, app.close() in afterAll closes the application - without it Jest hangs.In the next lesson we will climb to the pyramid's very peak - to E2E tests, which travel a user's full road. For now remember: a unit test says every part works; an integration test says they fit together.