
Jun 12, 2024
Mastering Time: Using Fake Timers with Vitest
Level Up Your Timers Tests With Speed and Isolation
Master Vitest mocking! Learn advanced techniques beyond vi.mock() for robust, reliable JavaScript/TypeScript tests. SpyOn, vi.fn, and more!

Imagine you're building a website for the Trade Federation, those folks from Star Wars. They're selling goods to both the Empire and the Rebels. Now, their website has a complex system for processing orders. It talks to a database, sends emails, and even checks stock levels with a remote API.
Here's the problem: when you want to test just one part of this system, say, the order processing logic, you don't want to rely on the database, the email service, or the external API. Why?
That's where mocking comes in. Mocking means creating fake versions of these external dependencies. We replace the real database with a fake one, the real email service with a fake one, and so on. This isolates the part of your code you're testing, so you can focus on it without distractions.
Example:
// OrderProcessor.ts
import { Database } from './database';
import { EmailService } from './emailService';
import { StockAPI } from './stockAPI';
export class OrderProcessor {
constructor(
private database: Database,
private emailService: EmailService,
private stockAPI: StockAPI
) {}
async processOrder(order: any) {
await this.stockAPI.checkStock(order.productId);
await this.database.saveOrder(order);
await this.emailService.sendConfirmation(order.customerEmail);
return 'Order processed';
}
}When testing OrderProcessor, we don't want to rely on real Database, EmailService, or StockAPI. We'll mock them!
Mocking is powerful, but it's easy to go overboard. If you mock everything, your tests might not reflect how your code works in the real world.
Imagine you mock the entire order processing system, including the business logic. Your tests might pass, but if there's a bug in the real logic, you won't catch it.
It's about finding the right balance. Mock external dependencies, but don't mock the core logic of the unit you're testing unless it is absolutely necessary.
Think of it this way: you want to test if the Trade Federation's order processor works correctly. But you don't want to test if the entire galaxy is working correctly.
Example:
If you mock the processOrder function itself, you will not test the logic inside of it, and the test is useless.
// OrderProcessor.test.ts
// Don't do this (unless you have a very good reason!)
vi.mock('./OrderProcessor', () => ({
OrderProcessor: vi.fn().mockImplementation(() => ({
processOrder: vi.fn().mockResolvedValue('Mocked order processed'),
})),
}));
In the world of testing, a "fixture" is like a stage set. It's a way to create a consistent starting point for your tests.
Imagine you're testing the Trade Federation's inventory system. You need to create some sample products to test with. Instead of creating these products in every test, you can create a fixture.
Example: Object Fixture
// __fixtures__/product.fixtures.ts
export const productFixture = {
productId: '123',
name: 'Droid Parts',
price: 100,
};Then, in your test:
// discount.test.ts
import { expect, test } from 'vitest';
import { productFixture } from './__fixtures__/product.fixtures';
import { applyDiscount } from './discount';
test('should apply a discount to a product price', () => {
// Arrange
const discountedPrice = applyDiscount(productFixture.price, 20);
// Assert
expect(discountedPrice).toBe(80);
});Example: Function Fixture
You can also use functions to create more complex fixtures:
// __fixtures__/order.fixtures.ts
export const createOrderFixture = (productId: string, quantity: number) => ({
orderId: 'order-66',
productId,
quantity,
customerEmail: 'rebel@alliance.com',
});And in your test:
// order.test.ts
import { expect, test } from 'vitest';
import { createOrderFixture } from './__fixtures__/order.fixtures.ts';
import { productFixture } from './__fixtures__/product.fixtures.ts';
import { calculateTotal } from './order.ts';
test('should calculate the total price of an order', () => {
// Arrange
const order = createOrderFixture('123', 5);
const total = calculateTotal(order.quantity, productFixture.price);
// Assert
expect(total).toBe(500);
});Using fixtures makes your tests:
Imagine you need to keep tabs on a function, but you don't want to stop it from doing its job. That's the beauty of vi.spyOn(). It's like placing a wiretap—you're listening in, but the conversation keeps going. You can see what a function is doing, how many times it's called, and what arguments it receives, all while letting it execute its actual logic.
Let's say the Trade Federation has a ShipmentProcessor class:
// shipment.ts
export class ShipmentProcessor {
sendShipment(orderId: string, address: string) {
console.log(`Sending shipment ${orderId} to ${address}`);
// ... some real shipping logic ...
return `Shipment ${orderId} sent`;
}
}Here's how you'd spy on the sendShipment method:
// shipment.test.ts
import { vi, expect, test } from 'vitest';
import { ShipmentProcessor } from './shipment';
test('should call sendShipment with correct arguments and return the correct value', () => {
// Arrange
const processor = new ShipmentProcessor();
const spy = vi.spyOn(processor, 'sendShipment');
const orderId = 'order-66';
const address = 'Coruscant';
// Act
const result = processor.sendShipment(orderId, address);
// Assert
expect(spy).toHaveBeenCalledWith(orderId, address);
expect(spy).toHaveBeenCalledTimes(1);
expect(result).toBe(`Shipment ${orderId} sent`); // Check the returned value
spy.mockRestore(); // Clean up the spy!
});Notice how we're also checking the returned value of sendShipment. This proves that the actual function logic is executed. Remember, vi.spyOn() lets the function run its course while you observe.
Now, sometimes you need to completely replace a function with a fake one. That's where vi.fn() comes in. It's like building a robot that mimics a real person. You get to decide exactly what the function returns, what errors it throws, or what logic it runs.
But here's the catch: you need to be careful. If your fake function doesn't behave similarly to the real one, your tests might not be relevant. You need to mimic the real function's behavior to some extent.
Let's look at an example with a database:
// database.ts
export class Database {
async saveOrder(data: any) {
// ... real database logic ...
return 'Data saved';
}
}// OrderProcessor.ts
import { Database } from './database';
import { EmailService } from './emailService';
import { StockAPI } from './stockAPI';
export class OrderProcessor {
constructor(
private database: Database,
private emailService: EmailService,
private stockAPI: StockAPI
) {}
async processOrder(order: any) {
await this.stockAPI.checkStock(order.productId);
await this.database.saveOrder(order);
await this.emailService.sendConfirmation(order.customerEmail);
return 'Order processed';
}
}// orderProcessor.test.ts
import { createOrderFixture } from './__fixtures__/order.fixtures';
import { OrderProcessor } from './orderProcessor';
import { vi, expect, test } from 'vitest';
test('should save order to database', async () => {
// Arrange
const mockDatabase = { saveOrder: vi.fn() };
const mockEmailService = { sendConfirmation: vi.fn() };
const mockStockAPI = { checkStock: vi.fn() };
const processor = new OrderProcessor(mockDatabase, mockEmailService, mockStockAPI);
const order = createOrderFixture('123', 5);
// Act
await processor.processOrder(order);
// Assert
expect(mockDatabase.saveOrder).toHaveBeenCalledWith(order);
});
test('should use mockImplementationOnce to control the return value of a function for a single call', () => {
// Arrange
const mockFunction = vi.fn();
mockFunction.mockImplementationOnce(()=> 'first call');
mockFunction.mockImplementationOnce(()=> 'second call');
// Act / Assert
expect(mockFunction()).toBe('first call');
expect(mockFunction()).toBe('second call');
expect(mockFunction()).toBe(undefined);
})
test('should use mockReturnValueOnce to control the return value of a function for a single call', () => {
// Arrange
const mockFunction = vi.fn();
mockFunction.mockReturnValueOnce('first call');
mockFunction.mockReturnValueOnce('second call');
// Act / Assert
expect(mockFunction()).toBe('first call');
expect(mockFunction()).toBe('second call');
expect(mockFunction()).toBe(undefined);
})
When vi.mock() and vi.fn() aren't enough, you need to create a completely custom mock for a module. This is where manual mocks come in, and you can even use vi.fn() within them.
Let's say you have a module that reads data from a file:
// fileReader.ts
import * as fs from 'fs';
export function readData(filePath: string) {
return fs.readFileSync(filePath, 'utf-8');
}Create a __mocks__ directory and a fileReader.ts file inside:
// __mocks__/fileReader.ts
import { vi } from 'vitest';
export const readData = vi.fn((filePath: string) => {
if (filePath === 'test.txt') {
return 'Mocked data';
}
return '';
});Now, in your test:
// fileReader.test.ts
import { readData } from './fileReader';
import { vi, expect, test } from 'vitest';
// This line will automatically replace every import of 'fileReader' with the mocked version
vi.mock('./fileReader');
test('should use mocked file data', () => {
// Arrange / Act
const data = readData('test.txt');
// Assert
expect(data).toBe('Mocked data');
expect(readData).toHaveBeenCalledWith('test.txt');
expect(readData).toHaveBeenCalledTimes(1);
});Using vi.fn() in your manual mock gives you the ability to check call counts and arguments, adding even more control and precision to your tests.
In the world of JavaScript, asynchronous code is everywhere. Dealing with promises and async/await in tests can be tricky, but Vitest provides powerful tools to make it easier.
Imagine you have a function that fetches data from an API:
// apiService.ts
export class ApiService {
async fetchData(url: string) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
}Here's how you'd mock it:
// apiService.test.ts
import { ApiService } from './apiService';
import { vi, expect, test } from 'vitest';
test('should resolve with data', async () => {
// Arrange
const apiService = new ApiService();
const fetchDataSpy = vi.spyOn(apiService, 'fetchData');
fetchDataSpy.mockResolvedValue({ message: 'Data received' });
// Act
const data = await apiService.fetchData('https://example.com/data');
// Assert
expect(data).toEqual({ message: 'Data received' });
expect(fetchDataSpy).toHaveBeenCalledWith('https://example.com/data');
});
test('should reject with an error', async () => {
// Arrange
const apiService = new ApiService();
const fetchDataSpy = vi.spyOn(apiService, 'fetchData');
fetchDataSpy.mockRejectedValue(new Error('Network error'));
// Act / Assert
await expect(apiService.fetchData('https://example.com/data')).rejects.toThrow('Network error');
expect(fetchDataSpy).toHaveBeenCalledWith('https://example.com/data');
});
For more complex scenarios, you can use mockImplementation with an async function:
test('should handle complex async logic', async () => {
// Arrange
const apiService = new ApiService();
const mockFetchData = vi.spyOn(apiService, 'fetchData').mockImplementation(async (url: string) => {
if (url === 'https://example.com/special') {
return { special: true };
}
return { normal: true };
});
// Act
const specialData = await apiService.fetchData('https://example.com/special');
const normalData = await apiService.fetchData('https://example.com/normal');
// Assert
expect(specialData).toEqual({ special: true });
expect(normalData).toEqual({ normal: true });
mockFetchData.mockRestore();
});
When your code interacts with external APIs, you need to mock those calls to keep your tests fast and reliable.
As shown in the previous examples, mocking fetch is a common way to handle API calls. You can control the responses and simulate different scenarios.
For more complex API mocking, consider using MSW. It lets you intercept network requests at the network level, providing a realistic mocking experience.
// __msw__/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('https://api.example.com/products', () => {
return HttpResponse.json([{ id: 1, name: 'Droid Parts' }]);
}),
http.post('https://api.example.com/orders', () => {
return HttpResponse.json({ orderId: 'order-66' });
}),
];// api.test.ts
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll, expect, test } from 'vitest';
import { handlers } from './__msw__/handlers';
import { fetchProducts, createOrder } from './api.ts';
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('should fetch products', async () => {
const products = await fetchProducts();
expect(products).toEqual([{ id: 1, name: 'Droid Parts' }]);
});
test('should create an order', async () => {
const order = await createOrder({ productId: 1, quantity: 2 });
expect(order).toEqual({ orderId: 'order-66' });
});
Sometimes, your code might run in a Node.js environment but still rely on browser-specific APIs like window or localStorage. This is common in frameworks that support server-side rendering or when you're using libraries that make use of browser globals. In these cases, you need to mock those APIs to ensure your tests run correctly in Node.js.
vi.stubGlobal() lets you replace global objects with mock values. This is useful for simple mocks. Let's create a Star Wars example:
// planetStorage.ts
export function saveLastVisitedPlanet(planet: string) {
localStorage.setItem('lastVisitedPlanet', planet);
}
export function getLastVisitedPlanet() {
return localStorage.getItem('lastVisitedPlanet');
}// planetStorage.test.ts
import { saveLastVisitedPlanet, getLastVisitedPlanet } from './planetStorage';
import { vi, expect, test } from 'vitest';
test('should mock localStorage for planet storage', () => {
// Arrange
const mockLocalStorage = {
store: {},
setItem: vi.fn((key, value) => (mockLocalStorage.store[key] = value)),
getItem: vi.fn((key) => mockLocalStorage.store[key]),
};
vi.stubGlobal('localStorage', mockLocalStorage);
// Act
saveLastVisitedPlanet('Tatooine');
const lastPlanet = getLastVisitedPlanet();
// Assert
expect(mockLocalStorage.setItem).toHaveBeenCalledWith('lastVisitedPlanet', 'Tatooine');
expect(lastPlanet).toBe('Tatooine');
});
For more complex browser environments, use jsdom. It creates a simulated browser environment in Node.js, allowing you to test code that relies on the document object or other browser APIs.
// documentUtil.ts
export function getTitle() {
return document.title;
}// documentUtil.test.ts
import { expect, test, beforeAll } from 'vitest';
import { JSDOM } from 'jsdom';
import { getTitle } from './documentUtil';
beforeAll(() => {
const dom = new JSDOM('<!DOCTYPE html><title>Trade Federation Page</title>');
global.document = dom.window.document;
});
test('should mock document.title', () => {
expect(getTitle()).toBe('Trade Federation Page');
});
Mocking classes is essential when your code depends on complex objects. It allows you to isolate the unit under test and control the behavior of its dependencies.
Use vi.spyOn() or vi.fn() to mock class methods. This is useful when you want to observe or modify the behavior of specific methods.
// droid.ts
export class Droid {
speak() {
return 'Beep boop';
}
}// droid.test.ts
import { Droid } from './droid';
import { vi, expect, test } from 'vitest';
test('should mock droid speak', () => {
const droid = new Droid();
const speakSpy = vi.spyOn(droid, 'speak').mockReturnValue('Mocked beep');
expect(droid.speak()).toBe('Mocked beep');
expect(speakSpy).toHaveBeenCalled();
});
Use vi.mock() to mock class constructors. This is useful when you want to replace the entire class with a mock implementation.
// communicator.ts
import { Droid } from "./droid";
export class Communicator {
constructor(private droid: Droid) {}
communicate() {
return this.droid.speak();
}
}// communicator.test.ts
import { Communicator } from './communicator';
import { Droid } from './droid';
import { vi, expect, test, Mock } from 'vitest';
vi.mock('./droid');
test('should mock droid constructor', () => {
vi.mocked(Droid).mockImplementation(() => ({
speak: vi.fn().mockReturnValue('Mocked constructor beep'),
}));
const communicator = new Communicator(new Droid());
expect(communicator.communicate()).toBe('Mocked constructor beep');
});
Mocking is only half the battle. Once you've set up your mocks, you need to verify that they're being used correctly. This is where Vitest's assertion techniques come in. Let's dive into how to use them effectively.
Verifying that a function was called, and how many times, is crucial. It ensures that your code is executing the expected logic. While toHaveBeenCalled() simply checks if a function was called at least once, toHaveBeenCalledTimes() provides a more precise verification.
Imagine you have a DroidCommunicator that sends messages:
// droidCommunicator.ts
export class DroidCommunicator {
sendMessage(message: string, droidId: string) {
console.log(`Sending message "${message}" to droid ${droidId}`);
// ... actual communication logic ...
}
}Here's how you'd verify the function calls:
// droidCommunicator.test.ts
import { DroidCommunicator } from './droidCommunicator';
import { vi, expect, test } from 'vitest';
test('should call sendMessage exactly once', () => {
// Arrange
const communicator = new DroidCommunicator();
const sendMessageSpy = vi.spyOn(communicator, 'sendMessage');
// Act
communicator.sendMessage('Hello there!', 'R2-D2');
// Assert
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
});
test('should call sendMessage multiple times', () => {
// Arrange
const communicator = new DroidCommunicator();
const sendMessageSpy = vi.spyOn(communicator, 'sendMessage');
// Act
communicator.sendMessage('Message 1', 'C-3PO');
communicator.sendMessage('Message 2', 'C-3PO');
// Assert
expect(sendMessageSpy).toHaveBeenCalledTimes(2);
});Now, let's explore a situation where toHaveBeenCalledTimes() helps you catch unexpected behavior. Suppose your DroidCommunicator has a feature where it should only send a message once, even if the user tries to send it multiple times in quick succession:
// droidCommunicator.ts
export class DroidCommunicator {
private messageSent = false;
constructor(private logger: (text: string) => void) {}
sendMessage(message: string, droidId: string) {
if (!this.messageSent) {
this.logger(`Sending message "${message}" to droid ${droidId}`);
// ... actual communication logic ...
this.messageSent = true;
}
}
}test('should only send message once, even if called multiple times', () => {
// Arrange
const logger = vi.fn();
const communicator = new DroidCommunicator(logger);
// Act
communicator.sendMessage('Urgent Message', 'BB-8');
communicator.sendMessage('Urgent Message', 'BB-8');
communicator.sendMessage('Urgent Message', 'BB-8');
// Assert
expect(logger).toHaveBeenCalledTimes(1);
});In this example, toHaveBeenCalledTimes(1) ensures that the message is only sent once, even though sendMessage is called multiple times. This helps catch potential bugs and improves the robustness of your code by preventing unintended side effects.
Verifying the arguments passed to mock functions is essential to ensure that your code is passing the correct data. toHaveBeenCalledWith() checks if the mocked function was called with the specific arguments at least once, while toHaveBeenLastCalledWith() checks the arguments of the last call.
test('should call sendMessage with specific arguments', () => {
// Arrange
const communicator = new DroidCommunicator(console.log);
const sendMessageSpy = vi.spyOn(communicator, 'sendMessage');
// Act
communicator.sendMessage('May the Force be with you', 'BB-8');
// Assert
expect(sendMessageSpy).toHaveBeenCalledWith('May the Force be with you', 'BB-8');
});
test('should check the last call arguments', () => {
// Arrange
const communicator = new DroidCommunicator(console.log);
const sendMessageSpy = vi.spyOn(communicator, 'sendMessage');
// Act
communicator.sendMessage('Message 1', 'C-3PO');
communicator.sendMessage('Message 2', 'R2-D2');
// Assert
expect(sendMessageSpy).toHaveBeenLastCalledWith('Message 2', 'R2-D2');
});
Checking arguments is crucial because it ensures that your mocks are being called with the correct data. Without this, your tests might pass, but your code might still have bugs. For example, if you're mocking an API call, you need to make sure that the correct parameters are being sent to the API.
For more complex assertions, you can create custom matchers. This is useful when you have specific assertion logic that you want to reuse across multiple tests.
Here's a simple example:
// __helpers__/customMatchers.ts
import { expect } from 'vitest';
expect.extend({
toBeDroidId(received, expected) {
const pass = received.startsWith('C');
if (pass) {
return {
message: () => `expected ${received} not to be a droid ID`,
pass: true,
};
} else {
return {
message: () => `expected ${received} to be a droid ID`,
pass: false,
};
}
},
});// vitest.d.ts
import 'vitest';
interface CustomMatchers<R = unknown> {
toBeDroidId: () => R
}
declare module 'vitest' {
interface Assertion<T = any> extends CustomMatchers<T> {}
interface AsymmetricMatchersContaining extends CustomMatchers {}
}// droidCommunicator.test.ts
import './customMatchers'; // Import the custom matchers
import { expect, test } from 'vitest';
test('should use custom matcher', () => {
expect('D123').toBeDroidId();
expect('C3PO').not.toBeDroidId();
});Explanation:
toHaveBeenCalledTimes(number): Verifies that a mock function has been called a specific number of times, allowing you to catch unexpected behavior.toHaveBeenCalledWith(...args): Verifies that a mock function has been called with specific arguments.toHaveBeenLastCalledWith(...args): Verifies that the last call to a mock function was with specific arguments.One of the most common pitfalls in testing is over-mocking. It's tempting to mock everything, but this can lead to tests that are disconnected from the actual behavior of your code. Remember, the goal of mocking is to isolate the unit under test, not to replace every dependency.
Only mock external dependencies or parts of your code that are difficult to test directly. For example, mock API calls, database interactions, or complex third-party libraries. But avoid mocking the core logic of the unit you're testing.
Ask yourself, "What am I trying to test?" If the answer involves the interaction with an external system or a complex dependency, then mocking is appropriate. Otherwise, try to test the code as it is.
Tests should be as readable as possible. This makes them easier to understand, maintain, and debug. Here are a few tips:
mockFn, use names like mockApiService or mockDatabaseSave.In this guide, we've explored advanced mocking techniques in Vitest, going beyond the basics to tackle complex scenarios. We've covered:
vi.spyOn(), vi.fn(), and manual mocks.By mastering these techniques, you can write more robust, reliable, and maintainable tests. Remember to mock only what's necessary, keep your tests readable, and always verify your mocks with appropriate assertions.
I encourage you to apply these techniques in your own projects and continue to explore the power of Vitest. Happy testing!
vi.spyOn(obj, method): Spy on a method, keep original.vi.fn(impl?): Create a mock function.vi.mock(path, factory?): Mock a module.__mocks__/module.ts: Manual mock.mockFn.mockImplementationOnce(fn): Set mock function behavior once.mockFn.mockReturnValueOnce(val): Set return value once.mockFn.mockResolvedValueOnce(val): Set resolved promise value once.mockFn.mockRejectValueOnce(err): Set rejected promise value.spy.mockRestore(): Restore original method.expect(mock).toHaveBeenCalledTimes(n): Check call count.expect(mock).toHaveBeenCalledWith(...args): Check call args.expect(mock).toHaveBeenLastCalledWith(...args): Check last call args.vi.stubGlobal(name, val): Mock global object.setupServer, rest.get/post).