Create your own
Lesson illustration

Testing the Observable Lifecycle

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.

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.

  1. Create a new file: src/Observable.test.ts.
  2. Import the necessary tools from vitest and our own Observable class.
  3. 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:

  1. When unsubscribe is called, the teardown logic returned by the producer is executed.
  2. 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 mock next, error, and complete methods 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 async and return a Promise that 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 involve setInterval or setTimeout, 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