Unit tests are supposed to be fast and isolated. But what happens when the function you want to test calls a database, sends an email, or makes a request to an external APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses.? You cannot let those side effects run in tests, they are slow, unpredictable, and expensive. This is where mocks and spies come in.
Why you need mocks
Think of a mockWhat is mock?A fake replacement for a real dependency in tests that records how it was called so you can verify interactions. like a stunt double in a film. The stunt double looks like the actor, responds to the director's cues, and lets the scene get filmed safely, but nothing real is actually at risk. A mock database looks like the real thing to your code, returns whatever you tell it to, and never touches an actual server.
Mocking serves two goals: isolation (your test only tests your code, not the dependencyWhat is dependency?A piece of code written by someone else that your project needs to work. Think of it as a building block you import instead of writing yourself.'s code) and control (you decide exactly what the dependency returns, including errors and edge cases).
When to mock vs. use the real thing
| Use a mock when... | Use the real thing when... |
|---|---|
| The dependency is slow (DB, network) | You are writing an integration test |
| You need to simulate errors | The dependency is lightweight (pure function) |
| The dependency has external costs | You want to verify the wiring is correct |
| You want to test without setup | You have a test database available |
Creating mockWhat is mock?A fake replacement for a real dependency in tests that records how it was called so you can verify interactions. functions with vi.fn()
vi.fn() creates a mock function, a fake function that records every call made to it.
import { vi, describe, it, expect } from 'vitest';
describe('sendWelcomeEmail', () => {
it('calls the email service with correct arguments', async () => {
// Create a mock email service
const mockEmailService = {
send: vi.fn().mockResolvedValue({ messageId: 'abc-123' })
};
const user = { name: 'Alice', email: 'alice@example.com' };
await sendWelcomeEmail(user, mockEmailService);
// Verify it was called correctly
expect(mockEmailService.send).toHaveBeenCalledTimes(1);
expect(mockEmailService.send).toHaveBeenCalledWith({
to: 'alice@example.com',
subject: 'Welcome to the platform',
body: expect.stringContaining('Alice')
});
});
});Controlling return values
You can make a mock return different values depending on when it is called:
const mockFn = vi.fn()
.mockReturnValue('default') // All calls return 'default'
.mockReturnValueOnce('first call') // First call returns this
.mockReturnValueOnce('second call'); // Second call returns this
console.log(mockFn()); // 'first call'
console.log(mockFn()); // 'second call'
console.log(mockFn()); // 'default'
console.log(mockFn()); // 'default'For async functions, use mockResolvedValue and mockRejectedValue:
const mockDb = {
findUser: vi.fn()
.mockResolvedValueOnce({ id: 1, name: 'Alice' }) // succeeds
.mockRejectedValueOnce(new Error('Connection lost')) // then fails
};Mocking entire modules with vi.mock()
Sometimes you need to replace every function exported from a moduleWhat is module?A self-contained file of code with its own scope that explicitly exports values for other files to import, preventing name collisions.. vi.mock() does this at the module level, any file that imports the mocked module gets the fake version.
// vi.mock() must be at the top level - Vitest hoists it automatically
vi.mock('../database/users.js', () => ({
findById: vi.fn(),
create: vi.fn(),
deleteById: vi.fn()
}));
import { findById, create } from '../database/users.js';
describe('UserService.create', () => {
afterEach(() => {
vi.clearAllMocks(); // Reset call counts and return values
});
it('creates a user and returns it', async () => {
create.mockResolvedValue({ id: 42, name: 'Bob' });
const result = await UserService.create({ name: 'Bob' });
expect(create).toHaveBeenCalledWith({ name: 'Bob' });
expect(result.id).toBe(42);
});
});vi.mock() is hoisted to the top of the file by Vitest, even if you write it in the middle of your code. This is why the import that comes after it still gets the mocked version, the mock is always set up first.Spying on existing functions
A spyWhat is spy?A test wrapper around a real function that records how it was called (arguments, return values) without replacing its implementation. is different from a mockWhat is mock?A fake replacement for a real dependency in tests that records how it was called so you can verify interactions.. A mock replaces a function entirely. A spy wraps a real function so you can observe it, by default, the real implementation still runs.
import * as emailModule from '../services/email.js';
describe('UserService', () => {
it('calls sendEmail when user is created', async () => {
// Spy on the real function
const emailSpy = vi.spyOn(emailModule, 'sendEmail');
await UserService.create({ name: 'Charlie', email: 'c@example.com' });
expect(emailSpy).toHaveBeenCalledTimes(1);
// Restore original implementation after
emailSpy.mockRestore();
});
});You can also override the implementation temporarily:
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// Now console.error is silenced for this testCommon spy matchers
expect(spy).toHaveBeenCalled();
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenCalledWith('arg1', 'arg2');
expect(spy).toHaveBeenLastCalledWith('last-arg');
expect(spy).toHaveReturnedWith('value');Mocking timers and dates
Tests that depend on Date.now(), setTimeout, or setInterval are fragile because they depend on real time. Vitest lets you fake the clock:
describe('rate limiter', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('resets after one minute', async () => {
const limiter = new RateLimiter({ maxRequests: 5, windowMs: 60_000 });
// Use up all requests
for (let i = 0; i < 5; i++) {
limiter.check('user-1');
}
expect(() => limiter.check('user-1')).toThrow('Rate limit exceeded');
// Advance time by 60 seconds
vi.advanceTimersByTime(60_000);
// Should work again
expect(() => limiter.check('user-1')).not.toThrow();
});
});Quick reference
| API | What it does |
|---|---|
vi.fn() | Create an empty mock function |
vi.fn().mockReturnValue(v) | Mock always returns v |
vi.fn().mockResolvedValue(v) | Mock always resolves to v |
vi.fn().mockRejectedValue(e) | Mock always rejects with e |
vi.fn().mockImplementation(fn) | Mock uses custom implementation |
vi.mock('module', factory) | Replace an entire module |
vi.spyOn(obj, 'method') | Spy on an existing method |
vi.clearAllMocks() | Reset call history (keep implementation) |
vi.resetAllMocks() | Reset call history + return values |
vi.restoreAllMocks() | Restore all spies to originals |
vi.useFakeTimers() | Take control of the clock |
// Mocking example
import { vi, describe, it, expect } from 'vitest';
// Mock a module
vi.mock('./database', () => ({
query: vi.fn().mockResolvedValue({ rows: [] })
}));
// Mock function
const mockFn = vi.fn()
.mockReturnValue('default')
.mockReturnValueOnce('first')
.mockReturnValueOnce('second');
// Spy on existing function
const spy = vi.spyOn(console, 'log');
// Verify calls
expect(mockFn).toHaveBeenCalledWith('arg');
expect(mockFn).toHaveBeenCalledTimes(2);