API Testing — Unit & Integration

A practical guide to what unit and integration tests are, why they matter, how they're written in the Bridge API, and when to reach for each — with real examples from the codebase.

The stack: Node.js + Express + TypeScript, TypeORM over MySQL 8, Redis 7 + BullMQ for queues, tested with Vitest and Testcontainers. The codebase currently has ~1,900 unit test cases across 66 files, plus a growing set of integration suites.


1. Why test at all?

A test is just code that runs your code and fails loudly when behaviour changes. We write them because:

There isn't just one kind of test — they range from tiny and isolated to large and realistic. The two this guide focuses on are unit and integration.


2. Unit Tests

2.1 What is a unit test?

A unit test verifies a single "unit" of logic — usually one function or one class method — in isolation from everything else (no real database, no network, no Redis, no other services).

Anything the unit depends on is replaced with a mock (a fake version we control). That's the whole point of isolating it: if the test fails, the bug is in that piece of code, not in something it calls several steps away.

A good unit test is FIRST:

Letter Means In practice
Fast milliseconds We run ~1,900 of them in seconds
Isolated no shared state vi.clearAllMocks() in beforeEach
Repeatable same result every time no real clock, no network, no DB
Self-validating pass/fail, no human reading logs expect(...) assertions
Timely written with (or before) the code part of every PR

2.2 Why they're useful

2.3 How we write them in Bridge

Every unit test follows the same three steps — Arrange → Act → Assert (set it up → run it → check the result):

it('trims the name', async () => {
  // Arrange — set up inputs and mock behaviour
  categoryRepo.create.mockImplementation((d) => ({ ...d }));
  categoryRepo.save.mockImplementation(async (d) => ({ id: 1, ...d }));

  // Act — call the one thing under test
  const result = await service.create({ name: '  Sales & Marketing  ' });

  // Assert — verify the outcome
  expect(categoryRepo.create).toHaveBeenCalledWith(
    expect.objectContaining({ name: 'Sales & Marketing', order: 0, status: 'active' })
  );
  expect(result.id).toBe(1);
});

Building blocks we standardised on:

2.4 Worked example #1 — a service unit test

The service layer holds our business logic, so it has the most unit tests. Here's VLCategoryService.delete(), tested in tests/unit/services/VLCategoryService.test.ts:

beforeEach(() => {
  vi.clearAllMocks();
  categoryRepo = createMockRepo();          // fake repositories — NO real DB
  moduleRepo = createMockRepo();
  service = new VLCategoryService(categoryRepo as any, moduleRepo as any);
});

it('throws CategoryInUseError when modules reference it', async () => {
  categoryRepo.findOne.mockResolvedValue({ id: 1 });
  // makeQb() fakes the query builder; getCount() returns 4
  moduleRepo.createQueryBuilder.mockReturnValue(makeQb({ getCount: vi.fn().mockResolvedValue(4) }));

  await expect(service.delete(1)).rejects.toBeInstanceOf(CategoryInUseError);
  await expect(service.delete(1)).rejects.toMatchObject({ moduleCount: 4 });
  expect(categoryRepo.delete).not.toHaveBeenCalled();   // it must NOT delete
});

What's being tested: the rule "you cannot delete a category that modules still use." The database is never touched — we tell the fake repo "4 modules use this" and check that the service refuses to delete and throws the right error. Fast, predictable, and focused on a single rule.

2.5 Worked example #2 — a controller unit test

Controllers connect HTTP requests to service calls. We unit-test them by putting a fake service onto the controller and calling it with fake req/res objects — from tests/unit/controllers/vlCategoryController.test.ts:

beforeEach(() => {
  mockService = {
    findById: vi.fn().mockResolvedValue(null),
    delete: vi.fn().mockResolvedValue(undefined),
    // ...
  };
  (vlCategoryController as any).service = mockService;   // swap the real service for a fake
});

