Jasmine's spyOn: Intercepting Methods Without Breaking Your Code Under Test
Jasmine's spyOn replaces one method on one object, records its calls, and restores the original after each spec. Here's how to pick the right strategy and avoid the common traps.
06 Jul 2025, 09:23 UTC

You have a service that calls a payment gateway, and you want to test your order logic without charging a real card. The naive options are bad: mock the whole module system, or let the test hit the network. Jasmine's spyOn offers a third path — replace one method on one object, watch what happens to it, and put the original back when the test ends.
The thesis of this piece is simple: spyOn is most useful when you treat it as a surgical tool for verifying interactions, not as a general-purpose stubbing hammer. Used that way, it keeps tests isolated without turning them into a second copy of your implementation.
What spyOn actually does
spyOn(obj, 'methodName') swaps obj.methodName for a spy — a function that records every call, its arguments, and its return value. By default the spy does nothing and returns undefined; the original implementation is not executed. After each spec, Jasmine restores the original method automatically, so spies don't leak between tests.
Three details matter in practice:
- The method must exist on the object you pass.
spyOnon a missing property throws, which is a feature — it catches typos and drift between the test and the real API. - The property must be configurable. Spying on a non-configurable property (for example, something defined with
Object.defineProperty(obj, 'x', { configurable: false })) will throw in strict mode. This occasionally bites when spying on built-ins or frozen objects. - Spies replace the property on that object. If the code under test captured a direct reference to the original function earlier, the spy never sees those calls.
Choosing the right spy strategy
The and chain controls what the spy does when invoked, and the choice changes what your test proves:
| Strategy | Behavior | Good for |
|---|---|---|
and.stub() (default) | No-op, returns undefined | Pure interaction checks: "was this called?" |
and.returnValue(v) | Returns a fixed value | Isolating the unit from a dependency's result |
and.callFake(fn) | Runs your substitute function | Conditional returns, argument-dependent behavior |
and.callThrough() | Records the call, then runs the original | Verifying calls on cheap, side-effect-free code |
and.throwError(e) | Throws when called | Testing error-handling paths |
Be wary of callThrough. It feels safe — "the real code still runs" — but it couples the test to the dependency's internals and reintroduces whatever side effects you were trying to avoid. If you need the real behavior, that's often a sign the test wants to be an integration test instead.
A worked example: order service, fake gateway
Assume Jasmine 4.x (the API below is stable across 3.x and 4.x). Here's a small service and a spec that verifies the interaction with a payment gateway without touching it:
// orderService.js
function createOrderService(gateway) {
return {
checkout(cart, cardToken) {
const total = cart.items.reduce((s, i) => s + i.price, 0);
const receipt = gateway.charge(total, cardToken);
return { total, receiptId: receipt.id };
}
};
}
// orderServiceSpec.js
describe('orderService.checkout', () => {
it('charges the cart total and returns the receipt id', () => {
const gateway = {
charge(amount, token) {
throw new Error('real gateway called!'); // must never run
}
};
spyOn(gateway, 'charge').and.returnValue({ id: 'rcpt_1' });
const service = createOrderService(gateway);
const result = service.checkout(
{ items: [{ price: 30 }, { price: 12 }] },
'tok_test'
);
expect(gateway.charge).toHaveBeenCalledTimes(1);
expect(gateway.charge).toHaveBeenCalledWith(42, 'tok_test');
expect(result).toEqual({ total: 42, receiptId: 'rcpt_1' });
});
});Run this with the jasmine CLI or your usual npm test script — no special permissions needed; it's ordinary Node or browser code. The deliberate throw in the real charge is a cheap self-check: if the spy failed to replace the method, the spec fails loudly instead of silently passing against real behavior. Remove the throw once you're confident, or keep it as a tripwire.
For finer-grained assertions, the spy exposes a call log: gateway.charge.calls.count(), gateway.charge.calls.mostRecent().args, and calls.allArgs() for every invocation. These are useful when the matcher API (toHaveBeenCalledWith) isn't expressive enough — for instance, asserting that a callback argument is a function, then invoking it.
Verifying time-driven code without waiting
Spies pair naturally with Jasmine's mock clock. If your code schedules work with setTimeout, install the clock, spy on the callback target, and advance time manually:
it('retries after one second', () => {
jasmine.clock().install();
const api = { fetch: () => Promise.resolve() };
spyOn(api, 'fetch');
scheduleRetry(api); // calls api.fetch via setTimeout(1000)
expect(api.fetch).not.toHaveBeenCalled();
jasmine.clock().tick(1000);
expect(api.fetch).toHaveBeenCalledTimes(1);
jasmine.clock().uninstall();
});Always uninstall the clock — a forgotten uninstall() leaks fake time into later specs and produces baffling failures.
Limitations worth knowing
Spies on getters and setters are unreliable across engines; use spyOnProperty(obj, 'prop', 'get') instead of spyOn for accessors. Spying on module-level functions imported directly (ES module bindings) generally doesn't work, because the importer holds its own binding — the common workaround is to inject dependencies, as the example above does with gateway. And a test heavy on spies can calcify: if renaming an internal method breaks twenty specs, the tests are asserting structure, not behavior.
A practical way to check yourself
After writing a spy-based test, run two sanity checks. First, temporarily remove the and.* strategy and confirm the test still passes only if a pure stub is acceptable — if the code under test needed a return value and got undefined, your test was weaker than you thought. Second, deliberately break the interaction (call the dependency twice, or with wrong arguments) and confirm the spec fails. A spy test that can't fail is just documentation. Start with one interaction that matters, spy on it, and resist the urge to spy on everything else.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.