The tests from the last two lessons are green: the domain calculates correctly, the permission matrix is right cell by cell, the currency converter degrades gracefully. And even so, the Escena Viva API could be completely broken.

Consider this scenario, which really happens. Somebody reorders the middleware in createApplication and places the orders route before express.json(). Result: req.body arrives as undefined, the zod schema rejects everything and every purchase returns 400. How many unit tests catch that? None. The controller works perfectly when you hand it a hand-built req, the schema validates fine what it is given, the repository saves correctly. Every piece is fine; the wiring is wrong.

That is what integration tests test: that the pieces fit together when they really run side by side.

Contents

  1. What an integration test integrates
  2. supertest and the Module 6 design
  3. The first end-to-end test
  4. The database in tests
  5. Isolation between tests
  6. Authenticating in tests
  7. Testing the paths that matter
  8. Testing concurrency against overselling
  9. The error format contract and external services

What an integration test integrates

An integration test runs several real layers together. A request to POST /api/orders travels through this:

graph LR
    P["HTTP request"] --> M1["helmet · cors · json<br/>request-id · logging"]
    M1 --> M2["purchaseLimit"] --> M3["authenticate"] --> M4["validate (zod)"]
    M4 --> C["orders controller"] --> R["repository + transaction"]
    R --> BD[("escena_viva")]
    C --> E["errorHandler"] --> RES["JSON response"]

Every arrow is a failure point that is invisible to a unit test: middleware in the wrong order or not mounted at all; a route with the wrong verb or prefix; errorHandler placed before the routes and therefore never reached; a zod schema validating a field the controller expects under another name; the repository returning a Mongoose document where a domain object is expected; the Module 7 transaction that in practice does not isolate what we think it does; and the real HTTP status of every error in the hierarchy.

supertest and the Module 6 design

npm install --save-dev supertest

What supertest does, precisely: it takes your Express application, starts it on an ephemeral port assigned by the operating system, makes a real HTTP request against that port and closes the server when it finishes. It is a genuine request, not a simulation: it goes through every middleware, the router, the body parsing and the error handler. And because the port is ephemeral, you will never collide with an EADDRINUSE even if your development server is on 3000.

This is where a Module 6 decision that may have looked like a whim pays off: src/app.js exports a factory that never calls listen, and it is src/server.js that connects the database, listens and manages the graceful shutdown. Thanks to that separation, the test is one line:

const app = createApplication({ serveStaticFiles: false, logging: false, rateLimiting: false });
await request(app).get('/api/events').expect(200);

If app.js called listen on load — the most common mistake in Express projects — every test file would open a server on port 3000, the second one would fail and the process would never finish. That is the real reason for the factory.

Option Value in tests Why
serveStaticFiles / logging false Static files hit the disk on every request, and morgan clutters Mocha's output
rateLimiting false Critical: with express-rate-limit active, the concurrency test would get 429s instead of testing capacity

The last one comes with a caveat: turning the limits off is fine, but then you have to test them in a separate file with rateLimiting: true, or nobody will ever check that they work.

The first end-to-end test

// test/integration/events.test.js
'use strict';

const request = require('supertest');
const { expect } = require('chai');
const { createApplication } = require('../../src/app.js');

describe('GET /api/events', () => {
  const app = createApplication({ serveStaticFiles: false, logging: false, rateLimiting: false });

  it('returns 200 with the catalog as JSON', async () => {
    const r = await request(app).get('/api/events')
      .expect(200).expect('Content-Type', /application\/json/);
    expect(r.body.data).to.be.an('array').with.lengthOf(3);
  });
  it('includes title, venue and sessions on every event', async () => {
    const { body } = await request(app).get('/api/events').expect(200);
    const concert = body.data.find((e) => e.id === 'evt-001');
    expect(concert).to.deep.include({ title: 'Concierto de Otono', venueId: 'org-almendra' });
    expect(concert.sessions).to.have.lengthOf(2);
  });

  it('does not expose the model internal fields', async () => {
    const { body } = await request(app).get('/api/events').expect(200);
    expect(body.data[0]).to.not.have.any.keys('_id', '__v');
  });
});

The third one is among the most profitable tests there are: it checks that the serialization does not leak Mongoose's internal fields. It is a bug that shows up the moment somebody returns the raw document instead of the domain object, and one nobody looks at until a client asks what __v is.

The repertoire chains together: .post(path), .set(header, value), .send(jsonBody), .query({ currency: 'GBP' }), .expect(status), .expect('Content-Type', /json/) and .expect((res) => ...) for a custom assertion about the response.

A practical tip: use .expect() for the status and the headers, and Chai for the body, because Chai's failure messages about objects are far more informative. And if you write .expect(200) and the server returns 500, supertest prints the statuses but not the error body: while debugging, store the response and print response.body.

The database in tests

This is the most important design decision in the lesson.

Option Advantages Drawbacks
A dedicated real database (escena_viva_test) Total fidelity: indexes, transactions, real types Requires the service installed; has to be cleaned; slower
mongodb-memory-server Nothing to install; isolated; fast Downloads binaries; may diverge from the production version
In-memory SQLite (the Sequelize side) Instant, zero configuration A different dialect from PostgreSQL: it tests neither locks nor real types
An ephemeral container (Testcontainers) Maximum fidelity and isolation Needs Docker; slow to start
An in-memory fake repository Blazing fast It does not test the integration, which is the whole point

Our choice: a dedicated real database, escena_viva_test. The star test of this lesson is the concurrency test against overselling, which depends on the engine's real transactional behavior: with SQLite or with a fake repository it would always pass without proving anything. Besides, we already have Module 7's idempotent seed, src/config/index.js is the only place that reads process.env — switching databases means changing one variable — and in CI a service is brought up in two lines.

We create .env.test with NODE_ENV=test, MONGODB_URI=mongodb://localhost:27017/escena_viva_test, DATABASE_URL=postgres://escena:escena@localhost:5432/escena_viva_test, a test JWT_SECRET, BCRYPT_COST=4, LOG_LEVEL=silent and TZ=UTC. And we load it before anything else from Mocha's bootstrap file:

// test/helpers/setup.js
'use strict';

const path = require('node:path');
const sinon = require('sinon');

// Loads .env.test BEFORE src/config/index.js reads process.env
require('dotenv').config({ path: path.join(__dirname, '..', '..', '.env.test') });
const { connect, disconnect } = require('../../src/db/connection.js');

exports.mochaHooks = {
  async beforeAll() { await connect(); },
  afterEach() { sinon.restore(); },
  async afterAll() { await disconnect(); },   // without this the process never ends
};

Order matters enormously. src/config/index.js reads process.env when it loads and freezes the result; if dotenv runs after the first require of a module that imports the configuration, your test will happily connect to the development database and wipe it. Loading it from the require in .mocharc.json, before anything else, is the guarantee. And as a cheap safeguard, a checkTestDatabase() function that throws if configuration.mongodbUrl does not include _test, called before any deletion.

Isolation between tests

Tests must not depend on the order in which they run. If test B only passes after A, you have a time bomb: the day somebody adds .only, runs a single file or turns on parallelism, B will fail for no apparent reason.

// test/helpers/database.js
async function clean() {
  checkTestDatabase();
  const collections = await mongoose.connection.db.collections();
  await Promise.all(collections.map((c) => c.deleteMany({})));  // without dropping indexes
}
async function seedTestData() {
  await clean();
  await seed({ silent: true });  // 7 sessions, capacity 3000, 1811 sold
}

You use them in each file as beforeEach(seedTestData) and afterEach(clean). Why beforeEach and not before: because the first test that buys tickets will change the capacity and the second one would expect a different number. Seeding for each test costs a few milliseconds and buys determinism.

Sharing state between tests is the number one source of flaky tests, and it shows up in three shapes: a module object mutated by one test and read by another, data in the database that outlives the test that created it, and an in-memory cache (like the one in currency-exchange.js) that never gets cleared.

Authenticating in tests

Here is the Module 8 promise: nearly every interesting route requires an authenticated user. How does a test get hold of a valid token?

