Hello! Welcome to the fifth lesson in our "Core Observable Architecture" module.
In our last lesson, we assembled the final pieces of our core observable implementation: the subscribe method and the internal SafeSubscriber. We now have a complete, albeit untested, system for creating, subscribing to, and tearing down observable streams.
As a lead developer, you know that untested code is unreliable code. Before we can confidently build a rich library of operators on top of this foundation, we must rigorously verify its behavior.
This lesson is dedicated to achieving the learning outcome: Write unit tests for the complete observable lifecycle: subscription, synchronous/asynchronous emission, error, completion, and unsubscription.
We will use Vitest to create a comprehensive test suite that validates every aspect of our Observable class, ensuring it is robust and correct.
1. The Tool for the Job: Mock Functions
To test our observable, we need a way to "spy" on the Observer we pass to the subscribe method. We need to confirm that its next, error, and complete methods are called with the right values, at the right times, and in the right order.
The perfect tool for this is a mock function. Vitest, with its Jest-compatible API, provides the vi.fn() utility to create these spies. We can then inspect a mock function's call history, arguments, and more.
Let's begin by reading the official documentation to understand the capabilities of mock functions.
The 'Mock Functions' documentation from Jest (which Vitest's API mirrors) is our guide for this lesson. It explains how to create mocks and inspect their behavior.
Please read the introduction ('Mock Functions') and the 'Reference' section covering .mock.calls and .mock.results. Focus on understanding how a mock function records every call made to it, which is exactly what we need to verify our observer's behavior.
2. Setting Up the Test File
Let's start by creating our test file and setting up a mock observer.
- Create a new file:
src/Observable.test.ts. - Import the necessary tools from
vitestand our ownObservableclass. - Define a mock observer before each test.
// src/Observable.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Observable } from './Observable';
import type { Observer } from './Observer';
describe('Observable', () => {
let mockObserver: Observer<unknown>;
beforeEach(() => {
mockObserver = {
next: vi.fn(),
error: vi.fn(),
complete: vi.fn(),
};
});
// Our tests will go here
});
This structure gives us a fresh mockObserver for every it block, preventing tests from interfering with each other.
3. Testing the Synchronous Lifecycle
Let's start with the simplest cases: observables that emit all their notifications synchronously.
Test 1: Synchronous Emission and Completion
We'll create an observable that emits two values and then completes.
// Add this 'it' block inside the 'describe' block
it('should emit values and complete for a synchronous producer', () => {
// Arrange
const source$ = new Observable<number>(observer => {
observer.next(1);
observer.next(2);
observer.complete();
});
// Act
source$.subscribe(mockObserver);
// Assert
expect(mockObserver.next).toHaveBeenCalledTimes(2);
expect(mockObserver.next).toHaveBeenCalledWith(1);
expect(mockObserver.next).toHaveBeenCalledWith(2);
expect(mockObserver.complete).toHaveBeenCalledTimes(1);
expect(mockObserver.error).not.toHaveBeenCalled();
});
Test 2: Synchronous Error Handling
Now, let's test an observable that emits an error. The observable contract dictates that complete should not be called if an error occurs.
// Add this 'it' block
it('should call the error method when the producer throws synchronously', () => {
// Arrange
const testError = new Error('Test Error');
const source$ = new Observable<number>(observer => {
observer.error(testError);
});
// Act
source$.subscribe(mockObserver);
// Assert
expect(mockObserver.error).toHaveBeenCalledTimes(1);
expect(mockObserver.error).toHaveBeenCalledWith(testError);
expect(mockObserver.next).not.toHaveBeenCalled();
expect(mockObserver.complete).not.toHaveBeenCalled();
});
Test 3: Enforcing the Observable Contract
Our SafeSubscriber is designed to stop notifications after an error or completion. Let's write a test to prove it works.
// Add this 'it' block
it('should not emit any values after completion', () => {
// Arrange
const source$ = new Observable<number>(observer => {
observer.next(1);
observer.complete();
observer.next(2); // This should be ignored
});
// Act
source$.subscribe(mockObserver);
// Assert
expect(mockObserver.next).toHaveBeenCalledTimes(1);
expect(mockObserver.next).toHaveBeenCalledWith(1);
expect(mockObserver.complete).toHaveBeenCalledTimes(1);
});
4. Testing the Asynchronous Lifecycle
Testing asynchronous code can be tricky. A test function might finish executing before the asynchronous operation (like a setTimeout) has a chance to run, leading to false positives.
RxJS Testing — Write Unit Tests for Observables
The article 'RxJS Testing' briefly demonstrates this exact problem. While we won't be using the RxJS-specific tools mentioned, the initial example clearly shows why a naive approach to async testing fails.
Read the short section 'Naïve Beginners Example' — the initial approach. Note how the test initially 'passes' without any expectations, and how making the test asynchronous is the first step toward a correct solution.
Vitest handles this elegantly. If you return a Promise from a test, Vitest will wait for that promise to resolve or reject before finishing the test. We can combine this with the async/await syntax to write clean, readable asynchronous tests.
Test 4: Asynchronous Emission
We'll create an observable that emits a value after a short delay. The test will return a Promise that resolves inside the complete handler.
// Add this 'it' block
it('should handle asynchronous emission of values', async () => {
// Arrange
const source$ = new Observable<string>(observer => {
setTimeout(() => {
observer.next('async value');
observer.complete();
}, 10);
});
// Act & Assert
await new Promise<void>(resolve => {
source$.subscribe({
next: value => {
expect(value).toBe('async value');
expect(mockObserver.next).not.toHaveBeenCalled(); // Ensure our mock wasn't used
},
error: err => {
// This shouldn't be called
},
complete: () => {
// We can do final checks here if needed
resolve();
},
});
});
});
Note: In this test, we provide a new observer inline to create the Promise. This pattern is very common for testing asynchronous observables.
5. Testing Unsubscription and Teardown
A crucial feature of our Observable is its ability to clean up resources. We must test two things:
- When
unsubscribeis called, the teardown logic returned by the producer is executed. - After unsubscribing, the observer no longer receives any notifications.
Test 5: Verifying Teardown Logic
We'll create a mock function for our teardown logic and assert that it's called upon unsubscription.
// Add this 'it' block
it('should call the teardown function on unsubscription', () => {
// Arrange
const teardownFn = vi.fn();
const source$ = new Observable<void>(observer => {
// Return the teardown function from the producer
return teardownFn;
});
// Act
const subscription = source$.subscribe(mockObserver);
subscription.unsubscribe();
// Assert
expect(teardownFn).toHaveBeenCalledTimes(1);
});
Test 6: Stopping Notifications on Unsubscription
For this test, we need to control the flow of time to verify that an interval-based observable stops emitting after we unsubscribe. Vitest provides fake timers for this.
// Add these 'it' blocks
it('should stop emitting values after unsubscription', () => {
// Arrange
vi.useFakeTimers(); // Tell Vitest to control time
const source$ = new Observable<number>(observer => {
let i = 0;
const intervalId = setInterval(() => {
observer.next(i++);
}, 100);
return () => clearInterval(intervalId);
});
// Act
const subscription = source$.subscribe(mockObserver);
// Advance time by 250ms, allowing two emissions
vi.advanceTimersByTime(250);
subscription.unsubscribe();
// Advance time again. No more emissions should occur.
vi.advanceTimersByTime(500);
// Assert
expect(mockObserver.next).toHaveBeenCalledTimes(2);
expect(mockObserver.next).toHaveBeenCalledWith(0);
expect(mockObserver.next).toHaveBeenCalledWith(1);
// Cleanup
vi.useRealTimers(); // Restore real timers
});
This test beautifully demonstrates the complete lifecycle: subscription starts the setInterval, emissions are received, unsubscription triggers the clearInterval teardown, and no further values are processed.
Conclusion
Excellent work! We have now built a comprehensive test suite that validates the entire lifecycle of our Observable class. By leveraging mock functions and Vitest's async and timer-mocking capabilities, we have proven that our core implementation is reliable.
Key Takeaways:
- Mock Observers: Using
vi.fn()to create mocknext,error, andcompletemethods is the standard way to test observable outputs. - Synchronous Testing: For synchronous producers, you can subscribe and immediately assert the state of your mock observer.
- Asynchronous Testing: For asynchronous producers, tests should be
asyncand return aPromisethat resolves upon completion or error, allowing you to perform assertions at the correct time. - Teardown Verification: Mock functions are also perfect for verifying that the cleanup logic returned by a producer is executed upon unsubscription.
- Time-based Testing:
vi.useFakeTimers()is an essential tool for testing observables that involvesetIntervalorsetTimeout, allowing you to control time and verify behavior like unsubscription.
Preview of the Next Lesson:
With a robust and well-tested Observable at our disposal, we are ready to add the most powerful feature of reactive programming: operators. In the next lesson, we will implement the pipe method, which will serve as the foundation for chaining operators together to transform, filter, and combine our data streams.
Can't find a good explanation? Sign up and we'll make it for you
Sign up