it('returns 409 with moduleCount when the category is still in use', async () => {
  mockService.delete.mockRejectedValue(new CategoryInUseError(3));
  const req = mockRequest({ params: { id: '4' } });
  const res = mockResponse();

  await vlCategoryController.delete(req, res);

  expect(res.statusCode).toBe(409);                 // correct HTTP status
  expect(res.data.data.moduleCount).toBe(3);        // correct payload
  expect(res.data.message).toContain('3 module(s)');
});

What's being tested: how the controller answers over HTTP — does a CategoryInUseError from the service turn into a 409 with the right body? The service is faked, so this only checks "does the controller turn each outcome into the right HTTP response."

Takeaway: unit tests split the app into thin layers — the service logic and the controller are tested separately, each with the other faked out.


3. Integration Tests

3.1 What is an integration test?

An integration test verifies that multiple units work correctly together — and, crucially, that they integrate with real external systems (a real database, real Redis), not fakes.

Unit tests prove each part works alone. Integration tests prove the parts work together: the SQL actually runs, the TypeORM entities match the real table columns, the migrations applied, the middleware runs in order, and the JSON response comes back in the right shape. A unit test simply can't catch these bugs, because there the database is a fake that always says "yes."

3.2 How integration differs from unit

Unit Integration
Scope one function/class many units + real infra
Dependencies mocked (fake DB, fake Redis) real (MySQL 8, Redis 7 via Docker)
Speed milliseconds seconds (container boot + real I/O)
Catches logic / branch bugs how parts connect, SQL, schema, JSON shape, login-flow bugs
Needs Docker? no yes (self-skips if Docker absent)
How many? thousands (cheap) dozens (focused on key journeys)
When it runs every save / every PR PR + deploy pipeline
Bridge config vitest.config.ts vitest.config.integration.ts

A unit test asks "is this function's logic correct?". An integration test asks "does a real request actually work end-to-end?".

3.3 Types of integration testing

"Integration" is a range, not one thing. From narrowest to broadest:

  1. Component / narrow integration — a service against a real database, but still below HTTP. Proves your queries and entity mappings are correct.
  2. API / HTTP integration (end-to-end through the app) — drive the real Express app over HTTP, through the real middleware (auth, routing) into real controllers, services, and a real DB. This is our highest-value type and what our V-Learning suite does.
  3. Persistence / migration integration — the DB is created by running our production SQL migrations, so a broken migration fails the suite.
  4. Contract integration — check that the request/response shape other code (e.g. the frontend) depends on doesn't change unexpectedly. We do a light version by checking the standard JSON response fields (success, data, message).
  5. External-service integration — talking to third parties (Postmark, SendGrid, Bunny, KYC providers). We deliberately do not hit live vendors; we test our adapters at the unit level by mocking fetch/SDKs, and keep real-vendor verification out of CI.

3.4 Where & when integration tests should be used

Use them for the things unit tests can't prove — and don't try to cover every branch here (that's the unit layer's job). Good targets:

Keep them few and high-value — they're slower and need Docker.

3.5 How the integration setup works

The integration tests start real infrastructure in Docker, automatically, using Testcontainers. Nothing is faked. The main pieces:

3.6 Worked example — the V-Learning HTTP journey

From tests/integration/http/vlearningHttp.integration.test.ts. This covers the whole path a real member request travels: check the JWT → look up the session in Redis/DB → route it → controller → service → MySQL → JSON response.

const hasDocker = isDockerAvailable();

