Skip to Content
Course content

218: Type-Safe Mocking Patterns

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Imagine you're directing a big action movie. You have a lead actor who is great at delivering lines but probably shouldn't be jumping off a building. So, you hire a stunt double. For the scene to work, that double doesn't just need to "look" like the actor; they need to be able to perform every specific action the script requires. If the script says the character needs to slide across a hood of a car while firing a prop gun, and your stunt double can't handle a gun, the whole production grinds to a halt.

In TypeScript, your interfaces are the script. Your real services are the lead actors, and your mocks are the stunt doubles. The biggest mistake I see developers make is hiring a "stunt double" that is just a random object cast as the interface. They tell the compiler, "Trust me, this object is a PaymentGateway," even though it's missing half the methods. That's like hiring a stunt double who doesn't know how to jump and just hoping the camera doesn't catch it. When the "script" (your interface) changes, your mocks don't break—they just silently fail at runtime, and you're left hunting for a TypeError: x is not a function in your test suite.

Stop Lying to Your Compiler

Usually, when people are in a rush, they do something like this:

const mockGateway = {
  processPayment: () => Promise.resolve({ success: true })
} as unknown as PaymentGateway;

I'll be honest: this is a trap. By using as unknown as T, you've effectively turned off TypeScript for this object. If you add a refundPayment method to the PaymentGateway interface tomorrow, this mock won't complain. Your tests will pass, but your code will crash in production. You've lied to the compiler, and the compiler can no longer protect you.

Building a Faithful Stunt Double

The right way to do this is to actually implement the interface. I know it feels like more boilerplate, but it's the only way to ensure your mocks evolve alongside your production code. If the interface changes, the mock breaks immediately at compile time. That's exactly what we want.

interface PaymentGateway {
  processPayment(amount: number, currency: string): Promise<PaymentResult>;
  getBalance(): Promise<number>;
}

class MockPaymentGateway implements PaymentGateway {
  // We can use these to track calls in our tests
  public processPaymentCalledWith: [number, string][] = [];

  async processPayment(amount: number, currency: string): Promise<PaymentResult> {
    this.processPaymentCalledWith.push([amount, currency]);
    return { success: true, transactionId: 'mock-123' };
  }

  async getBalance(): Promise<number> {
    return 100.00;
  }
}

Now, the MockPaymentGateway is tethered to the PaymentGateway contract. If I add a new method to the interface, TypeScript will scream at me until I update the mock. This is the "type-safe" part of mocking.

Handling the "I Only Need One Method" Problem

Sometimes you're testing a small function that only uses one method of a massive interface. Implementing 20 methods just to test one is a slog. In these cases, I use a factory function combined with Partial<T>, but with a careful twist to maintain safety.

Instead of casting the whole object, create a helper that lets you specify only what you need, while still acknowledging the rest is missing. Or, better yet, use a mock library's built-in type helpers (like jest.Mocked<T>), but always ensure the base is derived from the actual interface.

// A helper to create a partial mock that is still typed
function createPaymentMock(overrides: Partial<PaymentGateway>): PaymentGateway {
  return {
    processPayment: async () => ({ success: false, transactionId: '' }),
    getBalance: async () => 0,
    ...overrides,
  } as PaymentGateway; 
  // This cast is safer because the factory provides defaults for all required methods
}

const myMock = createPaymentMock({
  getBalance: async () => 500,
});

By providing defaults in the factory, you ensure that the object returned is a complete PaymentGateway, but you give yourself the flexibility to override only the behavior that matters for your current test case.




📋 Practical Task

Implementing a Type-Safe UserAnalytics Mock

You are working on a system that tracks user behavior. You have the following interface:

interface UserAnalytics {
  trackEvent(eventName: string, properties: Record<string, any>): void;
  identifyUser(userId: string, traits: Record<string, any>): void;
  setUserName(name: string): void;
}

Your task is to create a MockUserAnalytics class that implements this interface. However, simply implementing the methods isn't enough for a good test—you need to be able to verify that the methods were called.

  • Create a class MockUserAnalytics that implements the UserAnalytics interface.
  • Add a public property events: string[] to the class that stores the eventName every time trackEvent is called.
  • Ensure that if a new method is added to the UserAnalytics interface, your mock class will cause a TypeScript compilation error.
  • Instantiate the mock and call trackEvent('button_click', { color: 'red' }), then verify that the events array contains 'button_click'.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.