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


9 Comments

Commenting has been disabled for now. To ask questions and discuss this post, join the community.

pjotr
a year 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
a year 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.
a year 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
a year ago

Nice article

Pavel
a year ago

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

like in java mockito verifyZeroInteraction(object)

Miloš
a year ago

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

Connor
a year ago

Wouldn't it be possible to bypass this dependency by mocking like this:


jest.mock('../../repo') // Creates a mock of the entire class (as long as it's the default export)
const mockRepo: IRepo = new (Repo as any)(); // Repo is an "any" type so we don't have to inject a dependency

Since we're building the mock from the imlementation rather than the interface, could also more honestly type it as:
const mockRepo: Repo = new (Repo as any)();

Now we've got a class (or interface) typed mockRepo, which actually gets all it's methods from the implementation of the original Repo class, and will adjust itself if we adjust that class (no brittle implementation) and no external dependencies beyond Jest

deniz
10 months ago

Nice article Khalil, thank you.

Kerry
6 months ago

Interesting article.

I felt that there would be some logic around the owner and available variables that you'd want to unit test. If that is the case then the humble object pattern would be useful here and allow you to test that logic in isolation of side effects. However, some people may not like that as it reduces the cohesion of your code which is why it's recommended only for complex logic.


Also, imho checking that a method was called does not verify that it was called with the right parameters. You can do that of course, check the parameters, but I think rather than mocking, you would be better off using integration testing to verify the parts of your application that involve side effects such as updating the database and sending an email. The database would preferrable be a live one (maybe containerized) since it is part of your application. For the mail service, assuming that you don't own it, you could mock using Wiremock, Mocks Server NodeJS, or even Mock Service Worker. The integration tests generally have a higher chance of catching a regression bug, avoid brittleness by testing behavior over implementation details, and are relatively easy to maintain. Use unit testing for the pure parts of your application so that you get that fast feedback and confidence in your business logic.



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, imposter 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.

Want to be notified when new content comes out?

Join 15000+ other Software Essentialists learning how to master The Essentials of software design and architecture.

Get updates