describe.skipIf(!hasDocker)('V-Learning HTTP e2e (real app + real MySQL + real Redis)', () => {
  // beforeAll: point app's DB/Redis env at the shared containers, then import the real app
  // beforeEach: resetTables() + redis.flushAll()  → each test starts clean

  it('self-add a module → make progress → planner updates upcoming → active → completed', async () => {
    const userId = await createUser(dataSource);
    const { token } = await createAuthSession(dataSource, { id: userId, role: 'User' });
    const auth = { Authorization: `Bearer ${token}` };

    const moduleId = await createModule(dataSource, { title: 'Self-Added Module', status: 'published' });
    const [l1, l2] = await createLessons(dataSource, moduleId, 2);

    // 1) Add it to my planner (no progress yet)
    const add = await request(app).post(PLANNER).set(auth)
      .send({ items: [{ moduleId, deadline: utcDayOffset(7).toISOString().slice(0, 10) }] });
    expect(add.status).toBe(201);

    // 2) Fresh + future deadline → 'upcoming'
    let item = await plannerItem();
    expect(item.status).toBe('upcoming');

    // 3) Complete lesson 1 of 2 → there's progress → 'active'
    await request(app).post(`${BASE}/lessons/${l1}/complete`).set(auth);
    item = await plannerItem();
    expect(item.status).toBe('active');

    // 4) Complete the last lesson → module done → 'completed'
    await request(app).post(`${BASE}/lessons/${l2}/complete`).set(auth);
    item = await plannerItem();
    expect(item.status).toBe('completed');
  });
});

Why this can only be an integration test: it crosses two controllers over HTTP, the planner's status is worked out from real rows in MySQL, and the change (upcoming → active → completed) comes from combining the assignment and lesson-progress data. A unit test with a fake database could never prove the real query works out 'active' correctly.

Here's the access-denied test from the same file — it proves the real login checks reject a token whose session isn't in Redis or the database:

it('rejects a token with no matching session (Redis miss → DB fallback) with 401', async () => {
  const userId = await createUser(dataSource);
  const token = signOrphanToken({ id: userId, role: 'User' });   // no session row
  const res = await request(app).get(MY_ASSIGNMENTS).set('Authorization', `Bearer ${token}`);
  expect(res.status).toBe(401);
  expect(res.body.message).toMatch(/invalid or expired session/i);
});

4. Unit vs Integration — when to use which

The Test Pyramid: many fast unit tests at the base, fewer integration tests above, a handful of full end-to-end at the top.

        /\        few   ← end-to-end / HTTP journeys (slow, realistic)
       /  \
      /----\      some  ← integration (real DB + Redis)
     /      \
    /--------\    many  ← unit tests (fast, isolated)   ← Bridge: ~1,900

Rules of thumb:

They're complementary: units prove the pieces are right; integration proves the pieces are wired right.


5. Running the tests

# Unit tests — fast, no Docker needed
npm run test:unit            # all unit tests once
npm run test:watch           # re-run on save while developing
npm run test:coverage        # with coverage report

# Integration tests — needs Docker running (MySQL + Redis containers)
npm run test:integration     # boots containers, runs tests/integration/**
npm run test:integration:ci  # + coverage (used in the pipeline)

# Everything
npm test                     # base config = unit tests

Adding a new test — quick recipe

New unit test (tests/unit/services/MyService.test.ts):

  1. createMockRepo() for each repository the service takes.
  2. new MyService(repo as any) in beforeEach, after vi.clearAllMocks().
  3. One it(...) per behaviour/branch, following Arrange-Act-Assert.

New integration test (tests/integration/...):

  1. describe.skipIf(!isDockerAvailable()).
  2. Seed data with the seedVlearning builders + createAuthSession.
  3. Drive the app with supertest; assert on res.status and res.body.
  4. resetTables() in beforeEach so each test is independent.

6. Lessons & things to watch out for

Real things we ran into building this — worth knowing before you write a test:


7. Walkthrough — take a function and test it, step by step

The whole job of a test is: call the function, then check what came out. Everything else is just setup. Let's do it twice — once for a function with no dependencies, once for a function that talks to the database.

Case A — a pure function (no dependencies → no mocks)

A pure function just takes inputs and returns an output — it doesn't change anything else. These are the easiest things to test — no mocks at all.

