Recommended resources
The freely available resources this lesson will direct you to.
Hello! Welcome back to our course on building a modern JavaScript framework.
In our last lesson, we implemented combineLatest, a powerful operator for synchronizing the state of multiple observables. This concluded our initial exploration of combination operators.
Today, we are still in Module 5: Pipeable Operators: Combination & Timing, but we are shifting our focus to the second theme: timing. We will begin implementing operators that manipulate the flow of emissions over time. Our first and most fundamental timing operator is delay.
Our learning goal for this lesson is to use Vitest's fake timers to implement and test a delay operator to shift emissions in time. This will introduce you to a critical testing technique for any asynchronous, time-dependent logic, ensuring our tests are both fast and reliable.
1. The Challenge of Testing Time
Operators like delay rely on functions like setTimeout. If we were to test a delay(5000) operator in a test suite, we would have to wait 5 seconds for the test to complete. This is inefficient and makes for a very slow testing cycle.
The solution is to use fake timers. These tools allow us to mock the browser's or Node.js's time-keeping functions (setTimeout, setInterval, Date). We can then manually control the passage of time in our tests, making a 5-second delay pass in an instant.
Vitest has excellent built-in support for fake timers. Let's review the documentation to see how it works.
The official Vitest documentation explains how to mock timers. This is the foundation for testing our delay operator.
Please read the 'Timers' section. Pay close attention to the roles of vi.useFakeTimers(), vi.runAllTimers(), and vi.advanceTimersByTime(). Notice how the tests can verify the behavior of setTimeout without actually waiting.
As you saw, we can wrap our test logic with vi.useFakeTimers() and vi.useRealTimers() (or vi.restoreAllMocks()) and then use vi.advanceTimersByTime(ms) to simulate the passage of time deterministically.
2. Designing the delay Operator
The delay operator is a pipeable operator. It takes a duration in milliseconds and returns a function that transforms a source observable into a new observable. This new observable will emit all the same notifications (next, error, complete) as the source, but only after the specified time has elapsed.
Here's the implementation strategy:
- Return a new
Observable: The operator function will return a newObservable. - Subscribe to the source: The producer function of this new observable will subscribe to the source observable.
- Schedule notifications:
- When the source emits a
nextvalue, we won't pass it to the observer immediately. Instead, we'll usesetTimeoutto schedule theobserver.next(value)call for a future time. - The same logic applies to
errorandcompletenotifications. They will also be scheduled withsetTimeout.
- When the source emits a
- Manage resources: What happens if a consumer unsubscribes from our
delayobservable before the timeout has finished? We would have a pendingsetTimeoutthat could execute and potentially cause errors or memory leaks. Therefore, our teardown logic must clear all pending timers usingclearTimeout. We'll need to keep track of all the timer IDs created bysetTimeout.
3. Your Task: Implement and Test delay
Now, let's put this design into practice.
3.1. Implementation
Create a new file at src/operators/delay.ts. This will be a pipeable operator, so its structure will be a function that returns another function.
// src/operators/delay.ts
import { Observable } from '../observable';
import { Subscription } from '../subscription';
export function delay<T>(duration: number): (source: Observable<T>) => Observable<T> {
return (source: Observable<T>) =>
new Observable<T>(observer => {
// A subscription to manage all timers
const timerSubscriptions = new Subscription();
const sourceSub = source.subscribe({
next: value => {
const timeoutId = setTimeout(() => {
observer.next(value);
}, duration);
timerSubscriptions.add(() => clearTimeout(timeoutId));
},
error: err => {
const timeoutId = setTimeout(() => {
observer.error(err);
}, duration);
timerSubscriptions.add(() => clearTimeout(timeoutId));
},
complete: () => {
const timeoutId = setTimeout(() => {
observer.complete();
}, duration);
timerSubscriptions.add(() => clearTimeout(timeoutId));
},
});
// When the output observable is unsubscribed,
// we must unsubscribe from the source AND clear any pending timers.
return new Subscription(() => {
sourceSub.unsubscribe();
timerSubscriptions.unsubscribe();
});
});
}
Notice how we use a Subscription instance (timerSubscriptions) not just for observables, but as a generic container for teardown logic. Adding a function like () => clearTimeout(timeoutId) to it ensures that all our timers will be cleared upon unsubscription.
3.2. Testing
Now, create the corresponding test file src/operators/delay.test.ts. This is where you'll apply your knowledge of Vitest's fake timers.
Setup:
Remember to enable and disable fake timers for each test to ensure isolation.
// src/operators/delay.test.ts
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
import { of } from '../operators/of'; // Assuming you have this
import { Observable } from '../observable';
import { delay } from './delay';
describe('delay', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
// ... your test cases go here
});
Test Cases to Implement:
-
Delay
nextemissions:- Create an observable
of(1, 2). - Pipe it through
delay(500). - Subscribe with a mock observer:
const observer = { next: vi.fn() };. - Assert
observer.nexthas not been called yet. - Advance time by 499ms:
vi.advanceTimersByTime(499);. Assert it's still not called. - Advance time by 1ms:
vi.advanceTimersByTime(1);. Assertobserver.nexthas been called with1and then with2.
- Create an observable
-
Delay the
completenotification:- Use
of(1)piped throughdelay(200). - Subscribe with a mock observer that has
nextandcompletespies. - Advance time by 200ms.
- Assert that
next(1)was called, and thencomplete()was called.
- Use
-
Delay the
errornotification:- Create an observable that errors immediately:
const source$ = new Observable(obs => obs.error(new Error('fail')));. - Pipe it through
delay(300). - Subscribe with a mock observer that has an
errorspy. - Advance time by 300ms.
- Assert that
error()was called with the correct error object.
- Create an observable that errors immediately:
-
Unsubscription should prevent emissions:
- Use
of(1)piped throughdelay(1000). - Subscribe with a mock
nextspy. - Immediately call
subscription.unsubscribe(). - Run all pending timers to completion:
vi.runAllTimers();. - Assert that the
nextspy was never called. This proves your teardown logic is working correctly.
- Use
4. Broader Context: Marble Testing in RxJS
While Vitest's imperative advanceTimersByTime is powerful, the world of reactive testing has another major paradigm: declarative testing with marble diagrams. This is the standard approach in the RxJS library.
Instead of writing vi.advanceTimersByTime(500), you write a string like "----a--b---|", where each character represents a unit of virtual time and an event. It's a "domain-specific language" (DSL) for describing timed events.
You don't need to implement this now (we will cover it in Module 8), but it's valuable to see how the same delay operator would be tested in RxJS.
This article from the Scott Logic blog provides a fantastic introduction to testing RxJS with its built-in TestScheduler and marble diagrams.
Please read the section 'The Simple Case: Testing the delay operator' and the explanation of the marble syntax that follows. Compare the cold("1-2-3|") and toBe(" 300ms 1-2-(3|)") syntax with how you are using Vitest's timers. This will give you a glimpse into a more advanced testing technique we'll build later.
Seeing this alternative approach highlights a key design choice in testing frameworks: imperative control (Vitest) versus declarative scheduling (RxJS TestScheduler). Both achieve the same goal of deterministic, fast, asynchronous testing.
Conclusion
Great job! You have successfully implemented and tested your first timing operator. This is a significant step, as manipulating time is a cornerstone of reactive programming, enabling features like debouncing, throttling, and timeouts.
Key Takeaways:
- The
delayoperator usessetTimeoutto shift all observable notifications (next,error,complete) forward in time. - Crucially, it must clean up any pending timers on unsubscription to prevent memory leaks and side effects.
- Vitest's fake timers (
vi.useFakeTimers,vi.advanceTimersByTime) are essential for writing fast, deterministic, and reliable tests for time-dependent code. - Testing for unsubscription behavior is just as important as testing for the primary functionality.
In our next lesson, we will build directly on this foundation. We'll implement debounceTime, an operator that discards emissions that are followed too quickly by another, which is extremely useful for handling user input like search-as-you-type fields. You'll find that your new skills with fake timers will be indispensable.