Concept
Spies, Stubs, and Mocks
To test code containing external side effects (like API requests, file saves, or analytics), we replace those dependencies with test doubles:
- Spy (
vi.fn()): A function wrapper that records execution history (arguments passed, return values, call counts) while letting the original code execute. - Stub: Replaces a function with a dummy implementation that returns static hardcoded data instantly, preventing network operations.
- Mock: A mock object pre-programmed with specific behavioral expectations.
import { vi, test, expect } from 'vitest';
import { sendAlert } from './alert';
import { checkServerStatus } from './status';
// 1. Mocking a module import
vi.mock('./alert', () => ({
sendAlert: vi.fn() // Replaces function with a spy
}));
test('sends alert when status is offline', () => {
// 2. Act
checkServerStatus('offline');
// 3. Assert on the spy function
expect(sendAlert).toHaveBeenCalledTimes(1);
expect(sendAlert).toHaveBeenCalledWith('Server is offline!');
});Mocking Globals: Timers & Dates
If your module executes delay functions (like setTimeout) or evaluates date offsets, unit tests can become slow or pass/fail randomly based on execution times. Use mock timers:
test('waits 3 seconds and runs callback', () => {
const cb = vi.fn();
vi.useFakeTimers(); // Intercept system clock
runDelayedTask(cb);
expect(cb).not.toHaveBeenCalled();
// Fast-forward 3000ms instantly
vi.advanceTimersByTime(3000);
expect(cb).toHaveBeenCalled();
vi.useRealTimers(); // Teardown and restore clock
});Common Mistakes
1. Mocking internal class implementation details
Over-mocking makes tests useless. If you mock the internals of the class you are actively testing, your assertions verify your mock configuration rather than the production compiled code. Only mock boundaries (external API libraries, databases, disk filesystem writers).
2. Leaking spy call counts between test files
Spy call histories (like toHaveBeenCalledTimes()) compile globally within test runner files. If you do not reset mock state caches after each test case runs, count histories leak across tests, leading to random failures:
// Prevent leak:
afterEach(() => {
vi.clearAllMocks(); // Resets call histories
// or vi.resetAllMocks() to reset implementation too
});Best Practices
- Mock Boundaries: Only mock dependencies that exit the boundary context of the module under test (e.g.
axios,fs, database connections). - Default to clearAllMocks: Ensure your test runner configuration resets call spy counts automatically after each test.
- Mock Dates explicitly: If calculations rely on
new Date(), set the system clock to a fixed epoch timestamp before execution:vi.setSystemTime(new Date('2026-07-22'));
