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 👨‍🎤.


6 Comments

Submit
pjotr
4 months ago

i understand these descriptions, but i'm not sure whether these are the common meanings for these terms. the biggest testing libraries affect the developers' minds and the testing nomenclature is just so confusing.

i thought that

a) stub - is when you just want to replace a single method (i'm biased by sinon as it's the first stubbing library that I used). in jest we use 'spyOn' for this so there's already a clash

b) mock - is when we provide alternative implementations (with empty function as a default) for the whole module

c) spy - we call the real implementation, but we can assert on what it's been called with, the return value (if this function is a part of a different, bigger function) etc.


nonetheless, it's good to read and explore it!

Anthony
3 months ago

I'm not sure if understand all the blog when the examples are too complex. Can you maybe dumb them down a little bit. I feel that I need to know the implementation to fully understand what you mean in this post.

Anthony P.
3 months ago

Another note Khalil. Mocking should be rarely done based as Kent Beck mentioned. Right now you are testing implementation, you should be testing behavior.

Ishan
2 months ago

Nice article

Pavel
2 months ago

I trying figure out how can i verify in jest that none methodes was called.

like in java mockito verifyZeroInteraction(object)

Miloš
a month ago

Hey, what's the difference between using this and the jest.mock() function and passing it a module path?


Stay in touch!



About the author

Khalil Stemmler,
Developer Advocate @ Apollo GraphQL ⚡

Khalil is a software developer, writer, and musician. He frequently publishes articles about Domain-Driven Design, software design and Advanced TypeScript & Node.js best practices for large-scale applications.



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.

Want to be notified when new content comes out?

Join 10000+ other developers learning about Domain-Driven Design and Enterprise Node.js.

I won't spam ya. 🖖 Unsubscribe anytime.

Get updates