How to Mock without Providing an Implementation in TypeScript

Last updated Aug 8th, 2021
Having to provide an implementation everytime you create a test double leads to brittle tests. In this post, we learn how to create test doubles from a mere interface using the ts-auto-mock library.

🌱 This blog post hasn't fully bloomed. It's very likely to change over the next little while.

I've been spending some time attempting to really understand the philosophy of testing in software design. There is a tremendous amount of varying thought, but my goal is to find some truth and crunch it into something digestible.

A couple of the questions I've been wrestling with are:

Because I use Jest as my test runner and mocking comes with it out-of-the-box, I figured I'd use Jest to create my mocks and that'd be it. Unfortunately, as a diligent blog reader pointed out, I wasn't actually writing mocks. I was inadvertly writing stubs and incurring the negative implications of that slight as well.

Upon further research, I realized that this was an issue many TypeScript developers using Jest are still currently running into.

In this post, I'll explain how many of us are not actually mocking properly using Jest, what some of the implications of that are, and how to fix it.

What is a mock?

First of all, what's a mock?

The term "mocking" is often overloaded (we've purposely done that here) to refer to the concept of a subbing in a dependency for a test double, which is an umbrella term for either a "mock" or a "stub".

Test doubles

Fundamentally, we use a mock to stand in for a dependency that we'll issue command-like operations (outgoing interactions or state changes against dependencies) on. And we use stubs to provide data for query-like operations in tests.

A standard use case test

Here's a problematic example of a use case test written using Jest.

import { 
	INotificationService, 
	ITradesRepo, 
	IVinylRepo, 
	MakeOffer 
} from "./makeOffer";

describe('makeOffer', () => {
  describe(`Given a vinyl exists and is available for trade`, () => {
    describe(`When a trader wants to place an offer using money`, () => {
      test(`Then the offer should get created and an 
      email should be sent to the vinyl owner`, async () => {
        
        // Collaborator #1 - Should be a stub object.
        // We have to provide an implementation otherwise
        // we'll get a compilation error.
        let fakeVinylRepo: IVinylRepo = {
          getVinylOwner: jest.fn(async(vinylId: string) => {
            return { id: '4', name: 'Jim' };
          }),
          isVinylAvailableForTrade: jest.fn(async (vinylId: string) => {
            return true;
          }),
        }

        // Collaborator #2 - should be a mock
        // Unfortunately, we also need to provide an implementation of the
        // interface.
        let mockTradesRepo: ITradesRepo = {
          saveOffer: jest.fn(async (offer) => {
            return;
          })
        }

        // Collaborator #3 - should also be a mock object
        // Again, implementation required.
        let mockNotificationService: INotificationService = {
          sendEmail: jest.fn(async (email) => {
            return
          })
        }

        let makeOffer = new MakeOffer(
          fakeVinylRepo, 
          mockTradesRepo,
          mockNotificationService
        );

        let result = await makeOffer.execute({
          vinylId: '123',
          tradeType: 'money',
          amountInCents: 100 * 35
        });

        // We are confirming that the two command-like operations
        // have been called by looking commands invoked on the mocks.
        expect(mockTradesRepo.save).toHaveBeenCalled();
        expect(mockNotificationService.sendEmail).toHaveBeenCalled();
      })
    })
  })
})

Let's discuss the collaborators here.

The first collaborator is the fakeVinylRepo. Because this is used for queries, it's not going to be a mock of any sort. I want this to be a fake (a type of stub).

The second and third collaborators are intended to be used to verify that an "offer was created" and that an "email was sent" as per the test definition. Both of those things are command-like operations that should be changing state in dependencies. That means that we're looking at these things as if they're mocks.

Problems

What's wrong with this?

My mocks are actually stubs

As was pointed out to me by one blog reader, if you need to provide an implementation to your mock, you're not really creating a mock anymore - you're creating a stub. This makes sense if we really think about the definition of a mock and a stub.

I tried removing the implementation from my design, but I found that with Jest, I couldn't do that and keep my code happy and compiling.

let mockTradesRepo: ITradesRepo = jest.fn(); // Error

I could just any type this, but I don't want to. I'm documenting using an interface to help future test readers understand that what is being passed in here is of type IVinylRepo, not just any object. What I needed was the ability to merely specify the interface of a mock object and let the testing framework create the mock for me.

Unfortunately, I've yet to find a way to do this with Jest. It seems like I have to provide an implementation. This is problematic, because as one StackOverflow user commented,

Although it's technically true that a mock just needs to have the same shape as the interface, that misses the whole point. The whole point is to have a convenient way to generate a mock given an interface, so that developers don't have to manually create mock classes just to, say, stub out a single function out of a dozen methods every time you need to run a test.

Brittle test code

The larger issue here is that if we have to provide an implementation for every test double in our test files, every time we go and add a new method to the interface for an adapter, our tests will break until we go back and update all the mocks and stubs in our tests.

interface ITradesRepo {
  getOfferById: (id: string) => Promise<Offer, None>; // New method breaks tests
  saveOffer: (offer: Offer) => Promise<void>;
}

We obviously can't have that.

Mocking and stubbing with nothing but an interface using ts-auto-mock

I've stumbled upon a wonderful library written by the TypeScript-TDD community called ts-auto-mock.

With ts-auto-mock, we avoid the problem of needing to provide an implementation for each mock and stub. We just give it the interface and it fills that out for us.

// makeOffer.spec.ts

import { ITradesRepo, IVinylRepo, MakeOffer } from "./makeOffer";
import { createMock } from 'ts-auto-mock';
import { NotificationsSpy } from "./notificationSpy";

... 

// Don't care about providing implementations for the stubs
// and the compiler won't yell at us either
let fakeVinylRepo = createMock<IVinylRepo>();

// Here's our first mock object
let mockTradesRepo = createMock<ITradesRepo>();

// And our second mock object.
// We've also written this as a spy instead. You'll see why
// in a moment.
let notificationServiceSpy = new NotificationsSpy();

... 

// This compiles! 
let makeOffer = new MakeOffer(
  fakeVinylRepo, 
  mockTradesRepo,
  notificationServiceSpy
);

...

// Our assertions
expect(mockTradesRepo.saveOffer).toHaveBeenCalled();
expect(notificationsSpy.getEmailsSent().length).toEqual(1);

ts-auto-mock provides trivial implementations of all of methods on the interface at runtime, so if within my MakeOffer use case, I was to call any of the methods on the test doubles (mocks and stubs), it wouldn't result in a runtime failure.

// makeOffer.ts

export class MakeOffer {
  constructor(
    private vinylRepo: IVinylRepo,
    private tradesRepo: ITradesRepo,
    private notificationService: INotificationService,
  ) {}
  async execute(request: any) {

    // This is just to demonstrate that none of these methods exist yet, 
    // but we can still call them and verify that they work
    // in our tests! 
    const owner = await this.vinylRepo.getVinylOwner('');
    const available = await this.vinylRepo.isVinylAvailableForTrade('');

    this.tradesRepo.saveOffer({
      vinylId: '123',
      tradeType: 'money',
      amountInCents: 100 * 35,
    });

    this.notificationService.sendEmail({});

  }
}

You'll also notice in the test file that I've written the notificationService as a spy instead. Generally, you use a spy when you want more control as to how you'll verify that the state-changing command was issued on a dependency.

// modules/notifications/mocks/notificationSpy.ts

import { Email, INotificationService } from "./makeOffer";

export class NotificationSpy implements INotificationService {
  private emailsSent: Email[];
  constructor () {
    super();
    this.emailsSent = [];
  }

  public async sendEmail (email: Email): Promise<void> {
    this.emailsSent.push(email);
  }

  getEmailsSent () {
    return this.emailsSent;
  }
}

Because this is a traditional concrete-class-implementing-an-interface, if I add new methods to the INotificationService, I'll have to update it here, probably with a throw new Error('Not yet implemented') statement until I figure out how it should work in the spy.

This could be better because I can maintain this single spy and use it for various tests, but I'm still working out how we can use ts-auto-mock for other use cases like this.

Summary

  • In TypeScript, we're forced to provide an implementation for test doubles in Jest.

    • By definition of mocks and stubs, this means each test double is a stub.
    • It also means our tests and test doubles will be brittle since adding new methods to an interface requires changing the test doubles.
  • Use ts-auto-mock to create pure mock objects using merely an interface

Thank you Vittorio Guerriero!



Discussion

Liked this? Sing it loud and proud 👨‍🎤.



Stay in touch!



About the author

Khalil Stemmler,
Software Essentialist ⚡

I'm Khalil. I turn code-first developers into confident crafters without having to buy, read & digest hundreds of complex programming books. Using Software Essentialism, my philosophy of software design, I coach developers through boredom, impostor syndrome, and a lack of direction to master software design and architecture. Mastery though, is not the end goal. It is merely a step towards your Inward Pull.



View more in Test-Driven Development



You may also enjoy...

A few more related articles

How to Test Code Coupled to APIs or Databases
In the real-world, there's more to test than pure functions and React components. We have entire bodies of code that rely on datab...
When to Use Mocks: Use Case Tests
Mocking gets a pretty bad rap. However, if you're building an application using object-oriented programming and you're making use ...
Introduction to Test-Driven Development (TDD) with Classic TDD Example
The best thing tests give us is "feedback". Feedback as to if our designs are good, if there are bugs, and if we're making progres...
Use DTOs to Enforce a Layer of Indirection | Node.js w/ TypeScript
DTOs help you create a more stable RESTful API; they protect your API clients from changes made on the server.