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
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-mockitoimport { 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()oranyString() - 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.