Jasmine Spies: Isolate Dependencies and Verify Behavior in Unit Tests
Learn how Jasmine spies isolate external dependencies, verify interactions, and avoid common pitfalls like brittle internal-method spying and state leakage.
27 Jan 2026, 07:08 UTC

The problem with real dependencies in unit tests
When a unit test calls a real payment gateway, reads the system clock, or hits a database, the test becomes slow and unreliable. A network hiccup or a changed third-party response can fail a test even when your logic is correct. Jasmine spies solve this by letting you replace a real function with a controllable test double—an object that stands in for the real dependency—so the System Under Test (SUT) runs in isolation.
The useful takeaway: spyOn(obj, 'methodName') replaces an existing method on an object with a spy that records every call, argument, and return value without executing the original code. You can then force a specific return value or fake implementation, and assert that the SUT interacted with the dependency as expected.
What a Jasmine spy actually does
A spy is a test double that wraps or replaces a function. Unlike a stub, which only provides canned responses, a spy records how it was called. This lets you verify two things: what your code returned, and how your code behaved when calling out to other components.
The core API is spyOn(object, 'methodName'). It requires the target to already exist as a property on an object. Once created, the spy intercepts calls to that method. By default, the spy returns undefined. You change its behavior by chaining .and.returnValue(value), .and.callFake(fn), or .and.callThrough().
Worked example: isolating an API client
Consider an OrderService that depends on an apiClient. The real client makes an HTTP request and can fail for reasons outside your control. The following test replaces the client method so the service logic runs deterministically.
// order-service.js
class OrderService {
constructor(apiClient) {
this.apiClient = apiClient;
}
placeOrder(order) {
const response = this.apiClient.submitOrder(order);
return response.confirmed;
}
}
// order-service.spec.js
describe('OrderService', () => {
let apiClient;
let service;
beforeEach(() => {
apiClient = {
submitOrder: function(order) {
// Real implementation would perform a network request
throw new Error('Network unavailable');
}
};
service = new OrderService(apiClient);
});
it('returns true when the API confirms the order', () => {
spyOn(apiClient, 'submitOrder').and.returnValue({ confirmed: true });
const result = service.placeOrder({ id: 123, total: 50 });
expect(result).toBe(true);
expect(apiClient.submitOrder).toHaveBeenCalledWith({ id: 123, total: 50 });
});
});
In this example, the real submitOrder throws an error. Because spyOn replaces it before the test runs, the service receives { confirmed: true } instead. The second expectation verifies that the service passed the correct order object to the client.
Choosing the right spy behavior
Fixed return values
Use .and.returnValue(value) when the dependency should always respond the same way. This is the simplest form of isolation and works well for configuration lookups or simple status checks.
Conditional logic with callFake
When the return value depends on the input, use .and.callFake(fn). The fake function receives the same arguments as the original method.
spyOn(apiClient, 'submitOrder').and.callFake(function(order) {
if (order.total > 1000) {
return { confirmed: false, reason: 'limit exceeded' };
}
return { confirmed: true };
});
This lets you simulate edge cases—rejected payments, timeouts, or validation errors—without standing up a mock server.
Recording while still executing with callThrough
Sometimes you want the real logic to run, but you still need to verify that it was called. .and.callThrough() executes the original method and records the interaction. This is useful for logging or pure utility functions where the side effects are safe.
spyOn(logger, 'warn').and.callThrough();
myModule.process(data);
expect(logger.warn).toHaveBeenCalled();
Verifying interactions, not just return values
Return-value assertions tell you what your code produced. Interaction assertions tell you how your code used its dependencies. Jasmine provides several matchers for this:
toHaveBeenCalled()— the spy was invoked at least once.toHaveBeenCalledWith(arg1, arg2)— the spy was invoked with matching arguments.toHaveBeenCalledTimes(n)— the spy was invoked exactlyntimes.
You can also inspect the spy directly. spy.calls.count() returns the number of invocations, and spy.calls.argsFor(index) returns the arguments for a specific call. This is helpful when writing custom assertions or debugging why an interaction did not happen.
Common mistakes and hard limits
Spies require an existing object property
spyOn() only works on properties that already exist on an object. You cannot spy on a standalone function imported directly into a module:
// This fails: sendEmail is not a property of an object
import { sendEmail } from './mailer';
spyOn(sendEmail);
To isolate such a function, wrap it in an object or use dependency injection so the consumer receives an object rather than a bare function reference.
Spying on internal methods creates brittle tests
It is tempting to spy on private helper methods to force a specific code path. This couples the test to the implementation rather than the public behavior. If you later rename or refactor the internal method, the test breaks even though the public API is unchanged. Prefer spying on the boundary where your code meets the outside world: API clients, timers, or storage layers.
State leakage between tests
Jasmine restores original methods after each spec, but call counts and recorded arguments persist on the spy object. If you create a spy in a shared variable outside beforeEach(), or reuse a jasmine.createSpyObj across specs without resetting it, calls.count() accumulates. A test that expects one call may pass incorrectly because the spy already recorded calls from a previous test. Always create fresh spies inside beforeEach().
Checking that your spy is working
Run the spec from your project root, where package.json and the Jasmine configuration live:
# Run a single spec file
npx jasmine spec/order-service.spec.js
# Or run the full suite
npx jasmine
Expected check: The test passes. If the spy were not active, the real submitOrder would throw Error: Network unavailable and the spec would fail. A passing result therefore proves the dependency was isolated.
Risk: If you misspell the method name in spyOn, Jasmine throws an error stating that the property does not exist. If you forget to chain .and.returnValue(), the spy returns undefined by default, which may cause a different failure inside the SUT. Both are easy to diagnose because the error points to the missing or undefined value rather than a network timeout.
When not to use a spy
Spies are ideal for isolating side effects and verifying interactions at architectural boundaries. They are not a substitute for integration tests. If you replace every collaborator with a spy, you lose coverage of the contracts between real components. Use spies for the unit layer, and keep a smaller set of integration tests that exercise real dependencies together.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.