Unit Testing & Mocking Asynchronous Code
Key Learning Objectives
Write deterministic unit tests for asynchronous JavaScript functions without relying on non-deterministic real-time delays.
Understand fake timers: how advancing virtual time allows testing setTimeout, setInterval, and debounce instantly.
Implement test stubs and mock functions that record call counts, received arguments, and return values.
Avoid flaky tests caused by unhandled Promise rejections and assertion timing races.
Implement clean test teardowns to prevent global mock leakage and test pollution across suites.
The Interview Problem
What is logged to the console when the following unit testing scenario executes against a debounced search function using a virtual fake-timer harness?
1class FakeTimerHarness {2 constructor() {3 this.currentTime = 0;4 this.queue = [];5 }67 setTimeout(fn, delay) {8 const id = this.queue.length + 1;9 this.queue.push({ id, runAt: this.currentTime + delay, fn });10 return id;11 }1213 clearTimeout(id) {14 this.queue = this.queue.filter((item) => item.id !== id);15 }1617 advanceTime(ms) {18 this.currentTime += ms;19 this.queue.sort((a, b) => a.runAt - b.runAt);20 while (this.queue.length && this.queue[0].runAt <= this.currentTime) {21 const task = this.queue.shift();22 task.fn();23 }24 }25}2627function createMockFn() {28 const calls = [];29 const fn = (...args) => {30 calls.push(args);31 return 'mocked-result';32 };33 fn.calls = calls;34 return fn;35}3637const clock = new FakeTimerHarness();38const mockApi = createMockFn();39const logs = [];4041function debouncedFetch(api, delay) {42 let timerId = null;43 return (query) => {44 if (timerId) {45 clock.clearTimeout(timerId);46 logs.push('cancelled-timer');47 }48 timerId = clock.setTimeout(() => {49 logs.push('executed:' + query);50 api(query);51 timerId = null;52 }, delay);53 };54}5556const trigger = debouncedFetch(mockApi, 100);5758trigger('apple');59clock.advanceTime(50);60trigger('banana');61clock.advanceTime(100);6263const callCount = mockApi.calls.length;64const executedArg = mockApi.calls[0] ? mockApi.calls[0][0] : 'none';6566logs.push('calls:' + callCount);67logs.push('arg:' + executedArg);6869console.log(logs.join(' '));
Predict Console Output
Select the option that matches what standard ECMAScript prints to the console:
cancelled-timer executed:banana calls:1 arg:banana
executed:apple executed:banana calls:2 arg:banana
cancelled-timer executed:apple calls:1 arg:apple
cancelled-timer executed:banana calls:2 arg:apple
V8 Engine Execution Trace
Step 1 of 5 (Line 52)trigger('apple') is invoked. timerId is null, so it schedules a fake timer set to run at t=100ms.
Deep Technical Breakdown
Why Real Timers Break Test Suites
Testing asynchronous time-dependent code (debounce, throttle, polling, timeouts) with real setTimeout introduces fatal defects into CI/CD pipelines:
- Sluggish Test Suites: Waiting for real 500ms or 1000ms timers turns a 10-second test suite into a 10-minute crawl.
- Flaky Race Conditions: Operating system CPU spikes cause real timers to drift, resulting in non-deterministic test failures.
The Virtual Time (Fake Timers) Pattern
Modern testing frameworks (Jest, Vitest, Sinon) replace the global setTimeout and Date.now() with a deterministic virtual clock:
Real World: Thread blocks while wall-clock time passes...
Fake Timers: clock.advanceTime(1000) ──► Immediately runs all callbacks due <= 1000msWriting Deterministic Mock Functions
A mock function (stub/spy) must record:
- How many times it was invoked (
mockFn.mock.calls.length). - What arguments each invocation received (
mockFn.mock.calls[0]). - What value it returned or whether it threw.
Common Async Assertion Pitfalls in Jest/Vitest
- Forgetting
await: Writingexpect(asyncFn()).resolves.toBe(data)withoutawaitcauses the test runner to finish before the Promise resolves, creating a silent false-positive pass. - Catching without Assertion Verification: In
try/catchtest blocks, if the function does not throw as expected, thecatchblock is skipped and the test erroneously passes. Always useexpect.assertions(1)orawait expect(...).rejects.toThrow().
Common Traps & Mistakes
Writing real `await new Promise(r => setTimeout(r, 500))` in unit tests, introducing non-deterministic flakiness and slowing test execution.
Failing to clean up mocks with `jest.restoreAllMocks()` or `vi.restoreAllMocks()` in `afterEach`, causing test state pollution across test files.
Forgetting to advance fake timers when testing debounced inputs, wondering why mocked handlers never trigger.
Not asserting that a promise rejects: omitting `expect.assertions()` allows code that unexpectedly succeeds to pass tests designed to verify errors.
FAANG Follow-Up Probes
Probe #1
How does `jest.useFakeTimers()` interact with the JavaScript microtask queue (Promises) versus the macrotask queue (setTimeout)?
Probe #2
How would you mock `global.fetch` to simulate both successful JSON responses and network AbortError cancellations?
Probe #3
What is the difference between a mock, a stub, and a spy in testing terminology?
