For four modules we have lived with an npm test that fails on purpose. That ends today. We install Mocha and Chai, configure the runner, fix the test script in package.json and write the unit suite for the Escena Viva domain: Session, Event, SalesManager and — the promise outstanding since Module 8 — the entire matrix in src/authorization/policy.js, walked cell by cell.

Contents

  1. Installing the stack and fixing the test script
  2. Anatomy of a test file
  3. The hooks and their exact execution order
  4. Focusing and skipping tests
  5. Chai: styles and assertion catalog
  6. Assertions about errors
  7. Asynchronous tests and timeouts
  8. The Escena Viva unit suite
  9. Test data factories

Installing the stack and fixing the test script

npm install --save-dev mocha chai@4

The version matters: Chai 5 ships ESM only and cannot be loaded with require(). Since Escena Viva is CommonJS, we pin version 4; if you see "chai": "^5..." you will get ERR_REQUIRE_ESM on the very first run.

The runner options live in .mocharc.json at the project root, so that they are visible to the team and to the continuous integration server:

{
  "spec": ["test/**/*.test.js"],
  "recursive": true,
  "timeout": 5000,
  "reporter": "spec",
  "forbidOnly": true,
  "exit": false
}
Option Meaning
spec File pattern; with the *.test.js convention there is no ambiguity
recursive Descends into test/unit/ and test/integration/
timeout Maximum milliseconds per test (2000 by default)
reporter spec prints the describe/it hierarchy in a readable way
forbidOnly Fails the run if it finds a forgotten .only
exit false: do not force the exit. If something is left open, I want to know

That last one deserves a comment. If Mocha finishes but the process does not die, it is because something is still alive: a Mongo connection, a setInterval, a server listening. "exit": true hides the problem; false forces you to close resources properly. In 09-06 we will see how to diagnose what was left open.

And now, at last, the script that has been failing since Module 5. Next to the ones that already existed (start, dev, seed, lint, format, audit), we add:

{ "test": "mocha",
  "test:unit": "mocha test/unit/**/*.test.js",
  "test:watch": "mocha --watch",
  "check": "npm run lint && npm test" }

There is no need to pass paths any more: Mocha reads .mocharc.json. test:watch re-runs on save, which is how you work while developing. npm test now answers 0 passing with exit code 0: the project's first green.

Anatomy of a test file

Mocha exposes globals (describe, it, before...) without importing them; Chai does have to be imported.

// test/unit/session.test.js
'use strict';

const { expect } = require('chai');
const { Session } = require('../../src/domain/session.js');

describe('Session', () => {
  describe('sell()', () => {
    it('subtracts the tickets sold from the available capacity', () => {
      const session = new Session({           // arrange
        id: 'ses-001-1', eventId: 'evt-001', startsAt: '2026-10-17T20:00:00.000Z',
        capacity: 400, sold: 100, priceCents: 2500,
      });
      session.sell(3);                        // act
      expect(session.available).to.equal(297); // assert
    });
  });
});

describe groups and nests freely: by convention the outer one names the unit and the inner ones the method or the scenario. it is one test: it passes if the function finishes without throwing, and fails if it throws (and a failed Chai assertion throws). Style rule: do not use arrow functions if you need Mocha's this (for example this.timeout(10000)), because arrows have no this of their own.

The hooks and their exact execution order

Hook When What for
before Once, before all the tests in the block Connecting to the database, expensive resources
beforeEach Before each it in the block and in nested ones Rebuilding the clean state of each test
afterEach After each it Restoring doubles, cleaning up data
after Once, at the end of the block Closing connections

With an outer describe that has all four hooks plus test A, and an inner describe with its own four hooks plus tests B and C, the actual trace is exactly this:

outer before
outer beforeEach  >> test A  outer afterEach
inner before
outer beforeEach · inner beforeEach  >> test B  inner afterEach · outer afterEach
outer beforeEach · inner beforeEach  >> test C  inner afterEach · outer afterEach
inner after · outer after

The three rules that follow: beforeEach hooks run from the outside in and afterEach hooks from the inside out, like a stack; the outer beforeEach also runs for the tests in inner blocks (the usual source of "why is my object being reset?"); and the inner before does not run until the first test in that block.

What to put in each one: in before, only what is expensive and immutable, never state the tests are going to mutate. In beforeEach, the arrangement, because if two tests share a mutable object they will sooner or later step on each other. In afterEach, the mandatory cleanup (sinon.restore(), dropping collections, restoring clocks): Mocha runs it even if the test fails, and that is why it is the right place. In after, close whatever before opened, or the process will never end.

