Alright, let’s talk about mocking. It’s the part of testing everyone loves to hate, but mastering it is what separates you from the amateurs. We mock things for one simple reason: isolation. We want to test our code, not the entire universe of dependencies it happens to call. Your function that processes a user shouldn’t fail because the fetchUser API call decided to take a nap. We replace that real, flaky, or just plain slow module with a predictable stand-in—a mock.

The key thing to remember with TypeScript is that we’re playing two games at once: we have to satisfy the JavaScript runtime (Jest or Vitest) and the TypeScript compiler. One cares about what exists at runtime, the other cares about what exists at type-checking time. Getting them to agree is our mission.

The Basic Spell: jest.mock and vi.mock

Both Jest and Vitest use the same hoisting magic for their mock methods. When you call jest.mock or vi.mock at the top of your test file, the testing framework doesn’t just execute it in place. It hoists it to the very top of the file, before any of your imports. This is crucial. It means the mock is in place before your code under test even imports the module.

Let’s say we have a simple module that fetches a price and applies a discount.

// priceFetcher.ts
export const fetchPrice = async (itemId: string): Promise<number> => {
  // Imagine a real API call here
  return 42;
};

// priceCalculator.ts
import { fetchPrice } from './priceFetcher';

export const calculateFinalPrice = async (itemId: string, discount: number): Promise<number> => {
  const basePrice = await fetchPrice(itemId);
  return basePrice * (1 - discount);
};

To test calculateFinalPrice without actually calling the real fetchPrice, we mock it.

// priceCalculator.test.ts
import { calculateFinalPrice } from './priceCalculator';
import { fetchPrice } from './priceFetcher';

// This gets hoisted above the imports. Magic!
jest.mock('./priceFetcher'); // For Vitest: vi.mock('./priceFetcher');

// The mock function is now available for us to configure
const mockedFetchPrice = fetchPrice as jest.MockedFunction<typeof fetchPrice>;

test('calculateFinalPrice applies discount correctly', async () => {
  // Arrange
  mockedFetchPrice.mockResolvedValue(100); // Mock the promise to resolve to 100

  // Act
  const result = await calculateFinalPrice('test-id', 0.2); // 20% discount

  // Assert
  expect(result).toBe(80);
  expect(mockedFetchPrice).toHaveBeenCalledWith('test-id');
});

The beauty here is that our import import { fetchPrice } from './priceFetcher'; is not importing the real function. It’s importing the mock function created by jest.mock. This is why we can cast it to jest.MockedFunction and use .mockResolvedValue() on it.

Keeping TypeScript Happy: The Two-Schools Problem

See that cast we did? as jest.MockedFunction<...>? That’s us telling TypeScript, “I know you think this imported function is the real one, but trust me, the test runtime has replaced it with a mock that has all these Jest properties.” It’s a bit of a type safety lie, but it’s a necessary one.

There are two main schools of thought here, and honestly, the Vitest docs are a bit cagey about which one they prefer.

School 1: The Cast (Shown Above) This is quick, explicit, and very common in the Jest world. The downside is you’re casting away the original type, which is a minor code smell.

School 2: Using vi.mocked() (Vitest) or jest.mocked() (Jest ^27.0.0) Vitest provides a helper that does the cast for you and provides better type inference.

import { calculateFinalPrice } from './priceCalculator';
import { fetchPrice } from './priceFetcher';
import { vi } from 'vitest';

vi.mock('./priceFetcher');

// Use the vi.mocked() wrapper - it's now a typed mock function!
const mockedFetchPrice = vi.mocked(fetchPrice);

test('calculateFinalPrice applies discount correctly', async () => {
  mockedFetchPrice.mockResolvedValue(100);
  // ... rest of the test
});

I prefer School 2. It feels less hacky and is officially blessed. If you’re on a newer version of Jest, use jest.mocked().

Mocking Modules with Complex Exports

What if your module doesn’t export a single function? What if it exports an object, a default export, or a class? The hoisted mock call can handle it all by accepting a factory function.

// logger.ts
export const logger = {
  info: (msg: string) => console.log(`INFO: ${msg}`),
  error: (msg: string) => console.error(`ERROR: ${msg}`),
};

// codeThatLogs.ts
import { logger } from './logger';

export const doSomethingImportant = () => {
  logger.info('Starting important process...');
  // ... do work
  logger.error('Something went wrong!');
};

To mock this entire object:

// codeThatLogs.test.ts
import { doSomethingImportant } from './codeThatLogs';
import { logger } from './logger';
import { vi } from 'vitest';

// The factory function returns what the mock should be
vi.mock('./logger', () => ({
  logger: {
    info: vi.fn(),
    error: vi.fn(),
  },
}));

// Now TypeScript knows logger.info is a mock function
test('doSomethingImportant logs correctly', () => {
  doSomethingImportant();

  expect(vi.mocked(logger.info)).toHaveBeenCalledWith('Starting important process...');
  expect(vi.mocked(logger.error)).toHaveBeenCalledWith('Something went wrong!');
});

This is where you see the power. The factory function gives you complete control to reconstruct the module’s exports however you see fit. You can mock some functions and leave others as the real implementation (though I’d question your design if you need to), or return completely different values.

The Sharpest Edge: Dealing with Default Exports

This is, historically, a nightmare. The syntax is just… awkward. You have to remember that a default export is often just a property called default on the module.exports object.

// apiClient.ts - A default export
export default {
  post: (url: string, data: any) => Promise.resolve({ data }),
};

// userService.ts
import apiClient from './apiClient';

export const createUser = (name: string) => {
  return apiClient.post('/users', { name });
};

Mocking this requires you to return a default key from your factory function.

// userService.test.ts
import { createUser } from './userService';
import apiClient from './apiClient';
import { vi } from 'vitest';

// Pay attention to the 'default' key. This is the awkward part.
vi.mock('./apiClient', () => ({
  default: {
    post: vi.fn(() => Promise.resolve({ data: { id: '123', name: 'Test User' } })),
  },
}));

test('createUser calls the correct endpoint', async () => {
  await createUser('New User');
  expect(vi.mocked(apiClient.post)).toHaveBeenCalledWith('/users', { name: 'New User' });
});

This syntax is the number one cause of “why is my mock undefined?!” errors. You just have to internalize it: default exports are mocked using default: { ... }.

Best Practices: Don’t Go Overboard

Your mock should be the simplest possible thing that makes the test work. Don’t mock the entire world if you don’t have to.

  1. Mock at the boundary: Mock modules that do I/O (HTTP calls, file system, database). Don’t mock your own utility functions that are pure logic.
  2. Use mockImplementationOnce for sequencing: If you need a function to return different values on subsequent calls, mockResolvedValueOnce is your friend. It keeps the test contained.
  3. Consider jest.spyOn / vi.spyOn for partial mocks: If you only need to mock one function in a module but leave the rest intact, a spy can be a cleaner choice. Just remember you’re importing the real module first.
  4. Reset your mocks: Use beforeEach(() => { vi.clearAllMocks(); }) to avoid test pollution. This clears call history and implementations, but doesn’t remove the mock itself.

Mocking is a superpower. Used wisely, it lets you write fast, reliable, and focused unit tests. Used poorly, it creates a brittle parallel universe that lies about how your code actually works. Now go isolate something.