Choosing Between jest.fn() and jest.spyOn() for Interaction Testing
Learn when to use jest.fn() versus jest.spyOn() to isolate code and verify interactions. This guide covers implementation patterns, state management, and how to avoid brittle tests.
08 Oct 2026, 19:08 UTC

The Core Difference: Standalone Mocks vs. Wrapped Methods
When testing how a function interacts with other parts of your system, the primary challenge is isolating the unit under test from its dependencies. In Jest, the decision between jest.fn() and jest.spyOn() depends on whether you need to replace a dependency entirely or simply observe its behavior while it continues to function.
Use jest.fn() when you need a "blank slate" function. It is best for callbacks, dependency injection, or replacing an entire module where the original logic is irrelevant or causes side effects (like API calls) that must be avoided.
Use jest.spyOn() when you need to track calls to an existing method on an object. It allows the original code to execute by default, making it ideal for verifying that a specific internal method was called without breaking the rest of the object's functionality.
Practical Implementation: Mocking a Data Service
Consider a scenario where a UserService depends on a Database object. We want to test that the service calls the database with the correct ID, but we don't want to actually connect to a database during the test.
// database.js
export const Database = {
saveUser: (user) => {
// Imagine a real DB connection here
return { id: 1, ...user };
}
};
// userService.js
import { Database } from './database';
export const createUser = (name) => {
return Database.saveUser({ name });
};
Depending on your goal, you would implement the test in one of two ways:
Option A: Using jest.spyOn() to track existing logic
Run this in your test file. This approach keeps the original saveUser logic intact unless you explicitly override it.
import { Database } from './database';
import { createUser } from './userService';
test('should call saveUser with correct arguments', () => {
// Wraps the existing method
const spy = jest.spyOn(Database, 'saveUser');
createUser('Alice');
expect(spy).toHaveBeenCalledWith({ name: 'Alice' });
// Restore original implementation to avoid leaking state
spy.mockRestore();
});
Option B: Using jest.fn() for a complete replacement
This is useful if saveUser is slow or destructive. You replace the method with a dummy function that returns a controlled value.
import { Database } from './database';
import { createUser } from './userService';
test('should return the mocked user object', () => {
// Replace the method entirely
Database.saveUser = jest.fn().mockReturnValue({ id: 99, name: 'Mocked' });
const result = createUser('Alice');
expect(result.id).toBe(99);
expect(Database.saveUser).toHaveBeenCalledTimes(1);
});
Managing Mock State and Avoiding Leakage
One of the most common causes of "flaky" tests in Jest is state leakage. Because mocks track every call in the mock.calls array, a test that runs second may fail because it sees calls made during the first test.
| Method | Effect | When to Use |
|---|---|---|
clearAllMocks() |
Resets call counts and arguments. | Between tests to start call counts at 0. |
resetAllMocks() |
Resets counts AND removes any mockImplementation. |
When you want to wipe the mock completely. |
restoreAllMocks() |
Restores original implementation (spies only). | When using spyOn to ensure the real function returns. |
To automate this, add the following to your jest.config.js or a setup file:
// jest.config.js
module.exports = {
clearMocks: true,
};
Limitations and Common Pitfalls
- Read-only Properties:
jest.spyOn()will throw an error if you attempt to spy on a property that is non-configurable or read-only in the JavaScript environment. - Implementation Brittleness: Avoid mocking internal private methods. If you mock
_internalHelper()and later rename that helper during a refactor, your test will fail even if the public API still works. Mock the boundaries of your module instead. - The
mock.callsArray: For complex sequences, don't rely solely ontoHaveBeenCalledWith. You can inspectmock.mock.calls[0][0]to get the exact first argument of the first call for deep object validation.
Verification Checklist
To verify your mocks are working correctly, run your tests with the --verbose flag. If you suspect state leakage, intentionally add a duplicate call in one test and check if the toHaveBeenCalledTimes(1) assertion fails in a subsequent, unrelated test. If it fails, your mock cleanup (clear/reset) is missing.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.