Focusing and skipping tests

it.only('subtracts the tickets sold', () => { /* ... */ });  // only this one
it.skip('groups several sessions into one order', () => { /* skipped */ });
it('sends a reminder 24h before the session');  // pending: no function

The classic mistake with .only is forgetting it, pushing it, and having CI go green while running a single test. That is why we set forbidOnly: true: a forgotten .only breaks CI loudly instead of lying quietly (locally you disable it with mocha --forbid-only=false).

Pending tests are an honest way to write down the behavior that is still missing: they show up in the report, so they do not get forgotten the way a TODO does. There is also the conditional skip, before(function () { if (!process.env.MONGODB_URI_TEST) this.skip(); }), useful when a test requires a service that may not be there.

Chai: styles and assertion catalog

Chai offers three styles for saying the same thing: assert.equal(a, b), expect(a).to.equal(b) and a.should.equal(b). We will use expect because it reads almost like a sentence, it does not modify Object.prototype (unlike should, which blows up with null and undefined), and its failure messages include both the expected and the actual value.

Assertion What it checks Example
to.equal(v) Strict equality (===) expect(session.available).to.equal(297)
to.deep.equal(v) Structural equality expect(order.lines).to.deep.equal([{ quantity: 2 }])
to.include(v) Contains an element, substring or subset expect(code).to.include('EV-2026-')
to.have.property(p, v) The property exists, optionally with a value expect(body.error).to.have.property('code', 'INSUFFICIENT_CAPACITY')
to.have.lengthOf(n) Length of an array or string expect(event.sessions).to.have.lengthOf(2)
to.be.true / to.be.false / to.be.null Exact boolean and absence of value expect(session.soldOut).to.be.true
to.throw(Type, /regex/) The function throws expect(() => session.sell(0)).to.throw(ValidationError)
to.be.closeTo(v, delta) Numbers with a tolerance expect(session.occupancy).to.be.closeTo(0.25, 0.001)
to.be.an('array') / to.be.instanceOf(C) Type and instance expect(catalog).to.be.an('array')

Mistake number one: equal versus deep.equal

const actual = { id: 'evt-001', title: 'Concierto de Otono' };

expect(actual).to.equal({ id: 'evt-001', title: 'Concierto de Otono' });
// FAILS: they are two different objects in memory, === is false

expect(actual).to.deep.equal({ id: 'evt-001', title: 'Concierto de Otono' });
// PASSES: compares the structure, key by key, recursively

expect(actual).to.deep.include({ title: 'Concierto de Otono' });
// Only the keys that matter: ideal with volatile fields such as createdAt

A mnemonic: equal for primitives, deep.equal for anything written with braces or brackets. When you see expected { id: 'evt-001' } to equal { id: 'evt-001' } with two apparently identical objects, you are missing the deep.

Assertions about errors

With the hierarchy from Module 6, checking only that "something threw" is not enough: if sell(0) threw an internal TypeError instead of the expected ValidationError, a sloppy test would pass all the same. The scale goes from a bare to.throw() (sloppy), to to.throw(ValidationError) (better), to to.throw(ValidationError, /integer/) (type and message).

But what really travels to the client is the appCode, on which the HTTP status depends through STATUS_BY_CODE. The capture pattern lets you assert on it:

it('throws StateConflict with appCode INSUFFICIENT_CAPACITY when no seats are left', () => {
  const session = createSession({ capacity: 400, sold: 398 });  // only 2 available
  let caught = null;
  try {
    session.sell(5);
    expect.fail('Expected sell to throw StateConflict');
  } catch (error) { caught = error; }

  expect(caught).to.be.instanceOf(StateConflict);
  expect(caught.appCode).to.equal('INSUFFICIENT_CAPACITY');
  expect(caught.details).to.deep.include({ available: 2, requested: 5 });
});

The expect.fail(...) is essential: without it, if sell(5) did not throw, the catch would never run, caught would still be null and the assertions would fail with a confusing message. When a single property is enough there is a compact version: expect(() => session.sell(5)).to.throw(StateConflict).with.property('appCode', 'INSUFFICIENT_CAPACITY').

Asynchronous tests and timeouts

Mocha offers three mechanisms: async/await in the it (the right way), returning the promise (equivalent) and the done callback, a legacy of the pre-promise era that is still the best option for events emitted in the future.

it('returns the event with its sessions', async () => {
  const event = await eventRepository.findById('evt-001');
  expect(event.sessions).to.have.lengthOf(2);
});

You will see done in action a few pages further down, with SalesManager. Its traps: if you never call it the test dies by timeout with a generic message; if an assertion fails inside the callback it may not be attributed to the right test; and never mix it with async (Mocha throws Resolution method is overspecified).

The silent failure: the unawaited promise

// WRONG: the it returns undefined; the assertion runs AFTER the test finished.
it('throws when the event does not exist', () => {
  (async () => {
    const event = await eventRepository.findById('evt-999');
    expect(event).to.be.null;
  })();
});

Mocha marks the it as good immediately and the assertion is evaluated into the void: if it fails, it will show up as an unhandledRejection with no apparent relation to the test, or it will not show up at all. Rule: if async or .then appears in the body of an it, the it must be async and must await.

To test that a promise rejects, the same try/catch with expect.fail works, as does the chai-as-promised plugin (npm i -D chai-as-promised@7 and chai.use(chaiAsPromised)), which adds await expect(promise).to.be.rejectedWith(ResourceNotFound). Watch the await in front of expect: without it you are back to an unawaited promise and the test always passes. It is the same trap in disguise.

Timeouts

The message Error: Timeout of 2000ms exceeded means one of three things, in order of frequency: you forgot done() or returned a promise that never settles; the operation is genuinely slow (database, bcrypt at cost 12); or there is a deadlock waiting for an event nobody emits. You adjust it per block (describe('...', function () { this.timeout(10000); })), per test, or you disable it with this.timeout(0) while debugging. Do not raise the global timeout to paper over a slow test: you would turn a two-second deadlock into a thirty-second wait.

The Escena Viva unit suite

test/unit/session.test.js

'use strict';

const { expect } = require('chai');
const { ValidationError, StateConflict } = require('../../src/errors.js');
const { createSession } = require('../helpers/factories.js');

describe('Session', () => {
  describe('sell()', () => {
    it('subtracts the tickets sold from the available capacity', () => {
      const session = createSession({ capacity: 400, sold: 100 });
      session.sell(3);
      expect(session.available).to.equal(297);
    });

    it('throws ValidationError when the quantity is not a positive integer', () => {
      const session = createSession({ capacity: 400, sold: 0 });
      [0, -2, 1.5, 'two'].forEach((q) => expect(() => session.sell(q))
        .to.throw(ValidationError));
    });

    it('throws StateConflict with appCode INSUFFICIENT_CAPACITY when they do not fit', () => {
      const session = createSession({ capacity: 400, sold: 398 });
      expect(() => session.sell(5)).to.throw(StateConflict)
        .with.property('appCode', 'INSUFFICIENT_CAPACITY');
    });

    it('allows selling exactly the last available seats', () => {
      const session = createSession({ capacity: 400, sold: 397 });
      session.sell(3);
      expect(session.available).to.equal(0);
      expect(session.soldOut).to.be.true;
    });
  });
});

Still missing are the derived properties (occupancy with be.closeTo, priceEuros, soldOut being false), which follow the same mold. The boundary case — selling exactly the last three — is the <= versus < that someone writes wrong one day and turns into an oversale of one ticket.

test/unit/event.test.js

Event is an aggregate, so its tests are about the sums and about reconstruction: totalCapacity is 800 and ticketsSold is 350 with two sessions of 400 capacity and 100/250 sold; soldOut is false if a single session still has room; and Event.fromJSON({ id, title, venueId, sessions }) returns an instanceOf(Event) whose sessions are Session instances with their available already computed, and also throws if the JSON carries no identifier. It is the same pattern as in Session: a minimal scenario, one operation, an assertion on the observable value.

test/unit/policy.test.js — the matrix cell by cell

The test promised back in Module 8. Since hasPermission, canManageEvent and canViewOrder are pure functions, we walk the entire matrix with case tables and loops:

