RizTech Academy logo
RizTech Academy
Testing the Whole StackLesson 2 of 530 min

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

  1. Run npm run test --workspace=apps/api and note the total time. Work out which file is most of it.
  2. Add a seventh refusal to OrdersService and test it. Count the lines.
  3. Write the same test end to end instead, and count those lines.
  4. Add expect(prisma.variant.updateMany).toHaveBeenCalled() to a cancellation test. Then break the where clause and confirm the test still passes.
  5. Move the notification call inside the transaction and watch the ordering test fail.
  6. Replace the beforeEach rebuild with vi.clearAllMocks(). Set a mockResolvedValue in one test and find where it leaks.
  7. Lower the bcrypt cost to 4 in password.ts and time the suite. Then decide whether you would ship that.
  8. 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.
  9. Write a unit test for slotIsBookable at 8pm without changing your system clock. Note which design decision made that possible.
  10. Delete packages/shared's money tests and run the suite. Then introduce a float into cartTotals and 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