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.
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:
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.
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.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.
creates a new function that did not exist anywhere before. jest.fn()
takes an existing method of an existing object and wraps it in observation.jest.spyOn()
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.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.
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, @name.The drill ground is ready and the dummies are in place:
jest.fn() creates a new stand-in function and tracks its calls from the outset,mockReturnValue synchronously, mockResolvedValue a resolved promise, mockRejectedValue a rejected one, the ...Once variants apply a single time,useValue in createTestingModule substitutes the double for the real dependency,jest.spyOn() observes an existing method and by default does not change its behaviour - only mockResolvedValue replaces it,toHaveBeenCalledTimes checks the number of calls, toHaveBeenCalledWith the arguments,jest.clearAllMocks() in afterEach resets 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.