describe('authorization policy', () => {
  describe('hasPermission()', () => {
    const CASES = [
      ['attendee', PERMISSIONS.VIEW_CATALOG, true],
      ['attendee', PERMISSIONS.BUY_TICKETS, true],
      ['attendee', PERMISSIONS.MANAGE_EVENTS, false],
      ['attendee', PERMISSIONS.MANAGE_USERS, false],
      ['organizer', PERMISSIONS.MANAGE_EVENTS, true],
      ['organizer', PERMISSIONS.VIEW_VENUE_REPORTS, true],
      ['organizer', PERMISSIONS.MANAGE_USERS, false],
      ['administrator', PERMISSIONS.MANAGE_EVENTS, true],
      ['administrator', PERMISSIONS.MANAGE_USERS, true],
      ['administrator', PERMISSIONS.VIEW_VENUE_REPORTS, true],
      ['box-office', PERMISSIONS.VIEW_CATALOG, false],  // unknown role and no role:
      [undefined, PERMISSIONS.VIEW_CATALOG, false],     // that is where breaches sneak in
    ];

    CASES.forEach(([role, permission, expected]) => {
      it(`${expected ? 'grants' : 'denies'} ${permission} to role ${role}`, () => {
        expect(hasPermission(role, permission)).to.equal(expected);
      });
    });
  });

  describe('canManageEvent()', () => {
    const event = { id: 'evt-001', venueId: 'org-almendra' };  // at Teatro Almendra
    const OWNERSHIP = [
      ['administrator over any event', { role: 'administrator', venueId: null }, true],
      ['organizer over their own venue', { role: 'organizer', venueId: 'org-almendra' }, true],
      ['organizer over another venue', { role: 'organizer', venueId: 'org-boveda' }, false],
      ['attendee over any event', { role: 'attendee', venueId: null }, false],
      ['null user', null, false],  // the case that gets forgotten most often
    ];

    OWNERSHIP.forEach(([scenario, user, expected]) => {
      it(`${expected ? 'allows' : 'denies'} managing: ${scenario}`, () => {
        expect(canManageEvent(user, event)).to.equal(expected);
      });
    });
  });

  describe('canViewOrder()', () => {
    const order = { id: 'ord-001', userId: 'usr-001' };

    it('lets an attendee view their own order', () => {
      expect(canViewOrder({ id: 'usr-001', role: 'attendee' }, order)).to.be.true;
    });

    it('stops an attendee from viewing another attendee order', () => {
      expect(canViewOrder({ id: 'usr-002', role: 'attendee' }, order)).to.be.false;
    });
  });
});

Seventeen cells walked with two three-line loops, edge cases included. Mocha's output reads like a security specification, and if someone inverts an if in canManageEvent, several tests turn red naming exactly the problem.

test/unit/sales-manager.test.js

SalesManager is an EventEmitter, so we test that it emits what it should:

it('emits sale-recorded with the session and the quantity sold', (done) => {
  manager.once('sale-recorded', (data) => {
    expect(data.sessionId).to.equal('ses-001-1');
    expect(data.quantity).to.equal(2);
    done();
  });
  manager.recordSale(createSession({ id: 'ses-001-1', sold: 100 }), 2);
});

it('does not repeat low-capacity on later sales of the same session', () => {
  const session = createSession({ capacity: 100, sold: 100 - LOW_CAPACITY_THRESHOLD - 1 });
  let times = 0;
  manager.on('low-capacity', () => { times += 1; });
  manager.recordSale(session, 2);
  manager.recordSale(session, 1);
  expect(times).to.equal(1);
});

With a beforeEach(() => { manager = new SalesManager(); }) in front and a third, analogous test for session-sold-out. Two techniques live side by side: done for the asynchronous event and collecting into a variable for the synchronous ones, which lets you count how many times it was emitted. The second is the kind of rule nobody remembers when refactoring six months later.

Test data factories

Repeating the full constructor in every test is noise: it hides which piece of data matters in each case. The solution is factories with default values and partial overrides:

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

const { Session } = require('../../src/domain/session.js');
const { Event } = require('../../src/domain/event.js');

let counter = 0;

function createSession(overrides = {}) {
  counter += 1;
  return new Session({  // valid defaults, everything overridable
    id: `ses-test-${counter}`, eventId: 'evt-001',
    startsAt: '2026-10-17T20:00:00.000Z',
    capacity: 400, sold: 0, priceCents: 2500, ...overrides,
  });
}

// createEvent does the same with the Concierto de Otono at org-almendra,
// mapping its sessions through createSession; createUser returns Lucia,
// attendee, [email protected], venueId null. All overridable.

module.exports = { createSession, createEvent, createUser };

Three properties make a factory good: it always returns a fresh object (no shared constants), its defaults are valid (createSession() with no arguments produces a usable session) and the overrides make the relevant part visible (createSession({ sold: 398 }) says without noise that this test is about a nearly full house).

