Two kinds of tests, explained from scratch with real examples from our code.
A unit test checks one small piece of code on its own — usually a single function.
The trick: that function usually needs other things to work (like the database). In a unit test we don't use the real ones — we give it fakes that we control. That keeps the test:
Think of it like testing a car part on a workbench instead of driving the whole car. You plug in fake inputs, and check the one part does its job.
We have about 1,900 of these, and they run on every code change.
This is a real function from VLCategoryService.ts. It creates a new category:
async create(data) {
const entity = this.categoryRepository.create({
name: data.name.trim(), // clean up spaces around the name
order: data.order ?? 0, // default order to 0 if none given
status: data.status ?? 'active', // default status to 'active'
});
return this.categoryRepository.save(entity); // save to the database
}
In plain words: it takes a name, trims the spaces off it, fills in some
defaults, and saves it. categoryRepository is the thing that talks to the
database.
An entity is just the shape of one row in the database. This is
VLCategory — what a "category" looks like:
@Entity('vl_categories') // this maps to the "vl_categories" table
export class VLCategory {
@PrimaryGeneratedColumn() // auto-numbered id (the database fills it in)
id: number;
@Column({ length: 100, unique: true })
name: string; // the category name (no two the same)
@Column({ default: 0 })
order: number; // sort order
@Column({ enum: ['active', 'archived'], default: 'active' })
status: VLCategoryStatus; // either 'active' or 'archived'
@CreateDateColumn() createdAt: Date; // set automatically when created
@UpdateDateColumn() updatedAt: Date; // set automatically when changed
}
The @ lines just tell the database how to store each field. So a category is
really just: an id, a name, an order number, a status, and two timestamps.
Now we test the create function. We fake the database, call the real function,
and check it did the right thing:
it('trims the name', async () => {
// 1) Set up the fakes (no real database)
categoryRepo.create.mockImplementation((d) => ({ ...d })); // just echoes back what it's given
categoryRepo.save.mockImplementation(async (d) => ({ id: 1, ...d })); // pretends it saved, adds id 1
// 2) Call the real function with a messy name (extra spaces)
const result = await service.create({ name: ' Sales & Marketing ' });
// 3) Check what it did
expect(categoryRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Sales & Marketing', order: 0, status: 'active' })
);
expect(result.id).toBe(1);
});
How to read it:
categoryRepo is the fake database. We told its create and save to
return simple values, so nothing real happens.service.create(...) with a name that has extra spaces.toHaveBeenCalledWith(...) checks what the function passed in to the
database: the name is now 'Sales & Marketing' (spaces gone) with the defaults
filled in. That proves the trimming worked.result.id checks what came back: the saved category, with id 1.Two important ideas (these cover most of our tests):
| You write | It means |
|---|---|
mockResolvedValue(x) |
what the fake gives back (its output) |
toHaveBeenCalledWith(x) |
what the fake was given (its input) |
That's a unit test: fake the surroundings, run the one function, check the input it sent and the output it returned. No database needed.
The service holds the logic. The controller sits in front of it and turns the result into an HTTP response (a status code + a body). We unit-test it the same way — except now the service is the fake, and we check the HTTP response.
This is from vlCategoryController.test.ts. The question it answers: when the
service says "this category is still used by 3 modules" (it throws a
CategoryInUseError), does the controller reply with 409 and the right
message?
beforeEach(() => {
// Put a FAKE service on the controller (no real logic, no database behind it)
mockService = {
delete: vi.fn(),
// ...other methods
};
(vlCategoryController as any).service = mockService;
});
it('returns 409 when the category is still in use', async () => {
// Tell the fake service to fail with "in use by 3 modules"
mockService.delete.mockRejectedValue(new CategoryInUseError(3));
const req = mockRequest({ params: { id: '4' } }); // a fake incoming request
const res = mockResponse(); // a fake response that records output
await vlCategoryController.delete(req, res); // run the REAL controller
expect(res.statusCode).toBe(409); // right HTTP status
expect(res.data.data.moduleCount).toBe(3); // right number in the body
expect(res.data.message).toContain('3 module(s)'); // right message
});
How to read it:
mockService is a fake service we attach to the controller, so the
controller has no real logic or database behind it.mockRequest / mockResponse are fake versions of the HTTP request and
response (small helpers we wrote). res simply records whatever the controller
sets on it.mockRejectedValue(new CategoryInUseError(3)) makes the fake service throw
that error — so we can see how the controller reacts to it.expects confirm the controller turned that error into a 409 with
the module count in the body.So the two unit tests split the work: the service test checks the logic; the controller test checks the HTTP response. Each layer is tested on its own, with the other one faked.
You might wonder where categoryRepo, service, and the mockRequest /
mockResponse above actually come from. We don't rebuild fakes from scratch
in every test file — we keep a small set of shared, reusable mock builders and
import them. Three main ones:
tests/helpers/mockBuilders.ts — createMockRepo() (a fake database
repository) and makeQb() (a fake query builder).tests/mocks/mockServices.ts — ready-made fakes for shared services (email,
Redis, the job queue, the logger, etc.).tests/helpers/testHelpers.ts — mockRequest() and mockResponse() for
controller tests.Then a beforeEach builds fresh ones before each test, so no test is affected
by the one before it:
beforeEach(() => {
vi.clearAllMocks(); // forget all previous calls
categoryRepo = createMockRepo(); // a fresh fake repository
moduleRepo = createMockRepo();
service = new VLCategoryService(categoryRepo, moduleRepo); // real service, fake repos
});
createMockRepo() hands back an object with all the repository methods
(findOne, save, update, …) already faked — each test then just tells the
specific ones what to return. Sharing these keeps the tests short and consistent,
and any extra fake methods we don't use in a given test are simply ignored.
A unit test checks one piece alone, with fakes. An integration test checks many pieces working together — using the real database and the real Redis, nothing faked.
It answers a different question: not "is this function correct?" but "does a real request actually work, all the way through?"
To do this, the tests start a real MySQL and a real Redis in Docker automatically, run our real migrations on them, and send real HTTP requests to the real app.
Because integration tests need real infrastructure, a fair amount happens around them. It runs in layers — from "once for everything" down to "before every single test."
Once, before the whole run: the suite starts a real MySQL and a real Redis inside Docker — one of each, shared by every test. It waits until they're ready, then runs our real database migrations on the MySQL, so the tables look exactly like production. This is the slow part, and it happens only once.
Once, before the tests in the file: we point the app at those containers (set the database and Redis connection details), open the database connection, and load the real app. From here on, the app is running for real — just against throwaway infrastructure instead of production.
Before every single test: we wipe the data — empty the relevant tables and clear Redis. This gives each test a clean, known starting point, so the order the tests run in never matters and one test can't affect the next. (It's the same idea as resetting the fakes in a unit test, but for real data.)
After everything is done: we close the database and Redis connections, and the Docker containers are stopped and thrown away. Nothing is left behind on your machine.
And if Docker isn't installed, none of this runs — the whole integration group skips itself, so the unit tests still pass on their own.
| Unit test | Integration test | |
|---|---|---|
| Tests | one function | many pieces together |
| Database | fake | real (MySQL + Redis in Docker) |
| Speed | milliseconds | a few seconds |
| Needs Docker? | no | yes |
| How many | thousands (cheap) | a handful (important flows) |
| Answers | "is the logic right?" | "does a real request work end-to-end?" |
Simple way to remember it: unit tests prove each piece is right; integration tests prove the pieces are connected right.
Instead of one function, we test a whole real user journey:
A member adds a course to their planner. At first it shows as upcoming. They finish the first lesson, so it becomes active. They finish the last lesson, so it becomes completed.
That single flow touches login, two different controllers, the services, and the database — all the pieces, in order. That's exactly what an integration test is good for.
From vlearningHttp.integration.test.ts. Notice we create a real user, log in
for real, and send real HTTP requests — then check the status changes:
it('add a module to the planner → make progress → status updates', async () => {
// Real user + real login (real token + real session row)
const userId = await createUser(dataSource);
const { token } = await createAuthSession(dataSource, { id: userId, role: 'User' });
const auth = { Authorization: `Bearer ${token}` };
// A real course with 2 lessons, saved in the real database
const moduleId = await createModule(dataSource, { title: 'Self-Added Module' });
const [lesson1, lesson2] = await createLessons(dataSource, moduleId, 2);
// 1) Add it to my planner (no progress yet)
const add = await request(app).post('/api/v1/v-learning/planner').set(auth)
.send({ items: [{ moduleId, deadline: '2026-07-10' }] });
expect(add.status).toBe(201);
// A small helper, defined right here in the test: fetch the planner and pick
// out OUR course from the list, so we can check its status after each step.
const plannerItem = async () => {
const res = await request(app).get('/api/v1/v-learning/planner').set(auth);
expect(res.status).toBe(200);
return res.body.data.items.find((i) => i.module?.id === moduleId);
};
// 2) Fresh + future deadline → 'upcoming'
let item = await plannerItem();
expect(item.status).toBe('upcoming');
// 3) Finish lesson 1 → now there's progress → 'active'
await request(app).post(`/api/v1/v-learning/lessons/${lesson1}/complete`).set(auth);
item = await plannerItem();
expect(item.status).toBe('active');
// 4) Finish the last lesson → course done → 'completed'
await request(app).post(`/api/v1/v-learning/lessons/${lesson2}/complete`).set(auth);
item = await plannerItem();
expect(item.status).toBe('completed');
});
How to read it:
request(app).post(...) sends a real HTTP request to the real app, just
like the frontend would.plannerItem() is a small helper defined right inside the test. It fetches
the planner and finds our course in the returned list (.find(...)), so we
can read its status after each step.upcoming → active → completed) is worked out by the real
app from real data in MySQL.The journey above had several steps. Here's a simpler one (also from
vlearningHttp.integration.test.ts) that shows the process every integration
test follows: a logged-in member asks for their assigned courses and gets them
back.
it("returns the member's assignments for a valid token", async () => {
// STEP 1 — put real data in the real database (using the seed helpers)
const userId = await createUser(dataSource);
const moduleId = await createModule(dataSource, { title: 'HTTP Flow Module' });
await createAssignment(dataSource, { userId, moduleId, assignedBy: userId, deadline: utcDayOffset(7) });
// STEP 2 — log in for real (real session row + matching token)
const { token } = await createAuthSession(dataSource, { id: userId, role: 'User' });
// STEP 3 — send a real HTTP request, with the token attached
const res = await request(app)
.get('/api/v1/v-learning/my-assignments')
.set('Authorization', `Bearer ${token}`);
// STEP 4 — check the real response
expect(res.status).toBe(200);
const assignments = res.body.data.assignments;
expect(assignments).toHaveLength(1);
expect(assignments[0].module.title).toBe('HTTP Flow Module');
});
The process in Bridge is the same every time — four steps:
createUser, createModule, createAssignment)
to insert real rows into the real database.createAuthSession gives back a real token + a real session row,
so the request passes the real login check.request(app).get(...).set('Authorization', ...) sends a real
HTTP call through the whole app (login check → routing → controller → service →
database).res.status and res.body.(And before each test, the setup empties the tables and clears Redis — so every test starts from a clean, known state.)
We write lots of unit tests and a few important integration tests. Together they let us change the code with confidence.