Isolating Side Effects in Jasmine: Spies and the Fake Clock
Learn how to use Jasmine spies and the fake clock to eliminate flakiness in unit tests by isolating HTTP calls, timers, and external dependencies.
13 Oct 2025, 13:30 UTC

The Problem: Non-Deterministic Test Suites
Unit tests that rely on real network requests, system clocks, or random number generators are inherently flaky. They fail due to network latency, time-zone shifts, or rare edge cases in random distributions. To achieve a deterministic CI pipeline, you must isolate the System Under Test (SUT) from these external dependencies.
The takeaway: Use Jasmine's built-in spyOn and jasmine.clock() to replace volatile dependencies with controlled doubles. This ensures tests are fast, repeatable, and independent of the environment.
Minimal Design for Isolation
Avoid introducing heavy mocking libraries. Jasmine provides sufficient primitives to handle the three most common side effects: collaborators (HTTP/DB), time, and randomness.
Collaborator Isolation
Use spyOn(object, 'method') to intercept calls to an existing object. For dependencies injected via constructors, use jasmine.createSpyObj to create a mock object with multiple predefined methods.
// Example: Isolating an API client
const mockApiClient = jasmine.createSpyObj('ApiClient', ['fetchUser']);
mockApiClient.fetchUser.and.returnValue(Promise.resolve({ id: 1, name: 'Test User' }));
const service = new UserService(mockApiClient);
await service.getUserDetails(1);
expect(mockApiClient.fetchUser).toHaveBeenCalledWith(1);
Time and Timer Isolation
When code relies on setTimeout, setInterval, or new Date(), the jasmine.clock() utility intercepts the global timer functions. This allows you to "fast-forward" time without actually waiting.
beforeEach(() => {
jasmine.clock().install();
});
afterEach(() => {
jasmine.clock().uninstall();
});
it('should trigger a timeout after 5 seconds', () => {
const callback = jasmine.createSpy('callback');
setTimeout(callback, 5000);
jasmine.clock().tick(5001);
expect(callback).toHaveBeenCalled();
});
Trust and Data Boundaries
To make spying effective, the SUT must be designed for Dependency Injection (DI). If a class instantiates its own dependencies internally (e.g., this.client = new ApiClient()), Jasmine cannot easily intercept those calls without toolchain-level module mocking.
- Boundary: Spies should only be applied to collaborators, never to the SUT itself. Spying on the SUT often leads to tautological tests that verify the implementation rather than the behavior.
- Data: Use fixture payloads shaped to the API contract. Avoid using production data dumps, which can leak secrets or introduce unexpected schema changes into the test suite.
Operational Checks and Verification
To ensure isolation is working and not masking bugs, implement these checks:
| Check | Action | Expected Result |
|---|---|---|
| Order Independence | Run with --random in jasmine.json |
No specs fail when run in a different sequence. |
| Clock Leakage | Grep for install() without uninstall() |
Every clock installation has a matching cleanup in afterEach. |
| Contract Drift | Deliberately change a spy's return value | The spec fails, proving the SUT actually depends on that value. |
Failure Modes
- The Hanging Suite: Forgetting
jasmine.clock().uninstall()can cause subsequent tests to hang or behave erratically because the global timer is still intercepted. - Silent Promise Rejections: Using
and.callFaketo return a promise that never resolves or rejects can cause tests to time out (default is usually 5 seconds) without a clear error message. - Deep Equality Surprises:
toHaveBeenCalledWithuses deep equality. If your SUT passes an object with an extra internal property (like a timestamp), the assertion will fail even if the primary data is correct.
When to Redesign the Strategy
The spy-based approach is suitable for most jasmine-core projects, but certain conditions require a shift in architecture:
- Native ESM Migration: In strict ES Modules, imported bindings are read-only. You cannot
spyOna function imported directly from another module. You must switch to constructor injection or a mocking tool that intercepts the module loader. - Interaction Overload: If your tests consist primarily of
toHaveBeenCalledWithassertions, you are testing how the code works rather than what it does. Shift toward state-based verification (checking the final return value or a change in a data store). - Parallel Execution: If moving to a test runner that executes specs in parallel processes, avoid any shared global state or shared spy objects, as they will cause race conditions.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.