Mocks and Spies
Replace a slow dependency with a mock the test controls, drive the code under test through success and failure, and assert what it asked the dependency for.
What you'll learn
- Stand a dependency in with jest.fn() mocks provided under its token, and script their answers per test
- Test the failure path by making a mock reject, and async code with resolves and rejects
- Assert interactions with toHaveBeenCalledWith and not.toHaveBeenCalled, and know when a spy on the real class is the better tool
CatsService now asks an OwnersService for names, and the real one crosses a network. A unit test that used it would be slow, would fail when the owners system is down, and could never test what CatsService does when the owners system is down, because a test cannot make a real system fail on cue. The dependency has to be replaced by something the test controls. That something is a mock: a function that records how it was called and answers with whatever the test scripted.
A mock function
jest.fn() makes one:
const nameOf = jest.fn();
nameOf.mockResolvedValue('ada');
await nameOf(7); // 'ada'
expect(nameOf).toHaveBeenCalledWith(7);
expect(nameOf).toHaveBeenCalledTimes(1);
Scripting the answer: mockReturnValue(v) for a synchronous value, mockResolvedValue(v) for a promise that resolves, mockRejectedValue(err) for one that rejects, mockImplementation(fn) for logic, and each has a ...Once variant for the next call only. Recording: every call's arguments are in nameOf.mock.calls, and the matchers read them: toHaveBeenCalled(), toHaveBeenCalledTimes(n), toHaveBeenCalledWith(...args), toHaveBeenLastCalledWith(...args), and their .not forms. jest.fn() with no script returns undefined, which is often exactly right for a method the test does not care about.
Standing in for a whole dependency
A dependency is replaced by providing an object of mocks under its token. The testing module makes that one line:
const gateway = { charge: jest.fn(), refund: jest.fn() };
const moduleRef = await Test.createTestingModule({
providers: [OrdersService, { provide: PaymentGateway, useValue: gateway }],
}).compile();
OrdersService receives gateway where it asked for PaymentGateway, and never knows. The object needs only the methods the code under test calls, which is a useful pressure: a mock that needs twenty methods is telling you the class depends on too much.
Mocks remember calls across tests unless told otherwise, so jest.clearAllMocks() in beforeEach (calls forgotten, scripts kept) or jest.resetAllMocks() (both forgotten) keeps one test's history out of the next.
Spying on the real thing
Sometimes the real dependency is fine and one method needs replacing, or observing. jest.spyOn(object, 'method') wraps an existing method in a mock that calls through by default:
const owners = moduleRef.get(OwnersService);
jest.spyOn(owners, 'nameOf').mockResolvedValue('ada');
The docs' controller test does exactly this on a real service. mockRestore() puts the original back. And when a real module is imported into the testing module, overrideProvider() swaps one provider inside it without unpicking the module:
const moduleRef = await Test.createTestingModule({ imports: [OrdersModule] })
.overrideProvider(PaymentGateway)
.useValue(gateway)
.compile();
Testing what the code asked for
With a dependency mocked, a test can state two kinds of thing: what came out, and what the code under test asked the dependency for. findOne(1) should resolve to Tom with the owner's name, and it should have asked for owner 7, Tom's owner, not for cat 1. findOne(99) should reject and never have asked at all, because asking for an owner of a cat that does not exist is a bug that costs a network call. Both halves are assertions, and the second kind is only possible with a mock.
Assert interactions sparingly, though. A test that pins every call the code makes breaks on every refactor that changes how without changing what. Assert the calls that are part of the contract, like "does not call the owners system for a missing cat", and let the rest be.
Promises in tests
Asynchronous code is tested with async tests and two matcher prefixes. await expect(promise).resolves.toEqual(x) awaits the promise and matches its value; await expect(promise).rejects.toThrow(Error) awaits it and matches the rejection. Both need the await in front: without it the test finishes before the promise does, and a failure lands nowhere.
Your task
OwnersService is slow and lives elsewhere. Test CatsService without it.
- Make
ownersan object withnameOfandexistsas mock functions, provide it underOwnersService, and clear the mocks before each test. - Write the four tests:
findOne(1)resolves to Tom with owneradaand asked for owner 7;findOne(99)rejects withNotFoundExceptionwithout asking; whennameOfrejects,findOnerejects with that error;adopt(1, 42)rejects withBadRequestExceptionwhen owner 42 does not exist, and asked about 42.
Two of the bugs to catch are about what the service asked for, which only the mock can tell you.
When it fails
- The test finishes before the promise:
expect(...).resolvesor.rejectswithoutawait. Add it, and make the testasync. nameOf.mockResolvedValue is not a function:owners.nameOfis a plain function or missing. It has to bejest.fn().- A test sees calls from an earlier test: mocks are shared across tests.
jest.clearAllMocks()inbeforeEach. - "asked before the cat is checked" escapes: no test asserts
not.toHaveBeenCalled()for the missing cat. The rejection alone does not prove the order.
Remember
jest.fn()records calls and answers as scripted:mockResolvedValue,mockRejectedValue,mockReturnValue,mockImplementation.- Provide an object of mocks under the dependency's token;
jest.spyOnfor one method of a real object;overrideProviderfor one provider of an imported module. - Assert results always, interactions when they are part of the contract.
await expect(p).resolves/.rejects, and clear mocks between tests.
Stuck? Show a hint
owners = { nameOf: jest.fn(), exists: jest.fn() }, provided as { provide: OwnersService, useValue: owners }. Reset with jest.clearAllMocks() in beforeEach. Per test: owners.nameOf.mockResolvedValue('ada') or mockRejectedValue(new Error(...)), then await expect(service.findOne(1)).resolves.toEqual(...) or .rejects.toThrow(...), then expect(owners.nameOf).toHaveBeenCalledWith(7).