Run npm test now: 38 passing (52 ms). That is the speed of unit tests over a pure domain, and the reason the base of the pyramid is so wide.

Common Mistakes and Tips

  • ERR_REQUIRE_ESM when importing Chai. You are on Chai 5 with CommonJS. Install chai@4.
  • Using equal with objects. The failure shows two identical objects and drives you mad: you need deep.equal.
  • Resolution method is overspecified. You put both done and async on the same it. Pick one.
  • A forgotten .only. CI goes green after running one test. Turn on forbidOnly.
  • this.timeout() inside an arrow function. It does not work: use function ().
  • Arranging in before instead of beforeEach. The first test mutates the object and the following ones receive it dirty. When in doubt, beforeEach.
  • Assertions that never run. Every test with try/catch needs its expect.fail on the happy path.
  • Tip: verify each new test by breaking the code on purpose (change >= to > in sell and check that it turns red), and run a single file with npx mocha test/unit/session.test.js instead of reaching for .only.

Exercises

Exercise 1: the price of an order

Write test/unit/order.test.js with four tests for Order: that the total adds up the lines in cents, that a newly created order is in pending status, that it accepts no more than 6 tickets per session, and that an order with no lines throws. Use a local factory.

Exercise 2: the missing cell

The CASES table is missing PERMISSIONS.CANCEL_ORDER. Add it for the three roles with this rule: the attendee can cancel their own orders, the organizer cannot, the administrator can. Then write the canChangeRole test that verifies that only the administrator can change roles and that nobody can change their own role.

Exercise 3: hunt the false positive

Explain why this test always passes, even if findById returns null, and rewrite it:

it('finds the event by id', () => {
  eventRepository.findById('evt-001').then((event) => {
    expect(event.title).to.equal('Concierto de Otono');
  });
});

Solutions

Exercise 1. With a local createOrder() factory that by default carries one line of 2 tickets at 2500 cents:

it('adds up the total of all its lines in cents', () => {
  const order = createOrder({ lines: [
    { sessionId: 'ses-001-1', quantity: 2, priceCents: 2500 },
    { sessionId: 'ses-002-1', quantity: 1, priceCents: 1800 },
  ] });
  expect(order.totalCents).to.equal(6800);
});

The other three are straightforward: createOrder().status must be 'pending' with paidAt null; a line with quantity: 7 must throw ValidationError with a message that includes the 6; and createOrder({ lines: [] }) must throw as well.

Exercise 2. Three new rows in CASES (['attendee', PERMISSIONS.CANCEL_ORDER, true], ['organizer', ..., false], ['administrator', ..., true]) and four canChangeRole tests with admin, marta (organizer) and lucia (attendee):

expect(canChangeRole(admin, lucia)).to.be.true;   // the administrator can
expect(canChangeRole(marta, lucia)).to.be.false;  // the organizer cannot
expect(canChangeRole(lucia, marta)).to.be.false;  // the attendee cannot
expect(canChangeRole(admin, admin)).to.be.false;  // nobody on themselves

The last one is the important one: it stops the last administrator from demoting themselves by mistake and leaving the platform with nobody able to manage users.

Exercise 3. The it is not async and does not return the promise from .then, so Mocha considers the test finished before the .then runs; if the assertion fails afterwards, the failure arrives orphaned. The correct version is async, with await and a preceding expect(event).to.not.be.null that gives a far clearer failure message than a Cannot read properties of null.

Conclusion

npm test is green. We have Mocha configured through .mocharc.json, Chai in the expect style, the hooks with their order genuinely understood, the assertion catalog you use every day, the technique for checking that an error is the right error with its code, the ways to test asynchronous code along with the unawaited-promise trap, and a real suite covering the domain and the complete permission matrix.

But notice what all these tests have in common: none of their units depends on anything external. Session makes no network calls, policy.js queries no database, SalesManager never looks at the clock. That is why they run in milliseconds and deterministically.

The moment we step out of that bubble, the terrain changes. currency-exchange.js calls an API through fetch, generateTicketCode uses the current year, access tokens expire after 15 minutes and the repositories open connections to Mongo. None of those units can be tested deterministically as they stand.

In the next lesson, Test Doubles with Sinon, we will replace the clock, the network and the database with controlled versions: spies, stubs, mocks and fake clocks. And we will also see where the limit is, because faking too much produces tests that only check your own assumptions.

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