Unit testing NestJS services
Nest's documentation shows service tests built with Test.createTestingModule.
This course mostly does not use it, and the reason is worth more than the
technique.
A service is a class
service = new OrdersService(
prisma as unknown as PrismaService,
notifications as unknown as NotificationsService,
);
That is the whole setup. No module, no container, no compile(), no
await.
OrdersService takes two constructor arguments. Dependency injection is how it
gets them in the application; in a test you are the injector. Booting a
container to hand a class two objects is ceremony, and it costs a second of
startup on every file.
Test.createTestingModule earns its place when you want the real graph —
several providers wired as they are in production, or a module's configuration
under test. The end-to-end tests in this project use it for exactly that.
The casts are honest
prisma as unknown as PrismaService
That double cast looks like giving up on types, and it is a deliberate trade.
The fake implements the four methods this service calls, out of the several
hundred PrismaClient exposes. Making it structurally complete would mean
hundreds of lines of stubs nobody reads.
The risk is real: if OrdersService starts calling prisma.payment.findFirst,
the fake will not have it and the test fails with Cannot read properties of undefined rather than something useful. That is an acceptable cost for a
readable test — but it is a cost, and pretending otherwise is how people end up
surprised.
What is worth unit testing here
it("refuses a jump in the state machine", async () => {
prisma.order.findUnique.mockResolvedValue({ ...orderRow, status: "PLACED" });
await expect(
service.setStatus(orderRow.orderNumber, "DELIVERED"),
).rejects.toMatchObject({ status: 409 });
});
Branches and refusals. Each of these is one line of setup, whereas proving the same thing end to end would mean creating a user, a cart, an order and a delivery slot — for a check that never touches the database.
Six of them, covering every refusal in the service:
it("is a 404 when the order is not the user's", …);
it("refuses once the order has been packed", …);
it("tells the customer to ring the shop once it has left", …);
it("says so when it is already cancelled", …);
it("refuses moving to the status it is already in", …);
it("refuses to change a delivered order at all", …);
Notice they assert messages as well as statuses:
await expect(service.cancelForUser("user-1", orderRow.orderNumber)).rejects.toThrow(
/ring us/i,
);
That is not gold-plating. "This order has left the shop, please ring us" versus "cannot cancel" is the difference between a customer who rings and one who writes a bad review. The wording is behaviour, so it is tested — loosely, with a regex, so rewording the rest of the sentence does not break it.
What is not worth unit testing here
/**
* The behaviour that genuinely touches SQL — stock coming back, the slot being
* freed — is left to the end-to-end tests, where it is actually true rather
* than mocked. Asserting "we called updateMany" would only test the mock.
*/
This is the most important paragraph in the file.
You could write:
expect(prisma.variant.updateMany).toHaveBeenCalledWith({
where: { id: "variant-1" },
data: { stock: { increment: 2 } },
});
It passes. It also passes when the where clause is wrong, when the column
does not exist, when the migration has not run, and when PostgreSQL rejects the
statement — because none of those things happen. The test asserts that your code
called your fake with the arguments you told your fake to expect.
A test that would still pass if the database were on fire is not testing the database.
So stock release is proved in checkout.e2e-spec.ts, against real PostgreSQL,
by reading the row afterwards. It is slower, and it is true.
The test that guards an ordering
This one is worth studying, because it is the sort of thing tests are uniquely good at:
it("notifies only after the transaction has committed", async () => {
prisma.order.findUnique.mockResolvedValue({ ...orderRow, status: "PLACED" });
prisma.order.update.mockResolvedValue(updatedRow);
await service.setStatus(orderRow.orderNumber, "PACKED");
const [transactionCall] = prisma.$transaction.mock.invocationCallOrder;
const [notifyCall] = notifications.orderStatusChanged.mock.invocationCallOrder;
expect(notifyCall).toBeGreaterThan(transactionCall as number);
});
Module 13 established the rule: nothing inside a transaction may do anything the database cannot roll back, because a transaction can abort and an SMS cannot be unsent.
A comment saying so does not fail when somebody moves the line. This does.
invocationCallOrder is a global counter across all mocks, so comparing two of
them tells you which happened first. It is the only straightforward way to
assert an ordering between two collaborators.
And its companion:
it("does not notify at all when the transition is refused", async () => {
prisma.order.findUnique.mockResolvedValue({ ...orderRow, status: "DELIVERED" });
await expect(service.setStatus(orderRow.orderNumber, "PACKED")).rejects.toThrow();
expect(notifications.orderStatusChanged).not.toHaveBeenCalled();
});
A customer who is texted about a status change that did not happen is a support call.
beforeEach, not shared state
beforeEach(() => {
prisma = { order: { findFirst: vi.fn(), … }, … };
notifications = { orderStatusChanged: vi.fn() };
service = new OrdersService(…);
});
Rebuilt for every test, not created once and cleared. vi.clearAllMocks()
resets calls but keeps any mockResolvedValue you set — so a value set in test
three leaks into test four, and the failure appears in a test that looks
innocent.
Rebuilding is a few microseconds and removes a whole category of confusion.
Order dependence is the commonest cause of a suite that passes locally and fails in CI, where the order can differ.
Testing what is deliberately slow
describe("password hashing", () => {
it("produces a different hash each time", async () => {
const [a, b] = await Promise.all([
hashPassword("same-password-twice"),
hashPassword("same-password-twice"),
]);
expect(a).not.toBe(b);
});
});
bcrypt at cost 12 takes about a quarter of a second, and this file takes two seconds. The obvious fix — lower the cost for tests — is wrong: you would then be testing a configuration you do not ship.
Raise the timeout instead. vitest.config.ts sets testTimeout: 30_000, and
two seconds of genuinely slow hashing is a fair price for testing the real
thing.
The assertion itself is about salting. Two identical passwords hashing to the same string would mean the salt is missing, and a stolen table could then be attacked once for every user at a time rather than once per user.
The pure functions are where the value is
Sixty per cent of this project's unit tests are in packages/shared, and they
are the cheapest and most valuable of the lot:
it("stays exact across many lines", () => {
const lines = Array.from({ length: 100 }, () => ({ linePaise: 9517 }));
expect(cartTotals(lines).subtotalPaise).toBe(951_700);
});
That test fails the day somebody "simplifies" money to floats. It costs nothing to run and it guards the number a customer is charged.
Pure functions are trivially testable, which is itself an argument for pushing
logic into them. cartTotals, canTransition, slotIsBookable and
normalisePhone are all pure — and all four are tested to their boundaries in a
few milliseconds.
When something is hard to test, that is usually a fact about the code rather than about testing.
Check your work
Why not Test.createTestingModule for a service: it is a class, and the
container exists to hand it two objects you already have.
When the container does earn its place: when you want the real graph, which is what the end-to-end tests want.
The cost of the double cast: a new call on the fake fails with an unhelpful error rather than a type error.
Why branches are unit tested: one line of setup each, versus a whole order in a database for a check that never touches it.
Why messages are asserted: the wording is behaviour, and "ring us" versus "cannot cancel" changes what the customer does.
Why toHaveBeenCalledWith on Prisma proves nothing: it passes with wrong
SQL, a wrong condition and no database at all.
What invocationCallOrder is for: asserting an ordering between two
collaborators, which a comment cannot.
Why rebuild in beforeEach: clearAllMocks keeps configured return values,
so state leaks into the next test and fails somewhere innocent.
Why not lower the bcrypt cost for tests: you would be testing a configuration you do not ship.
Why pure functions carry most of the value: they are trivial to test to their boundaries, which is an argument for putting logic in them.
Practice
- Run
npm run test --workspace=apps/apiand note the total time. Work out which file is most of it. - Add a seventh refusal to
OrdersServiceand test it. Count the lines. - Write the same test end to end instead, and count those lines.
- Add
expect(prisma.variant.updateMany).toHaveBeenCalled()to a cancellation test. Then break thewhereclause and confirm the test still passes. - Move the notification call inside the transaction and watch the ordering test fail.
- Replace the
beforeEachrebuild withvi.clearAllMocks(). Set amockResolvedValuein one test and find where it leaks. - Lower the bcrypt cost to 4 in
password.tsand time the suite. Then decide whether you would ship that. - Pick a function in the API that is hard to test. Work out what would make it easy, and whether that would also make it better.
- Write a unit test for
slotIsBookableat 8pm without changing your system clock. Note which design decision made that possible. - Delete
packages/shared's money tests and run the suite. Then introduce a float intocartTotalsand see what catches it.
Next: the tests that need a real database.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship