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.
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.
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 |
if, every
error case, every edge case gets its own tiny test.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:
describe / it — group and name tests (vitest globals, no imports).vi.fn() — fake functions whose return values and calls we
control and inspect.tests/mocks/mockServices.ts — ready-made fakes for EmailService, Redis,
QueueService, Logger, S3, BunnyService, and a generic TypeORM repository.tests/helpers/mockBuilders.ts — createMockRepo() and makeQb() (a fake
of TypeORM's query builder — the object you call in a chain).tests/helpers/testHelpers.ts — mockRequest(), mockResponse(),
token/email generators, mockFetchResponse().tests/fixtures/* — canned domain objects (makeCategory(), etc.).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.
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.
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."
| 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?".
"Integration" is a range, not one thing. From narrowest to broadest:
success, data,
message).fetch/SDKs, and keep
real-vendor verification out of CI.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.
The integration tests start real infrastructure in Docker, automatically, using Testcontainers. Nothing is faked. The main pieces:
vitest.config.integration.ts — separate config. Targets
tests/integration/**, runs files serially (they share one DB), and wires
in a global setup.tests/integration/helpers/globalSetup.ts — starts one MySQL 8 and one
Redis 7 container for the whole run and shares their host/port with every
test worker, then shuts them down at the end. Does nothing when Docker isn't
installed, so machines without Docker still run the unit tests fine.mysqlContainer.ts — starts mysql:8.0, waits until it's ready, then runs
our real production migrations (runMigrations) on it.tests/integration/helpers/dbReset.ts — empties the relevant tables
(TRUNCATE) before each test, with foreign-key checks turned off, so tests
don't affect each other.auth.ts — createAuthSession() seeds a real user_sessions row and
signs a matching JWT, so requests pass through the real verifyToken
middleware exactly like production. signOrphanToken() makes a token with no
backing session, to test rejection paths.seedVlearning.ts — builders (createUser, createModule,
createLessons, createAssignment, …) that insert real rows to set up a
scenario.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);
});
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.
# 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
New unit test (tests/unit/services/MyService.test.ts):
createMockRepo() for each repository the service takes.new MyService(repo as any) in beforeEach, after vi.clearAllMocks().it(...) per behaviour/branch, following Arrange-Act-Assert.New integration test (tests/integration/...):
describe.skipIf(!isDockerAvailable()).seedVlearning builders + createAuthSession.supertest; assert on res.status and res.body.resetTables() in beforeEach so each test is independent.Real things we ran into building this — worth knowing before you write a test:
vi.clearAllMocks() in beforeEach (unit) and
resetTables() + redis.flushAll() (integration). Shared state = flaky tests.process.env.DB_* /
REDIS_* before importing @/config/database and @/app.file-type ESM import in the admin V-Learning controller can't load under
Vitest's VM — we vi.mock that controller in suites that don't exercise it.new them (see makeBullmqMock()).utcDayOffset(); don't mix
in local-time dates or date-boundary tests will flake.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.
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.
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:
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 thing — relation(...).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.
vi.mock & vi.hoisted)This trips everyone up once, so it's worth 5 minutes.
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.
vi.mock is hoisted to the topVitest 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.
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
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.)
A quick reference for every keyword that appears in the examples above.
| 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). |
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. |
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) |
mockReturnValuevsmockResolvedValue: useResolvedwhenever the real method returns a Promise (i.e. the callerawaits it). Use the plainReturnValuefor synchronous returns. Mixing them up is the #1 beginner bug — a forgottenResolvedgives you a raw value where the codeawaits a Promise.
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). |
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.
The best option is already in this repo:
npm run test:ui) — opens a browser dashboard of the whole
suite: click a test to see it pass/fail, see exactly what it expected vs. what
it got, filter, and re-run on save. This is the ideal thing to demo live, because it runs
against our real code. @vitest/ui is already a dev dependency, so
npm run test:ui works out of the box — it opens at http://localhost:51204/__vitest__/.npm run test:watch) — edit a test or the source, save, and
Vitest instantly re-runs only what changed. The fast edit → save → red/green
feedback loop you'll live in while writing tests.To experiment with expect / matchers / mocks in the browser without cloning
Bridge, use an in-browser sandbox that runs Node + Vitest for real:
vitest.new (recommended): open
vitest.new (or stackblitz.com
→ "Vitest" starter). It boots a full Node + Vitest project in the browser
via WebContainers — edit *.test.ts, hit save, and tests re-run instantly.
Nothing to install.codesandbox.io) — same idea; pick a
Node / Vitest template.vitest.dev) — the Guide plus the
API → Expect and Mock Functions pages are the canonical reference for
every keyword in §9, with runnable examples/.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.
| 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.
tests/mocks/mockServices.tstests/helpers/mockBuilders.ts, tests/helpers/testHelpers.tstests/unit/services/VLCategoryService.test.tstests/unit/controllers/vlCategoryController.test.tstests/integration/helpers/{globalSetup,mysqlContainer,dbReset,auth}.tstests/integration/http/vlearningHttp.integration.test.tsvitest.config.ts, vitest.config.integration.ts