Choosing Between jest.fn() and jest.spyOn() for Dependency Isolation
Stop fighting leaky tests. Learn when to use jest.fn() for standalone mocks versus jest.spyOn() for tracking existing methods to create isolated, deterministic unit tests.
14 May 2026, 15:04 UTC

The Problem: Leaky Tests and Fragile Mocks
When writing unit tests, a common frustration is the "domino effect": a bug in a low-level utility function causes dozens of unrelated high-level business logic tests to fail. This happens because the tests are too integrated. To achieve true isolation, you need to replace real dependencies with controlled doubles.
The challenge is deciding how to replace those dependencies. Using the wrong mocking strategy often leads to "brittle tests"—tests that pass even when the actual application is broken, or tests that fail because of leftover state from a previous test run.
Standalone Mocks with jest.fn()
jest.fn() creates a completely new, blank mock function. It has no internal logic and returns undefined by default. It is best used when you need to pass a callback into a function or when you are replacing an entire module dependency.
Because jest.fn() doesn't track an original implementation, it is lightweight and explicit. You define exactly what it should return using methods like mockReturnValue() (for synchronous values) or mockResolvedValue() (for Promises).
Tracking Existing Methods with jest.spyOn()
jest.spyOn() is used when you want to track calls to a method that already exists on an object. Unlike jest.fn(), a spy wraps the original method. By default, the original code still executes, but Jest records every time the method is called and with what arguments.
This is particularly useful for verifying side effects—such as ensuring a logger was called—without breaking the actual logging functionality. If you need to prevent the original code from running, you can chain .mockImplementation() to the spy.
Worked Example: Testing a User Service
Consider a UserService that depends on an ApiClient. We want to test the service logic without making actual network requests.
// userService.test.js
const { UserService } = require('./userService');
const { ApiClient } = require('./apiClient');
describe('UserService.getUserData', () => {
let userService;
let mockClient;
beforeEach(() => {
// 1. Create a standalone mock for the client
mockClient = {
fetchUser: jest.fn()
};
userService = new UserService(mockClient);
});
it('should format user data correctly', async () => {
// Setup the mock to return a deterministic value
mockClient.fetchUser.mockResolvedValue({ id: 1, name: 'Jane Doe' });
const result = await userService.getUserData(1);
expect(result).toBe('User: Jane Doe');
// Verify the dependency was called with the correct ID
expect(mockClient.fetchUser).toHaveBeenCalledWith(1);
});
it('should log an error when the API fails', async () => {
// 2. Use spyOn to track a global object method
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockClient.fetchUser.mockRejectedValue(new Error('API Down'));
await userService.getUserData(1);
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch user');
// Clean up the spy to restore original console.error behavior
consoleSpy.mockRestore();
});
});
Execution Details
- Environment: Run these tests using
npm testornpx jestin a Node.js environment. - Permissions: No special system permissions are required beyond standard user access to the project directory.
- Risk: Using
spyOnon global objects (likeconsoleorprocess) without callingmockRestore()can pollute other tests, leading to missing logs or unexpected behavior in the test runner.
The Trade-off: Mock State and Maintenance
The biggest risk with Jest mocks is state leakage. If you use a mock across multiple tests, the call count persists. If Test A calls a function once, and Test B calls it once, toHaveBeenCalledTimes(2) will be true for Test B, which is usually a mistake.
| Method | What it clears | Use Case |
|---|---|---|
mockClear() |
Call history only | Resetting call counts between tests while keeping the mock implementation. |
mockReset() |
History AND implementation | Completely wiping the mock back to undefined. |
mockRestore() |
The entire mock/spy | Only for spyOn; restores the original original function. |
Limitation: Over-mocking creates a "mirror world." If you mock every single internal function, your tests only prove that your mocks work, not that your code works. To verify the result, occasionally run an integration test that uses the real ApiClient against a staging environment.
Closing Action
To keep your test suite maintainable, follow this rule of thumb: use jest.fn() for injected dependencies (passed via constructors) and jest.spyOn() for external modules or global objects. Always call mockClear() in a afterEach block to ensure each test starts with a clean slate.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.