// illustrative source
export function applyDiscount(price: number, percent: number): number {
  if (percent < 0 || percent > 100) throw new Error('percent out of range');
  return Math.round(price * (1 - percent / 100));
}

The recipe — think of the cases first, then write one it per case: happy path, boundaries (0 and 100), and the error path.

import { describe, it, expect } from 'vitest';
import { applyDiscount } from '../../../src/utils/pricing';

describe('applyDiscount', () => {
  it('takes the percentage off and rounds', () => {
    // Arrange + Act
    const result = applyDiscount(100, 25);
    // Assert
    expect(result).toBe(75);
  });

  it('0% leaves the price unchanged', () => {
    expect(applyDiscount(100, 0)).toBe(100);
  });

  it('throws when the percent is out of range', () => {
    expect(() => applyDiscount(100, 150)).toThrow('percent out of range');
  });
});

That's the entire mental model: Arrange inputs → Act (call it) → Assert (expect). No mocks needed because the function depends on nothing.

Case B — a function with a dependency (→ mock the dependency)

Real services talk to the database. We can't (and don't want to) hit a real DB in a unit test, so we replace the repository with a fake and then inspect how the function used it. Here's the real createModule from src/services/VLearningService.ts:

async createModule(data: any): Promise<VLModule> {
  const { categoryIds, ...rest } = data;
  if (rest.status === 'published') throw new Error("Can't publish this module without lessons.");

  const module = this.moduleRepository.create(rest);
  const saved  = await this.moduleRepository.save(module);

  if (Array.isArray(categoryIds) && categoryIds.length > 0) {
    await this.moduleRepository
      .createQueryBuilder()
      .relation(VLModule, 'categories')   // "I'm editing the categories relation"
      .of(saved.id)                       // "...for THIS module"
      .add(categoryIds);                  // "...link these category ids"
  }
  return saved;
}

Two distinct things to test here:

  1. the business rule — publishing with no lessons throws;
  2. the collaboration — it links the categories via the relation builder.
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { VLearningService } from '../../../src/services/VLearningService';
import { VLModule } from '../../../src/entities/VLModule';
import { createMockRepo, makeQb, MockRepo } from '../../helpers/mockBuilders';

describe('VLearningService.createModule', () => {
  let service: VLearningService;
  let moduleRepo: MockRepo;

  beforeEach(() => {
    vi.clearAllMocks();
    moduleRepo = createMockRepo();
    // (constructor takes several repos; only moduleRepo matters for this test)
    service = new VLearningService(moduleRepo as any /*, ...other repos */);
  });

  it('refuses to publish a module that has no lessons', async () => {
    await expect(service.createModule({ title: 'X', status: 'published' }))
      .rejects.toThrow("Can't publish this module without lessons");
  });

  it('saves the module and links the given categories', async () => {
    const qb = makeQb();                                   // fake query builder
    moduleRepo.create.mockImplementation((d: any) => d);   // create() just echoes the entity
    moduleRepo.save.mockResolvedValue({ id: 42, title: 'Onboarding' }); // save() returns a row with an id
    moduleRepo.createQueryBuilder.mockReturnValue(qb);     // hand back our fake builder

    const saved = await service.createModule({ title: 'Onboarding', categoryIds: [1, 2] });

    expect(saved.id).toBe(42);
    // We didn't touch a DB — we PROVE the wiring by inspecting the fake:
    expect(qb.relation).toHaveBeenCalledWith(VLModule, 'categories');
    expect(qb.of).toHaveBeenCalledWith(42);
    expect(qb.add).toHaveBeenCalledWith([1, 2]);
  });
});

The key idea of Case B: since there's no real database, you can't check "the rows are linked." Instead you check that the function asked the database to do the right thingrelation(...).of(42).add([1,2]). That's what toHaveBeenCalledWith is for, and it's why makeQb() exists: the real query builder lets you chain calls in a row (.relation().of().add()), so each fake method has to return the builder again — see mockReturnThis in the cheat-sheet.


