NestJS course Β· Module 7: Testing
Mocking - simulating battle conditions
In this lesson6
You want to test a service that pays out wages. The trouble is it reaches into a database and into the Empire's external treasury. The test would therefore need a running database, a network and somebody else's server - it would be slow, and it would fail on the first broken connection even though your code was faultless.
The legion trains differently. On the drill ground you set up dummies instead of real opponents. A dummy strikes no blows back, but it lets you check whether the legionary holds formation. In testing we call such dummies test doubles.
Four kinds of dummy
Test doubles differ in how much they can do. The whole quartet is worth knowing, because the names recur in every piece of documentation - here from the simplest to the most elaborate:
- Dummy - a straw-stuffed figure. It fills a parameter slot the method will not use anyway. It does nothing.
- Stub - a dummy with a ready answer. Asked anything, it always says the same, with no trace of logic. "Legionary with id 1? Here is Marcus."
- Spy - a dummy that remembers the blows. It answers like a stub, but additionally records how many times it was called and with what.
- Mock - a dummy with expectations. It not only records calls but serves to verify interactions: did the service really call the repository, and exactly once?
In practice Jest blurs the line between the last three - the same function often acts as both stub and spy. But the distinction of roles remains: a stub supplies data, a spy observes, a mock verifies.
jest.fn() - a dummy built from scratch
The basic tool creates an empty stand-in function:
1const findOne = jest.fn();This function does nothing and returns undefined, but Jest tracks every call to it - remembering how many times it fired and with which arguments. So jest.fn() alone is already a spy; all it lacks is an answer.
We add the answer with one of four methods, which differ in what they return:
1const repo = {
2 findOne: jest.fn().mockResolvedValue({ id: 1, name: 'Marcus' }),
3 count: jest.fn().mockReturnValue(42),
4 save: jest.fn().mockRejectedValue(new Error('Treasury unavailable')),
5};mockReturnValue returns a value immediately, synchronously - fit for methods that are not asynchronous. mockResolvedValue returns a resolved promise, so it suits anywhere the code does an await. mockRejectedValue returns a rejected promise - this is how you check that your service handles a failure properly.
There is also a variant with the Once suffix: mockReturnValueOnce answers that way only the first time and then falls back to the default behaviour. It comes in handy when you want the first call to fail and the retry to succeed.
We slot the prepared dummy in place of the real dependency:
1const module = await Test.createTestingModule({
2 providers: [
3 PayService,
4 { provide: getRepositoryToken(Legionary), useValue: repo },
5 ],
6}).compile();useValue tells NestJS: when somebody asks for the legionary repository, hand them this object instead of the real one. The service notices nothing - it receives something with the same methods.
jest.spyOn() - a spy on an existing method
Sometimes you do not want to build a dummy from scratch but to watch a real object. That is what the second method is for:
1const spy = jest.spyOn(legionService, 'findAll');The difference between the two is fundamental, and it is what gets asked most often. jest.fn() creates a new function that did not exist anywhere before. jest.spyOn() takes an existing method of an existing object and wraps it in observation.
The crucial part is what spyOn by default does not do: it does not replace the behaviour. The real method still runs, and you merely see that it was called. When you also want to substitute an answer, you add it exactly as before:
1jest.spyOn(legionService, 'findAll').mockResolvedValue([]);Now the real method no longer runs. That pair - observe, or observe and replace - is all of spyOn.
Verification - was the dummy called
Since doubles record calls, we can ask about them in assertions:
1expect(spy).toHaveBeenCalledTimes(1);
2expect(repo.findOne).toHaveBeenCalledWith({ where: { id: 1 } });toHaveBeenCalledTimes checks the number of calls, toHaveBeenCalledWith the arguments. This is the moment the double acts as a mock: we no longer care what it returned, only whether the conversation happened at all and in what form.
Be careful not to overdo it, though. Testing every call ties the test to the service's internal structure - a small refactor will then break the tests even though the code's behaviour has not changed. Check interactions where the interaction itself is the point: that an email was sent, that a write to the database occurred. For ordinary reads, checking the result is enough.
Cleaning up after drill
Doubles remember calls - including those from the previous test. Without clearing, toHaveBeenCalledTimes(1) will start seeing two calls in the second test:
1afterEach(() => {
2 jest.clearAllMocks();
3});clearAllMocks resets the counters and recorded arguments of every stand-in. It is one of those lines whose absence shows up only when tests start passing or failing depending on the order they run in - the hardest kind of fault to track down. Put it in straight away.
Summary
The drill ground is ready and the dummies are in place:
- test doubles replace real dependencies, so a test needs neither a database nor a network,
- the quartet from simplest up: Dummy (filler), Stub (ready answer), Spy (records calls), Mock (verifies interactions),
jest.fn()creates a new stand-in function and tracks its calls from the outset,- answers:
mockReturnValuesynchronously,mockResolvedValuea resolved promise,mockRejectedValuea rejected one, the...Oncevariants apply a single time, useValueincreateTestingModulesubstitutes the double for the real dependency,jest.spyOn()observes an existing method and by default does not change its behaviour - onlymockResolvedValuereplaces it,toHaveBeenCalledTimeschecks the number of calls,toHaveBeenCalledWiththe arguments,- do not verify every call: a test bound to internal structure breaks on refactoring,
jest.clearAllMocks()inafterEachresets the counters - without it, tests start depending on their order.
In the next lesson we will check how much of the code your tests really reached - you will meet test coverage. For now remember: a double is a dummy on the drill ground; jest.fn() builds one, jest.spyOn() dresses a real object as one, and the assertions ask whether the legionary struck at all.
Code for this lesson: src/mocking.spec.ts
1// Mocking - Simulating Weather Conditions
2import { Test, TestingModule } from '@nestjs/testing';
3import { Injectable } from '@nestjs/common';
4
5// Interface of an external service
6interface WeatherAPI {
7 getConditions(region: string): Promise<{
8 wind: number;
9 waves: number;
10 visibility: number;
11 }>;
12}
13
14// Navigation service - depends on WeatherAPI
15@Injectable()
16class NavigationService {
17 constructor(private weatherApi: WeatherAPI) {}
18
19 async canSail(region: string): Promise<boolean> {
20 const conditions = await this.weatherApi.getConditions(region);
21 return conditions.wind < 50 && conditions.waves < 5;
22 }
23
24 async getRouteRisk(region: string): Promise<string> {
25 const conditions = await this.weatherApi.getConditions(region);
26 if (conditions.wind > 70) return 'extreme';
27 if (conditions.wind > 50) return 'high';
28 if (conditions.wind > 30) return 'moderate';
29 return 'low';
30 }
31}
32
33describe('NavigationService - Mocking', () => {
34 let service: NavigationService;
35
36 // Mock WeatherAPI - we simulate the weather conditions
37 const mockWeatherApi: WeatherAPI = {
38 getConditions: jest.fn(),
39 };
40
41 beforeEach(async () => {
42 const module: TestingModule = await Test.createTestingModule({
43 providers: [
44 NavigationService,
45 { provide: 'WeatherAPI', useValue: mockWeatherApi },
46 ],
47 }).compile();
48
49 service = new NavigationService(mockWeatherApi);
50 jest.clearAllMocks();
51 });
52
53 it('should allow marching in calm conditions', async () => {
54 // Mock configuration - calm sea
55 (mockWeatherApi.getConditions as jest.Mock).mockResolvedValue({
56 wind: 20, waves: 2, visibility: 10,
57 });
58
59 const canSail = await service.canSail('Mediterranean');
60 expect(canSail).toBe(true);
61 expect(mockWeatherApi.getConditions).toHaveBeenCalledWith('Mediterranean');
62 });
63
64 it('should prevent marching in storm', async () => {
65 // Mock configuration - storm
66 (mockWeatherApi.getConditions as jest.Mock).mockResolvedValue({
67 wind: 80, waves: 8, visibility: 1,
68 });
69
70 const canSail = await service.canSail('Atlantic');
71 expect(canSail).toBe(false);
72 });
73
74 it('should assess route risk correctly', async () => {
75 (mockWeatherApi.getConditions as jest.Mock).mockResolvedValue({
76 wind: 55, waves: 4, visibility: 5,
77 });
78
79 const risk = await service.getRouteRisk('North Sea');
80 expect(risk).toBe('high');
81 });
82
83 it('should handle API errors gracefully', async () => {
84 (mockWeatherApi.getConditions as jest.Mock).mockRejectedValue(
85 new Error('Weather service unavailable')
86 );
87
88 await expect(service.canSail('Unknown')).rejects.toThrow(
89 'Weather service unavailable'
90 );
91 });
92});
93Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. jest.fn() in tests creates:
2. What is the difference between jest.fn() and jest.spyOn()?
These are 2 of 3 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Mock the database repository using jest.fn()
- Click in order
Arrange the correct mockResolvedValue configuration:
- Vertical ordering
Arrange mocking variants from synchronous to asynchronous:
- Code editor
Install a spy on the findAll() method of the legions service
- Click in order
Arrange the assertion checking the number of mock calls:
- Vertical ordering
Arrange Test Doubles from simplest to most complex: