Advanced TypeScript Mocking: jest.fn() with Generics, ts-mockito, and Partial Mocks

Advanced TypeScript Mocking: jest.fn() with Generics, ts-mockito, and Partial Mocks

TypeScript mocking has one goal: replace real dependencies with test doubles that are fully type-checked. The toolbox includes jest.fn() with generic parameters, jest.Mocked for class/interface mocks, jest.spyOn for partial mocks, and ts-mockito for fluent mock setup. This guide covers each pattern with realistic examples and explains when to use which approach.

Key Takeaways

jest.fn<ReturnType, [Args]>() prevents type mismatches in mock return values. Without generics, mockReturnValue accepts anything. With generics, the compiler enforces the return type contract.

jest.Mocked<T> is the cleanest way to type a mocked class or interface. It converts all methods to jest.MockedFunction, making .mockResolvedValue(), .mockReturnValue(), and .mockImplementation() available with type checking.

Partial mocks via jest.spyOn are safer for stateful services. Instead of mocking the entire module, spy on only the method you need to control. The rest of the class/module behaves normally.

Factory functions are better than inline mocks. A createMockEmailService() function is reusable, self-documenting, and keeps test files clean. Inline jest.fn() calls scattered across tests are hard to maintain.

ts-mockito's when().thenReturn() pattern improves readability. For complex mock setups with argument matchers, ts-mockito reads more clearly than stacked mockReturnValue calls.

The Problem with Untyped Mocks

In JavaScript, you can write:

const mockService = {
  getUser: jest.fn().mockReturnValue({ id: 1, name: 'Alice' }),
};

This works, but TypeScript won't catch mistakes. If getUser should return Promise<User> but the mock returns User (missing the Promise), tests may pass while production code breaks.

The goal of typed mocking is to make the compiler catch these mismatches at write time.

jest.fn() with Generic Parameters

The basic pattern:

// Without generics (unsafe)
const mockFetch = jest.fn();
mockFetch.mockResolvedValue('not a User object');  // no error

// With generics (safe)
const mockFetch = jest.fn<() => Promise<User>>();
mockFetch.mockResolvedValue('not a User object');  // TS error: Type 'string' is not assignable to type 'User'

For functions with arguments:

interface User { id: string; name: string; email: string; }

// Single argument
const mockGetUser = jest.fn<(id: string) => Promise<User>>();
mockGetUser.mockResolvedValue({ id: '1', name: 'Alice', email: 'alice@example.com' });

// Multiple arguments
const mockCreateUser = jest.fn<(name: string, email: string) => Promise<User>>();
mockCreateUser.mockResolvedValue({ id: '2', name: 'Bob', email: 'bob@example.com' });

jest.Mocked<T> for Classes

When you mock an entire class, jest.Mocked<T> gives you a fully typed mock version:

// email-service.ts
export class EmailService {
  async send(to: string, subject: string, body: string): Promise<{ messageId: string }> {
    // real implementation
  }
  async sendBulk(recipients: string[], subject: string, body: string): Promise<void> {
    // real implementation
  }
  async getStatus(messageId: string): Promise<'sent' | 'failed' | 'pending'> {
    // real implementation
  }
}
// notification.test.ts
import { EmailService } from './email-service';
import { NotificationService } from './notification-service';

jest.mock('./email-service');  // auto-mock at module level

describe('NotificationService', () => {
  let emailService: jest.Mocked<EmailService>;
  let notificationService: NotificationService;

  beforeEach(() => {
    // Cast to jest.Mocked<T> after jest.mock() replaced the class
    emailService = new EmailService() as jest.Mocked<EmailService>;
    notificationService = new NotificationService(emailService);
  });

  it('sends a welcome email on user registration', async () => {
    emailService.send.mockResolvedValue({ messageId: 'msg_123' });

    await notificationService.onUserRegistered({
      email: 'alice@example.com',
      name: 'Alice',
    });

    expect(emailService.send).toHaveBeenCalledWith(
      'alice@example.com',
      'Welcome to our platform',
      expect.stringContaining('Alice')
    );
  });

  it('throws if email service fails', async () => {
    emailService.send.mockRejectedValue(new Error('SMTP connection refused'));

    await expect(
      notificationService.onUserRegistered({ email: 'alice@example.com', name: 'Alice' })
    ).rejects.toThrow('SMTP connection refused');
  });
});

Mock Factory Functions

Inline mock creation inside beforeEach gets repetitive. Factory functions are cleaner:

// test/mocks/email-service.mock.ts
import type { EmailService } from '../../email-service';

export function createMockEmailService(
  overrides: Partial<jest.Mocked<EmailService>> = {}
): jest.Mocked<EmailService> {
  return {
    send: jest.fn().mockResolvedValue({ messageId: 'msg_default' }),
    sendBulk: jest.fn().mockResolvedValue(undefined),
    getStatus: jest.fn().mockResolvedValue('sent'),
    ...overrides,
  } as jest.Mocked<EmailService>;
}

Usage:

it('handles bulk send failure', async () => {
  const emailService = createMockEmailService({
    sendBulk: jest.fn().mockRejectedValue(new Error('Rate limit exceeded')),
  });

  const service = new NotificationService(emailService);
  await expect(service.sendNewsletter(['a@b.com', 'c@d.com'])).rejects.toThrow(
    'Rate limit exceeded'
  );
});

This pattern is especially useful in complex test files where different tests need slightly different mock behaviors.

Mocking Interfaces

TypeScript interfaces have no runtime representation — you can't jest.mock() them. Use factory functions instead:

// interfaces/repository.ts
export interface IUserRepository {
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  save(user: User): Promise<User>;
  delete(id: string): Promise<void>;
}
// test/mocks/user-repository.mock.ts
import type { IUserRepository } from '../../interfaces/repository';

export function createMockUserRepository(
  overrides: Partial<jest.Mocked<IUserRepository>> = {}
): jest.Mocked<IUserRepository> {
  return {
    findById: jest.fn().mockResolvedValue(null),
    findByEmail: jest.fn().mockResolvedValue(null),
    save: jest.fn().mockImplementation(async (user) => user),
    delete: jest.fn().mockResolvedValue(undefined),
    ...overrides,
  };
}

Partial Mocks with jest.spyOn

When you don't want to mock an entire module — just one method:

import * as userUtils from './user-utils';

describe('generateUsername', () => {
  it('falls back to ID when name is too short', () => {
    const spy = jest.spyOn(userUtils, 'sanitizeName').mockReturnValue('');

    const username = userUtils.generateUsername({ id: 'abc123', name: 'Al' });
    
    expect(username).toBe('user_abc123');
    expect(spy).toHaveBeenCalledWith('Al');
    
    spy.mockRestore();  // always restore!
  });
});

Use afterEach(() => jest.restoreAllMocks()) in the describe block to avoid manual restore calls:

describe('generateUsername', () => {
  afterEach(() => jest.restoreAllMocks());

  it('falls back to ID when name is too short', () => {
    jest.spyOn(userUtils, 'sanitizeName').mockReturnValue('');
    const username = userUtils.generateUsername({ id: 'abc123', name: 'Al' });
    expect(username).toBe('user_abc123');
  });
});

ts-mockito

ts-mockito provides a fluent API inspired by Java's Mockito. It's especially useful for complex argument matchers:

npm install --save-dev ts-mockito
import { mock, when, verify, anything, deepEqual, instance } from 'ts-mockito';
import { UserService } from './user-service';

describe('UserService with ts-mockito', () => {
  it('returns user for valid ID', async () => {
    const MockedUserService = mock(UserService);

    when(MockedUserService.getById('user-1'))
      .thenResolve({ id: 'user-1', name: 'Alice', email: 'alice@example.com' });

    when(MockedUserService.getById(anything()))
      .thenReject(new Error('User not found'));

    const service = instance(MockedUserService);

    const user = await service.getById('user-1');
    expect(user.name).toBe('Alice');

    await expect(service.getById('nonexistent')).rejects.toThrow('User not found');

    verify(MockedUserService.getById('user-1')).once();
  });
});

ts-mockito shines when you need to:

  • Assert call order with verify(...).calledBefore(...)
  • Match complex arguments with deepEqual() or anyString()
  • Set up conditional behavior based on specific argument values

Mocking Modules with Implementation

For modules that export functions (not classes):

// utils/date.ts
export function formatDate(date: Date): string { /* ... */ }
export function parseDate(str: string): Date { /* ... */ }
export function isExpired(date: Date): boolean { /* ... */ }
jest.mock('./utils/date', () => ({
  formatDate: jest.fn((d: Date) => d.toISOString()),
  parseDate: jest.fn((s: string) => new Date(s)),
  isExpired: jest.fn().mockReturnValue(false),
}));

import * as dateUtils from './utils/date';
const mockDateUtils = dateUtils as jest.Mocked<typeof dateUtils>;

it('formats the expiry date in ISO format', () => {
  const date = new Date('2026-12-31');
  mockDateUtils.formatDate.mockReturnValue('December 31, 2026');

  const label = getExpiryLabel(date);
  expect(label).toBe('Expires: December 31, 2026');
});

Async Mock Patterns

Common async mock variations:

// Returns resolved value
mock.fetchUser.mockResolvedValue({ id: '1', name: 'Alice' });

// Returns rejected value
mock.fetchUser.mockRejectedValue(new Error('Network error'));

// Returns different values on successive calls
mock.fetchUser
  .mockResolvedValueOnce({ id: '1', name: 'Alice' })
  .mockResolvedValueOnce({ id: '2', name: 'Bob' })
  .mockRejectedValueOnce(new Error('No more users'));

// Dynamic based on argument
mock.fetchUser.mockImplementation(async (id: string) => {
  if (id === 'admin') return { id, name: 'Admin', role: 'admin' };
  if (id === 'guest') return { id, name: 'Guest', role: 'viewer' };
  throw new Error(`User ${id} not found`);
});

When to Use Which Pattern

Scenario Best pattern
Mocking a class jest.mock() + jest.Mocked<T> cast
Mocking an interface Factory function returning jest.Mocked<T>
Partially mocking a module jest.spyOn()
Complex argument matching ts-mockito
Reusable mocks across files Factory function in test/mocks/
One-off mock in a single test Inline jest.fn<ReturnType, Args>()

Beyond Mocks

Mocking is a test isolation tool. It replaces real dependencies to make tests deterministic. But over-mocking creates tests that pass when the real system is broken — because nothing is real.

Balance unit tests (with mocks) against integration tests (with real dependencies) and production monitoring. HelpMeTest handles the production side: automated tests against your live application, 24/7, alerting you to real failures before users notice.

Read more