8. Sharing mocks across a file — hoisting (vi.mock & vi.hoisted)

This trips everyone up once, so it's worth 5 minutes.

The problem: imports run before your test code

When you write:

import { EmailService } from '../../../src/services/EmailService';

…that import executes immediately — and EmailService itself imports Redis and BullMQ, which try to connect the moment they're loaded. By the time your beforeEach runs, the damage is done. We need the fakes in place before any import runs.

Vitest's fix: vi.mock is hoisted to the top

Vitest automatically moves every vi.mock(...) call to the very top of the file, above your imports. So this works even though it's written after:

// You write this near the top; Vitest runs it BEFORE the import above.
vi.mock('@/config/database', () => ({
  AppDataSource: { getRepository: vi.fn(() => ({})) },
}));

That's why our controller tests can vi.mock('@/services/LoggerService', …) and trust the real controller never touches the real logger.

The catch: a hoisted factory can't see your variables

Because vi.mock is lifted to the top, its factory runs before any const you declared. So this would crash — bullmqMock doesn't exist yet at hoist time:

const bullmqMock = makeBullmqMock();        // runs LATER
vi.mock('bullmq', () => bullmqMock);        // hoisted EARLIER → ReferenceError

The fix: vi.hoisted — "compute this at hoist time too"

vi.hoisted() runs your setup code at the same hoisted moment as vi.mock, and hands back the values so the factory can use them. This is the real pattern from tests/unit/services/EmailService.test.ts:

// Build the shared doubles AT hoist time, so the vi.mock factories below can use them.
const { mockRedis, bullmqMock } = await vi.hoisted(async () => {
  const m = await import('../../mocks/mockServices');
  return { mockRedis: m.mockRedisClient, bullmqMock: m.makeBullmqMock() };
});

vi.mock('../../../src/config/redis', () => ({ redisClient: mockRedis })); // one shared Redis fake
vi.mock('bullmq', () => bullmqMock);                                      // one shared BullMQ fake

import { EmailService } from '../../../src/services/EmailService';        // now safe to import

In one sentence: vi.mock swaps a module for a fake before imports; vi.hoisted lets you build a shared object early enough for those fakes to reference it — so the whole file mocks Redis/BullMQ from one single source of truth. (See makeBullmqMock() in tests/mocks/mockServices.ts — its constructors are regular functions, not arrows, precisely so the code under test can new them.)


9. Keyword cheat-sheet (plain English)

A quick reference for every keyword that appears in the examples above.

Structure & lifecycle

Keyword Meaning
describe('name', fn) Groups related tests under a heading.
it('does X', fn) / test(...) One test case. Name it as a sentence: "throws when not found."
expect(value) Starts an assertion; chain a matcher onto it.
beforeEach(fn) Runs before every it — where we reset state & rebuild fakes.
beforeAll / afterAll Run once before/after the whole describe (e.g. set env, open/close a DB).

Creating & controlling mocks (the vi.* family)

Keyword Meaning
vi.fn() Creates a fake function that records every call and lets you script its return value.
vi.mock('module', factory) Replaces a whole imported module with a fake. Vitest moves this above your imports automatically.
vi.hoisted(fn) Runs setup at hoist time so a vi.mock factory can reference its result (see §8).
vi.spyOn(obj, 'method') Wraps a real method so you can watch it (and optionally fake it) without replacing the object.
vi.clearAllMocks() Forgets all recorded calls & scripted returns between tests — our beforeEach staple.
vi.stubGlobal('fetch', fn) Replaces a global (e.g. fetch) for the test.

Scripting what a mock returns (called on a vi.fn())

