We use cookies to enhance your experience on the site
CodeWorlds

Integration Testing - checking that the legion works together

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.

The testing pyramid - how much of what to write

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:

  • Unit - the base. The most numerous and fastest, because they touch nothing beyond one class.
  • Integration - the middle layer. Fewer and slower, because they run several elements at once.
  • E2E - the peak. The fewest and slowest; they travel a request's whole road.
  • Manual tests - occasional, where automation cannot reach.

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).

Unit versus integration - one difference

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.

Assembling a module from real parts

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.

Testing through real HTTP

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.

Cleaning up between tests

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.

Summary

The machine is assembled and fired - for real this time:

  • an integration test checks several components working together, where a unit test examines one in isolation,
  • doubles do not disappear - only the boundary you place them at moves,
  • the testing pyramid: most unit tests, fewer integration, fewest E2E, occasional manual,
  • the order from fastest:
    test
    ,
    test:watch
    ,
    test:cov
    ,
    test:e2e
    ,
  • we assemble the module from real parts with
    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,
  • the chain
    .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.

Go to CodeWorlds