How to Mock without Providing an Implementation in TypeScript
🌱 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:
- When exactly should you mock?
- When should you not mock?
- How do you do mocking properly?
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".
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
- For an example, with Jest see jest-ts-auto-mock
Thank you Vittorio Guerriero!
Stay in touch!
Join 20000+ value-creating Software Essentialists getting actionable advice on how to master what matters each week. 🖖
View more in Test-Driven Development