InteractiveFrameworks

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.

  1. Make owners an object with nameOf and exists as mock functions, provide it under OwnersService, and clear the mocks before each test.
  2. Write the four tests: findOne(1) resolves to Tom with owner ada and asked for owner 7; findOne(99) rejects with NotFoundException without asking; when nameOf rejects, findOne rejects with that error; adopt(1, 42) rejects with BadRequestException when 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(...).resolves or .rejects without await. Add it, and make the test async.
  • nameOf.mockResolvedValue is not a function: owners.nameOf is a plain function or missing. It has to be jest.fn().
  • A test sees calls from an earlier test: mocks are shared across tests. jest.clearAllMocks() in beforeEach.
  • "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.spyOn for one method of a real object; overrideProvider for 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).