The bad idea is logging in from the test with credentials written into the code: it puts credentials in the repository (a habit that ends with a real password in a commit), it is slow because every login runs bcrypt on purpose, it couples the whole suite to the login endpoint — if that breaks, fifty tests fail instead of one — and it is brittle in the face of any change to the password policy.

The good idea is a helper that creates the user the test needs and signs a token with the same service the application uses:

// test/helpers/authentication.js
'use strict';

const { User } = require('../../src/models/user.js');
const { signAccessToken } = require('../../src/services/tokens.js');

let counter = 0;

/** Creates a user with the requested role and returns a valid access token. */
async function createAuthenticatedUser({ role = 'attendee', venueId = null } = {}) {
  counter += 1;
  const user = await User.create({
    name: `Test user ${counter}`,
    email: `test-${role}-${counter}@escenaviva.test`,
    passwordHash: '$2b$12$000000000000000uGf3nFEQhZ1lPCpNvBrjLzcVX2CqOMy',
    role, venueId, verified: true,   // the hash is irrelevant: we never log in with it
  });
  const token = signAccessToken({ id: user.id, role: user.role, venueId: user.venueId });
  return { user, token, header: ['Authorization', `Bearer ${token}`] };
}

const asAttendee = () => createAuthenticatedUser({ role: 'attendee' });
const asOrganizer = (v = 'org-almendra') => createAuthenticatedUser({ role: 'organizer', venueId: v });
const asAdministrator = () => createAuthenticatedUser({ role: 'administrator' });

module.exports = { asAttendee, asOrganizer, asAdministrator };

You use it by spreading the array into .set(...): const lucia = await asAttendee() and then request(app).post('/api/orders').set(...lucia.header), or asOrganizer('org-almendra') to edit an event at Teatro Almendra. You will see dozens of examples in the rest of the lesson.

Why this is legitimate and not cheating: we use the very same signAccessToken production uses, so the token goes through exactly the same verification as any real token — HS256 signature with the same secret, expiry, payload format. We are not skipping authentication; we are skipping the login, which is a different operation and gets tested separately, once, in test/integration/authentication.test.js: valid credentials, a 401 with the same latency when the email does not exist (the bcrypt decoy), a 401 with the wrong password, and never revealing which of the two failed.

Testing the paths that matter

The complete purchase flow

describe('ticket purchase flow', () => {
  beforeEach(async () => { await seedTestData(); });
  afterEach(async () => { await clean(); });

  it('creates the order, returns 201 with Location and lets you fetch it back', async () => {
    const lucia = await asAttendee();
    const creation = await request(app).post('/api/orders').set(...lucia.header)
      .send({ sessionId: 'ses-001-1', quantity: 2 })
      .expect(201).expect('Content-Type', /json/);

    expect(creation.body.totalCents).to.equal(5000);
    expect(creation.body.status).to.equal('pending');
    expect(creation.body.tickets).to.have.lengthOf(2);
    expect(creation.body.tickets[0].code).to.match(/^EV-\d{4}-\d{6}$/);
    expect(creation.headers.location).to.equal(`/api/orders/${creation.body.id}`);

    // The created resource can be fetched at the advertised location
    const lookup = await request(app).get(creation.headers.location)
      .set(...lucia.header).expect(200);
    expect(lookup.body.id).to.equal(creation.body.id);
  });

  it('subtracts the purchase from the session capacity', async () => {
    const lucia = await asAttendee();
    const beforePurchase = await request(app).get('/api/sessions/ses-001-1').expect(200);
    await request(app).post('/api/orders').set(...lucia.header)
      .send({ sessionId: 'ses-001-1', quantity: 3 }).expect(201);
    const afterPurchase = await request(app).get('/api/sessions/ses-001-1').expect(200);
    expect(afterPurchase.body.available).to.equal(beforePurchase.body.available - 3);
  });
});

The second test ties the HTTP world to the data world: it checks that the purchase had a real, persistent effect, not just that it returned 201.

The rejections

Every error status in the Module 6 hierarchy deserves its own test:

it('returns 401 when no token is sent', async () => {
  const { body } = await request(app).post('/api/orders')
    .send({ sessionId: 'ses-001-1', quantity: 2 }).expect(401);
  expect(body.error.code).to.equal('NOT_AUTHENTICATED');
});
it('returns 403 when an organizer edits an event from another venue', async () => {
  const bruno = await asOrganizer('org-boveda');
  await request(app).patch('/api/events/evt-001')   // belongs to org-almendra
    .set(...bruno.header).send({ description: 'Wrong venue' }).expect(403);
});
it('returns 404 when the session does not exist', async () => {
  const lucia = await asAttendee();
  const { body } = await request(app).post('/api/orders').set(...lucia.header)
    .send({ sessionId: 'ses-999-9', quantity: 2 }).expect(404);
  expect(body.error.code).to.equal('SESSION_NOT_FOUND');
});
it('returns 409 when the capacity is insufficient', async () => {
  const lucia = await asAttendee();
  await adjustCapacity('ses-001-1', { available: 1 });
  const { body } = await request(app).post('/api/orders').set(...lucia.header)
    .send({ sessionId: 'ses-001-1', quantity: 4 }).expect(409);
  expect(body.error.details).to.deep.include({ available: 1, requested: 4 });   // the why
});
it('returns 422 when more than 6 tickets are requested', async () => {
  const lucia = await asAttendee();
  await request(app).post('/api/orders').set(...lucia.header)
    .send({ sessionId: 'ses-001-1', quantity: 7 }).expect(422);
});

Three obvious siblings are missing, cast from the same mold: the 400 with a body that does not satisfy the schema ({ quantity: 'two' }, with no sessionId, whose error.details must be an array of zod failures), the 401 with a tampered token (Bearer not.a.token) and the 403 of an attendee trying to edit an event.

The insecure direct reference

Of all the tests in the module, this is the one worth most per line written. It is the most common security bug in REST APIs: an identifier in the URL that the server serves without checking whose it is.

it('stops an attendee from viewing another attendee order', async () => {
  const lucia = await asAttendee();
  const marc = await asAttendee();
  const luciasOrder = await request(app).post('/api/orders').set(...lucia.header)
    .send({ sessionId: 'ses-001-1', quantity: 2 }).expect(201);

  // Marc knows the id of Lucia's order and asks for it directly
  const { body } = await request(app).get(`/api/orders/${luciasOrder.body.id}`)
    .set(...marc.header)
    .expect(404);   // 404, not 403: we do not confirm that the resource exists
  expect(body.error.code).to.equal('ORDER_NOT_FOUND');
});

The 404 instead of a 403 is deliberate, and we decided it back in Module 8: answering 403 would confirm to Marc that the order exists, information that is none of his business. This test pins that security decision so that nobody undoes it thinking they are improving the error messages. Its counterpart is the test that an administrator can view any attendee's order.

Testing concurrency against overselling

This test validates all the transactional work from Module 7, and there is no way to write it other than as an integration test.

it('does not oversell when simultaneous purchases race for the last tickets', async () => {
  await adjustCapacity('ses-001-1', { available: 5 });
  // Ten different attendees ask for 2 tickets each: 20 requested, 5 available
  const buyers = await Promise.all(Array.from({ length: 10 }, () => asAttendee()));

  const results = await Promise.all(buyers.map((b) =>
    request(app).post('/api/orders').set(...b.header)
      .send({ sessionId: 'ses-001-1', quantity: 2 })));
  const successes = results.filter((r) => r.status === 201);
  const conflicts = results.filter((r) => r.status === 409);
  expect(successes).to.have.lengthOf(2);   // only 2 purchases of 2 fit into 5 seats
  expect(conflicts).to.have.lengthOf(8);
  conflicts.forEach((r) => expect(r.body.error.code).to.equal('INSUFFICIENT_CAPACITY'));

  const session = await request(app).get('/api/sessions/ses-001-1').expect(200);
  expect(session.body.available).to.equal(1);            // the final state is consistent
  expect(session.body.sold).to.be.at.most(session.body.capacity);
});

The decisive assertion is the last one: sold never exceeds capacity. If Module 7 had implemented the purchase with a read → check → write without a transaction or a conditional update, this test would expose the race condition immediately: all ten purchases would read "5 available", all ten would pass the check and we would sell 20 tickets for 5 seats.

Two honest warnings: the test is non-deterministic in its detail — the exact number of successes can vary if the logic allows partial purchases, so assert the invariant and not a specific split — and it is slow, a few hundred milliseconds against a real database. It is worth it: this is the test that stops us from selling the same seat twice.

The error format contract and external services

The shape { error: { code, message, status, details } } is a contract with whoever consumes the API, and it is worth pinning down with a case table:

const CASES = [
  ['401 with no token', () => request(app).post('/api/orders').send({}), 401],
  ['404 on a nonexistent route', () => request(app).get('/api/does-not-exist'), 404],
  ['400 with an invalid body', () => request(app).post('/api/auth/login').send({}), 400],
];
CASES.forEach(([name, makeRequest, status]) => {
  it(`respects the error format on ${name}`, async () => {
    const { body } = await makeRequest().expect(status);
    expect(body.error).to.have.all.keys('code', 'message', 'status', 'details');
    expect(body.error.status).to.equal(status);
    expect(body.error.code).to.be.a('string').and.match(/^[A-Z_]+$/);
  });
});

One more test is worth adding: one that checks the error response does not leak the call stack: expect(body.error).to.not.have.property('stack') and expect(JSON.stringify(body)).to.not.include('node_modules'). Leaking a stack trace to the client reveals server paths, library versions and sometimes fragments of queries, and it is a silent bug that appears the moment somebody "improves" the error handler for debugging and forgets to take it out.

In integration we test our complete system, but not third-party services: calling the real rate provider on every run produces slow, flaky tests that depend on a quota. The technique is faking at the edge: keep the whole integration real and replace only the outbound call with Sinon.

it('still returns the catalog when the converter is down', async () => {
  sinon.stub(global, 'fetch').rejects(new Error('ECONNREFUSED'));
  const { body } = await request(app).get('/api/events?currency=GBP').expect(200);
  expect(body.data).to.have.lengthOf(3);
});

This test has enormous value: it guarantees that an outage at an external provider does not take the catalog down, a real business requirement that only an integration test with a faked edge can demonstrate.

A note on end-to-end testing with a browser. Above this sits the real level: an automated browser that opens the site, searches for the Festival de Jazz, picks seats, pays and checks that the ticket appears. Playwright is today's reference in Node, with Cypress as an alternative. It is out of the scope of this course, which is server-side, and there is a deeper reason beyond the syllabus: these are the most expensive and brittle tests in the pyramid. The professional recommendation is to have very few of them, only over the flows that sink the business, and to resolve everything else at the levels below, which is exactly what we have done.

Common Mistakes and Tips

  • Pointing at the development database. You find out when the data disappears. Load .env.test from Mocha's require and check for _test before any deletion.
  • Forgetting await on supertest. request(app).get('/api/events').expect(200) without await returns a promise: the test always passes. It is the 09-02 trap in disguise.
  • Leaving the rate limits on. The concurrency test gets 429 instead of 409 and you blame the transaction.
  • Seeding in before instead of beforeEach. Every purchase changes the capacity and the second test expects a number that is no longer its own. And do not log in on every test: bcrypt is slow on purpose, that is what the authentication helper is for.
  • Not closing the connection at the end. With "exit": false the process hangs: that is what the afterAll with disconnect() is for. And do not depend on array order: Mongo does not guarantee it without a sort, so look items up by id with data.find(...).
  • Tip: when an integration test fails, print response.body before anything else; the error body usually says exactly what happened. And run the suite twice in a row without cleaning by hand: if the second run fails, your cleanup is incomplete.

Exercises

Exercise 1: the rate limits

Create test/integration/limits.test.js with an application built using rateLimiting: true. Write a test that repeats POST /api/auth/login until it exceeds loginLimit and checks that the last one returns 429 with its code and the Retry-After header. Think about how to stop this file from contaminating the others.

Exercise 2: the refresh lifecycle

Write the tests for Module 8's rotating refresh: that POST /api/auth/refresh with a valid cookie returns 200 and a new, different cookie; that reusing the old cookie after rotating it returns 401 and revokes the whole family; and that after the revocation not even the new cookie works.

Exercise 3: the organizer and their reports

Write the tests for GET /api/events/:id/report, which only the organizer who owns the venue or an administrator can query. Cover 200 for the owning organizer, 403 for the one from another venue, 403 for the attendee, 401 with no token, 200 for the administrator and 404 for a nonexistent event requested by an administrator.

Solutions

Exercise 1

// Its OWN application with the limits on: not shared with the other files
const limitedApp = createApplication({ serveStaticFiles: false, logging: false, rateLimiting: true });

it('returns 429 once the login attempt limit is exceeded', async () => {
  const credentials = { email: '[email protected]', password: 'whatever1!' };
  let last;
  for (let i = 0; i < 12; i += 1) {
    last = await request(limitedApp).post('/api/auth/login').send(credentials);
    if (last.status === 429) break;
  }
  expect(last.body.error.code).to.equal('TOO_MANY_REQUESTS');
  expect(last.headers).to.have.property('retry-after');   // when to retry
});

Contamination is avoided by using your own instance of the application (each createApplication creates its own express-rate-limit counters) and not reusing it in other files. If the limit store were external — Redis, Module 10 — you would have to flush it in afterEach.

Exercise 2

it('revokes the family when an already rotated cookie is reused', async () => {
  const initial = (await logIn()).headers['set-cookie'];
  const first = await request(app).post('/api/auth/refresh')
    .set('Cookie', initial).expect(200);
  const fresh = first.headers['set-cookie'];
  expect(fresh[0]).to.not.equal(initial[0]);

  const reuse = await request(app).post('/api/auth/refresh')
    .set('Cookie', initial).expect(401);   // reusing the old one: a sign of theft
  expect(reuse.body.error.code).to.equal('REFRESH_REUSED');
  // The whole family is revoked: not even the new one works
  await request(app).post('/api/auth/refresh').set('Cookie', fresh).expect(401);
});

It is one of the most valuable tests in the project: reuse detection is subtle security logic that nobody remembers six months later and that a well-meaning refactor can switch off without anyone noticing.

Exercise 3

const PATH = '/api/events/evt-001/report';

it('returns 200 to the organizer who owns the venue', async () => {
  const marta = await asOrganizer('org-almendra');
  const { body } = await request(app).get(PATH).set(...marta.header).expect(200);
  expect(body).to.have.property('revenueCents');
});
it('returns 404 when the event does not exist, even for the administrator', async () => {
  const admin = await asAdministrator();
  await request(app).get('/api/events/evt-999/report').set(...admin.header).expect(404);
});

The other four come from the same mold: 403 for the org-boveda organizer, 403 for the attendee, 401 with no token and 200 for the administrator. Notice the ordering these tests reveal: authentication first (401), then authorization (403) and only then existence (404). If the 404 were checked before the 403, an attendee could work out which event identifiers exist by probing the API.

Conclusion

We have moved up a level in the pyramid and gained what unit tests could not give us: the certainty that the pieces fit together. We now know that the routes are mounted, that the middleware runs in the right order, that errorHandler translates every error in the hierarchy into its HTTP status and its contract format, that the capacity really is subtracted in the database, that one attendee cannot see another's order, and that ten simultaneous purchases for five seats sell five seats.

And we have delivered on the Module 8 promise with test/helpers/authentication.js: a helper that creates a user with the required role and signs a valid token with the same service the application uses, with no password written into the code and without paying the bcrypt cost on every test.

One uncomfortable question remains. We have plenty of tests, but do they cover what matters? Are there branches of the code no it has ever executed? How long does the suite take, and what happens when it takes three times as long? How does the team make sure nobody pushes code with the tests red?

In the next lesson, Coverage and Test Automation, we measure what we are really testing with c8, we set thresholds that can only go up, we organize the npm scripts, we learn to hunt down a flaky test and we leave the project ready for a continuous integration server to run all of this without a human involved.

Node.js Course: From Beginner to Advanced

Module 1: Introduction to Node.js

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved