Jasmine Spies: Verifying Interactions Without Overfitting Your Tests
Jasmine spies let you stub dependencies and verify interactions without real implementations — but overusing toHaveBeenCalledWith makes tests brittle. Here's how to spy well.
25 Jul 2025, 17:59 UTC

Your service calls a payment gateway, an email sender, and a logger. You want to test the service's logic — not the gateway's uptime or your SMTP server. This is the exact problem Jasmine spies solve: they replace a dependency's function with a recording stand-in, so you can stub its output and verify how it was called, all without touching real implementations.
The takeaway up front: spies are excellent for isolating a unit and confirming it delegates correctly, but interaction assertions like toHaveBeenCalledWith are easy to overuse. The skill is knowing when to verify what happened versus how it happened.
Two ways to create a spy
Jasmine gives you two entry points. spyOn(obj, 'method') replaces an existing method on an object — the method must exist at call time, or Jasmine throws, which usually means an import-order or wiring bug in your test setup. jasmine.createSpy('name') builds a standalone fake function you can inject anywhere a callback or collaborator is expected.
Both record every invocation: arguments, call count, and order. Both are torn down automatically between specs when declared inside a spec or beforeEach, so there's no manual cleanup and no cross-test pollution.
Choosing a stubbing strategy
Once a spy replaces the real function, you decide what it does when called. The strategies map to intent:
and.returnValue(x)— fixed output. Use for simple dependencies whose internals don't matter.and.callFake(fn)— computed behavior. Use when the output depends on the input, or when you want to simulate a branch of the dependency's logic.and.throwError(msg)— force the failure path. The cheapest way to test your error handling.and.callThrough()— record the call but still run the real method. Useful for logging or pure functions, risky for anything with side effects.
For async dependencies, and.returnValue(Promise.resolve(data)) lets you write await-based specs without done callbacks in Jasmine versions that support async specs.
A worked example
Here's an order service with a gateway dependency, tested in a plain Jasmine spec (runs wherever your suite runs — npx jasmine or via Karma; no special permissions needed):
describe('OrderService', () => {
let gateway, service;
beforeEach(() => {
gateway = { charge: (amount, currency) => Promise.resolve() };
spyOn(gateway, 'charge').and.returnValue(
Promise.resolve({ id: 'txn_123' })
);
service = new OrderService(gateway);
});
it('charges the total in the order currency', async () => {
const result = await service.placeOrder({ total: 4200, currency: 'EUR' });
expect(gateway.charge.calls.count()).toBe(1);
expect(gateway.charge.calls.argsFor(0)).toEqual([4200, 'EUR']);
expect(result.transactionId).toBe('txn_123');
});
it('wraps gateway failures in a domain error', async () => {
gateway.charge.and.throwError('network down');
// or: .and.returnValue(Promise.reject(new Error('network down')))
await expectAsync(service.placeOrder({ total: 1, currency: 'EUR' }))
.toBeRejectedWithError(/payment failed/i);
});
});Note the shape of the assertions: calls.count() guards against double-charging, calls.argsFor(0) pins the exact first-call arguments, and the final assertion checks the state returned to the caller. That last one is the most valuable — it survives refactors of the service's internals.
The trade-off: interaction tests are brittle
toHaveBeenCalledWith performs deep equality on arguments. Convenient, but it couples the test to the implementation. If the service later passes an extra options object to charge — harmless to behavior — the test breaks even though nothing is wrong. Overspecified interaction tests turn refactors into test-maintenance exercises.
A practical rule: prefer state-based assertions (what did the unit return, what state changed) and reserve interaction verification for cases where the call itself is the behavior — a charge exactly once, an email sent, an event emitted. If deleting the spy assertion wouldn't let a real bug through, the assertion is probably noise.
Check it against your version
Spy APIs are stable, but matcher availability and async-spec support vary across Jasmine major versions. Before relying on any of the above, check the pinned version in package.json and run a minimal spec that spies on a collaborator, invokes the unit, and asserts calls.count() and calls.argsFor(0). If that passes, the recording and stubbing APIs you're using are supported in your setup — and you have a template for every dependency-isolation test that follows.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.