Using Jasmine Spies to Decouple Unit Tests from Dependencies
Learn how to use Jasmine spies (spyOn and jasmine.createSpy) to isolate units from dependencies, with a worked example, trade‑offs, and verification steps.
22 Mar 2026, 13:40 UTC

Introduction
When testing a JavaScript unit that reaches out to another module—such as an HTTP client, a logger, or a configuration service—you often want to verify that the unit behaves correctly without actually invoking the dependency. Jasmine’s spy mechanism lets you replace a real function with a test double that records how it was called and can return predefined values. This article shows how to apply spies to isolate a service from its HTTP client, discusses the trade‑offs of interaction‑based assertions, and gives concrete steps to verify that the test behaves as expected.
What Are Jasmine Spies?
Jasmine provides two primary ways to create a spy:
spyOn(object, 'methodName')replaces an existing method onobjectwith a stub that records calls.jasmine.createSpy('name')creates a standalone fake function you can pass as a dependency.
A spy tracks every invocation, its arguments, and the this context. You can then assert on those interactions with matchers such as toHaveBeenCalledWith, toHaveBeenCalledTimes, or by inspecting spy.calls. Behavior can be tuned with chainers like and.returnValue, and.callFake, and.throwError, or and.callThrough (which delegates to the original implementation while still recording).
Worked Example: Service That Calls an HTTP Client
Consider a simple service that fetches user data:
// userService.js
class UserService {
constructor(httpClient) {
this.http = httpClient;
}
async getUser(id) {
const response = await this.http.get(`/users/${id}`);
return response.data;
}
}
module.exports = UserService;
In the spec we replace httpClient.get with a spy, make it return a resolved promise with fake data, invoke the service, and then check both the returned value and that the spy received the expected URL.
// userService.spec.js
const UserService = require('./userService');
describe('UserService', () => {
let httpClient;
let service;
beforeEach(() => {
// Create a mock object with a get method we can spy on
httpClient = { get: jasmine.createSpy('get') };
service = new UserService(httpClient);
});
it('returns user data and calls HTTP client with correct URL', async () => {
const fakeData = { id: 42, name: 'Ada' };
// Configure the spy to resolve with a shape matching the real response
httpClient.get.and.returnValue(Promise.resolve({ data: fakeData }));
const result = await service.getUser(42);
// Assert on the service’s output
expect(result).toEqual(fakeData);
// Assert on the interaction with the dependency
expect(httpClient.get).toHaveBeenCalledWith('/users/42');
});
});
This spec demonstrates:
- Creating a spy on a method that exists on the mock object.
- Using
and.returnValueto provide a deterministic promise. - Executing the unit under test.
- Verifying both the return value (state‑based) and the call signature (interaction‑based).
Trade‑offs and Limitations
Spies are powerful, but they couple the test to the implementation detail of how the service communicates with its dependency. If you later refactor getUser to use a different HTTP method or to batch requests, the test will fail even though the external behavior (returning the correct user data) remains unchanged. Therefore:
- Prefer assertions on the unit’s output or observable side‑effects when they fully capture the contract.
- Reserve interaction assertions (
toHaveBeenCalledWith,toHaveBeenCalledTimes) for cases where the call itself is part of the contract—e.g., triggering a save, emitting an event, or invoking a callback that must happen exactly once. - Avoid using
and.callThroughon the unit under test; it turns a unit test into a thin integration test and can mask bugs in the dependency.
Another practical limitation: spyOn throws if the target method does not exist at the time the spy is created. This can cause confusing failures after a rename or removal. A quick guard is to verify the object shape in a separate sanity check or to rely on TypeScript/Flow typings that catch missing members early.
Verification Steps
To confirm that the example works as described:
- Check the installed Jasmine version:
npm ls jasmineor inspectpackage.json. The APIs used (jasmine.createSpy,and.returnValue) are stable from Jasmine 2.0 onward. - Run the spec locally:
npx jasmine userService.spec.js. You should see a passing test. - Deliberately break the interaction—change the URL in the service to
/users/${id}/detail—and run the spec again. The expectationtoHaveBeenCalledWithshould fail, proving the test is actually exercising the call. - Restore the original URL and break the return value (e.g., make the spy reject). The test should now fail on the assertion against
result, showing that both sides of the verification are active.
These steps give you confidence that the spy is correctly set up, that the test asserts what you intend, and that failures are meaningful.
Closing
Jasmine spies let you replace real dependencies with observable fakes, enabling focused unit tests that validate both what a function returns and how it collaborates with others. By using spies judiciously—favoring output‑based assertions where possible and reserving interaction checks for genuine contracts—you gain reliable feedback without over‑specifying implementation details. Apply the pattern shown above to any service‑like code, verify with the steps outlined, and adjust the spy behavior (and.callFake, and.throwError, etc.) as your test scenarios evolve.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.