Keyword Meaning Example
.mockReturnValue(v) Plain (non-async) return: the fake returns v. getCount.mockReturnValue(5)
.mockResolvedValue(v) Async return: the fake returns Promise.resolve(v). Use for anything await-ed (repos, services). save.mockResolvedValue({ id: 1 })
.mockRejectedValue(e) Async throw: returns Promise.reject(e). Use to test error paths. delete.mockRejectedValue(new Error('boom'))
.mockImplementation(fn) Replace the body entirely — compute the return from the args. create.mockImplementation(d => ({ id: 1, ...d }))
.mockReturnThis() Return the mock object itself — this is how we fake an API you call in a chain (qb.where().orderBy().take()); each call hands the builder back. used throughout makeQb()
...Once variants Same as above but only for the next call, then falls back. Great for "found, then not found." findOne.mockResolvedValueOnce(row).mockResolvedValueOnce(null)

mockReturnValue vs mockResolvedValue: use Resolved whenever the real method returns a Promise (i.e. the caller awaits it). Use the plain ReturnValue for synchronous returns. Mixing them up is the #1 beginner bug — a forgotten Resolved gives you a raw value where the code awaits a Promise.

Checking what happened (matchers after expect)

Keyword Meaning
.toBe(x) Exact match (===) — for simple values (numbers, text, true/false).
.toEqual(obj) Compares the full contents of objects/arrays (not whether they're the same instance).
.toHaveBeenCalled() The mock was called at least once.
.toHaveBeenCalledWith(args) The mock was called with exactly these arguments — proves the function passed the right data on.
.toHaveBeenCalledTimes(n) Called exactly n times.
.not.toHaveBeenCalled() Asserts it was never called (e.g. "it must not delete").
expect.objectContaining({...}) Match an object that contains at least these fields (ignore the rest).
.rejects.toThrow('msg') For async code: the Promise rejected with an error matching msg.
.toBeInstanceOf(Class) The value/error is of a given type (e.g. CategoryInUseError).
.toMatchObject({...}) The value contains at least these properties (handy on thrown errors).

TypeORM query-builder terms (what makeQb() is faking)

These aren't test keywords — they're the real TypeORM API our services call, which is why our query-builder mock has to provide them. The relation set in particular shows up in the createModule example:

Method What it does in TypeORM
createQueryBuilder('m') Starts a SQL query builder aliased m.
.where() / .andWhere() Add SQL conditions.
.leftJoinAndSelect() Join a related table and select its columns.
.orderBy() / .skip() / .take() Sort & paginate.
.getMany() / .getOne() / .getCount() The final call — actually runs the query and returns rows / a row / a count.
.relation(Entity, 'rel') Switch into relation-editing mode for the rel relation (e.g. a module's categories).
.of(id) "…for the record with this id."
.add(ids) / .remove(ids) Link / unlink related records (rows in the join table).

Because the real builder lets you chain calls (every method returns the builder so you can keep adding .method() calls), our fake makes each method mockReturnValue(qb) (the same idea as mockReturnThis()), and only the final methods (getMany, getCount, …) return data.


10. Where to practice — interactive playgrounds & the live UI

The best option is already in this repo:

A website to write tests live (no install, no repo)

To experiment with expect / matchers / mocks in the browser without cloning Bridge, use an in-browser sandbox that runs Node + Vitest for real:

The browser sandboxes are great for teaching the syntax (assertions, mocks). For anything DB-backed you still want the real repo + Docker, since integration tests need actual MySQL/Redis containers.

A good hands-on demo: run npm run test:ui, open VLCategoryService.test.ts, change the service so it doesn't throw CategoryInUseError, save, and watch the test go red — then fix it and watch it go green. That single loop captures the whole value proposition.


11. Summary

Unit Integration
Proves each piece is correct the pieces are wired correctly
Dependencies mocked real (Docker MySQL + Redis)
Speed / count ms / ~1,900 seconds / dozens
Example VLCategoryService.delete() refuses when in use planner journey upcoming→active→completed over HTTP
Default when logic, branches, error paths real requests, SQL, auth chain, journeys

Write many unit tests. Write fewer, high-value integration tests. Together they let us ship fast without breaking things.


Key files in the codebase