Create your own
Lesson illustration

Testing RxJS Operator Compatibility with Adapters

Hello! Welcome back to our project.

In the last lesson, we built the crucial fromRxJs and toRxJs adapter functions. This was a major step in ensuring our library can coexist with the existing RxJS ecosystem. You even got a sneak peek at RxJS's TestScheduler when we wrote a test for our toRxJs adapter.

Today, we will put those adapters to work to achieve a very high level of confidence in our library's correctness. We will fulfill the learning outcome: Write integration tests using the adapters to verify compatibility with core RxJS operators.

Our goal is to create "golden master" tests. This is a testing strategy where you compare the output of your implementation against a known-good, or "golden," implementation. In our case, RxJS is our golden master. We will verify that for the same input stream, our custom operators produce the exact same output stream, with the exact same timing, as their RxJS counterparts.

1. The "Golden Master" Integration Test Pattern

This testing pattern gives us a powerful way to validate our work. If our map operator behaves identically to RxJS's map operator in a variety of scenarios, we can be very confident that our implementation is correct.

The test for each operator will follow these steps:

  1. Define a source observable using RxJS's TestScheduler and marble diagrams. This is our controlled input signal.
  2. Calculate the expected output: Pipe the source through the official RxJS operator.
  3. Calculate the actual output:
    a. Convert the RxJS source observable to our type using fromRxJs.
    b. Pipe the result through our custom operator.
    c. Convert the final stream back to an RxJS observable using toRxJs.
  4. Assert Equality: Use the RxJS TestScheduler's expectObservable helper to assert that the actual stream's marble diagram is identical to the expected stream's.

This process is conceptually similar to characterizing a custom electronic component: you provide a known input signal and compare its output to that of a standard, reference component to ensure it meets specifications.

2. RxJS Marble Testing with TestScheduler

To perform these tests, we need to be fluent with RxJS's TestScheduler. It's a powerful tool that lets us test complex asynchronous logic in a synchronous, deterministic way by virtualizing time.

You've seen the basics, but let's do a more formal review. Please read the following sections from the official RxJS documentation. They cover the core API and the all-important marble syntax.

Testing RxJS Code with Marble Diagrams

The official RxJS guide on 'Testing RxJS Code with Marble Diagrams' is the definitive resource on this topic. It details the API of TestScheduler and the syntax used to define streams and assertions.

Please read the 'API' and 'Marble syntax' sections. Pay close attention to the run callback helpers (like cold, hot, expectObservable) and the meaning of different marble characters (-, a, |, #, ()).

This knowledge is the foundation for writing effective integration tests. The marble syntax is a compact, visual language for describing events over time.

3. Integration Test for the map Operator

Let's apply the pattern to test our map operator. We'll set up a test file, src/operators/map.integration.test.ts.

Here's the full test. We'll walk through it step-by-step below.

// src/operators/map.integration.test.ts

import { describe, it, expect } from 'vitest';
import { TestScheduler } from 'rxjs/testing';
import { map as rxMap } from 'rxjs/operators';
import { fromRxJs } from '../interop/fromRxJs';
import { toRxJs } from '../interop/toRxJs';
import { map } from './map'; // Our map operator

describe('map operator integration', () => {
  it('should behave the same as RxJS map', () => {
    const scheduler = new TestScheduler((actual, expected) => {
      expect(actual).toEqual(expected);
    });

    scheduler.run(({ cold, expectObservable }) => {
      // 1. Define the source stream and values
      const sourceMarbles = ' -a-b-c-|';
      const sourceValues = { a: 1, b: 2, c: 3 };
      const source$ = cold(sourceMarbles, sourceValues);

      // 2. Define the transformation function
      const transform = (x: number) => x * 10;

      // 3. Calculate EXPECTED output using RxJS operator
      const expected$ = source$.pipe(rxMap(transform));
      const expectedMarbles = '-x-y-z-|';
      const expectedValues = { x: 10, y: 20, z: 30 };

      // 4. Calculate ACTUAL output using our operator and adapters
      const ourResult$ = fromRxJs(source$).pipe(map(transform));
      const actual$ = toRxJs(ourResult$);

      // 5. Assert that the actual output matches the expected marble diagram
      expectObservable(actual$).toBe(expectedMarbles, expectedValues);

      // (Optional but good practice) Also assert the RxJS version behaves as expected
      expectObservable(expected$).toBe(expectedMarbles, expectedValues);
    });
  });
});

Breakdown:

  1. source$: We use the cold helper provided by scheduler.run to create an RxJS observable. The marble diagram '-a-b-c-|' specifies that it will emit value a at frame 10, b at frame 30, c at frame 50, and complete at frame 70. (Remember each - is 10 frames by default in older RxJS, but inside run() it is 1 frame. Let's assume 1-frame timing to be modern.)
  2. expected$: This is our "golden master." We apply the official rxMap to the source. We define the marble diagram we expect this operation to produce.
  3. actual$: This is our implementation under test. We convert the source$ using fromRxJs, apply our own map operator, and convert the result back using toRxJs so it can be consumed by expectObservable.
  4. expectObservable(actual$).toBe(...): This is the core assertion. It subscribes to actual$ and records all its emissions, errors, and completions. When the scheduler flushes at the end of the run block, it compares the recorded event sequence to the expectedMarbles diagram and expectedValues map.

By running this test, we prove that our map operator transforms values just like RxJS's map operator.

4. Integration Test for a Timing Operator: debounceTime

Testing simple transformation operators is useful, but the real power of TestScheduler shines with operators that manipulate time. Let's write a test for our debounceTime operator.

The following article provides excellent, practical examples of testing complex, time-based operators.

Marble diagrams for testing RxJs operators

The article 'Marble diagrams for testing RxJs operators' from EDICOM's tech blog shows a full, real-world example of creating a custom operator and testing it with marble diagrams. It covers scenarios like debouncing, canceling requests, and error handling.

Focus on 'Test 1: Initial value and delayed search'. Notice how they construct the source marble diagram to simulate user typing and how the expected diagram reflects the behavior of debounceTime.

Inspired by that example, let's write our own integration test for debounceTime.

// src/operators/debounceTime.integration.test.ts

import { describe, it, expect } from 'vitest';
import { TestScheduler } from 'rxjs/testing';
import { debounceTime as rxDebounceTime } from 'rxjs/operators';
import { fromRxJs } from '../interop/fromRxJs';
import { toRxJs } from '../interop/toRxJs';
import { debounceTime } from './debounceTime'; // Our operator

describe('debounceTime operator integration', () => {
  it('should behave the same as RxJS debounceTime', () => {
    const scheduler = new TestScheduler((actual, expected) => {
      expect(actual).toEqual(expected);
    });

    scheduler.run(({ cold, expectObservable }) => {
      // 1. Define source stream to simulate rapid emissions
      // 'a' and 'b' are close together. 'c' is emitted after a pause.
      const sourceMarbles = ' -a-b---c|';
      const sourceValues = { a: 'A', b: 'B', c: 'C' };
      const source$ = cold(sourceMarbles, sourceValues);

      const debounceDuration = 20; // 20 virtual milliseconds

      // 2. EXPECTED: 'a' is ignored, 'b' is emitted after the debounce duration.
      // 'c' is also emitted after its debounce duration.
      // Time:               --|--b'-----c'|
      // Frames: 0--10--20--30--40--50--60--70--80
      // Emits:     a   b      c
      // Debounce(20):    ^debounce a
      //                  ^debounce b -> emits 'b' at 30+20=50
      //                             ^debounce c -> emits 'c' at 60+20=80
      const expectedMarbles = '-----b----c|';
      const expectedValues = { b: 'B', c: 'C' };
      const expected$ = source$.pipe(rxDebounceTime(debounceDuration, scheduler));

      // 3. ACTUAL: Our operator
      // Note: we cast the scheduler as it expects our own Scheduler type,
      // but the RxJS TestScheduler is compatible for testing purposes.
      const ourResult$ = fromRxJs(source$).pipe(debounceTime(debounceDuration, scheduler as any));
      const actual$ = toRxJs(ourResult$);
      
      // 4. Assert
      expectObservable(actual$).toBe(expectedMarbles, expectedValues);
      expectObservable(expected$).toBe(expectedMarbles, expectedValues);
    });
  });
});

This test perfectly captures the essence of debounceTime. The emission of a is suppressed because b arrives before the 20ms debounce period ends. The value b is then emitted 20ms after it arrived. Finally, c is emitted 20ms after it arrived because no other values followed it. Proving our operator matches this behavior is a huge win.

Test your understanding!

Let's write the marble diagrams for an integration test of the filter operator.

  • Source Stream: Emits numbers 1, 2, 3, and 4.
    • sourceMarbles: '-a-b-c-d-|'
    • sourceValues: { a: 1, b: 2, c: 3, d: 4 }
  • Operator Logic: filter(x => x % 2 === 0) (only allow even numbers)

What should the expectedMarbles and expectedValues be for the output stream?

Show answer

The filter will discard a (1) and c (3), and allow b (2) and d (4) to pass through at the same time they were emitted.

  • expectedMarbles: '---b---d-|'
  • expectedValues: { b: 2, d: 4 }

Conclusion

Excellent work today. You have established a robust, professional-grade testing strategy for your reactive library. By writing "golden master" integration tests, you are not just checking for crashes; you are verifying that your implementation is functionally identical to the industry standard, RxJS.

Key Takeaways:

  • Golden Master Testing: Comparing your implementation against a reference standard (RxJS) is a powerful way to ensure correctness.
  • RxJS TestScheduler is Essential: It allows for deterministic and synchronous testing of complex asynchronous, time-based logic.
  • Marble Diagrams as a Language: You learned to read and write marble diagrams to describe and assert the behavior of observables over virtual time.
  • The Integration Pattern: You now have a reusable pattern (fromRxJs -> ourOperator -> toRxJs) for testing any operator in your library against its RxJS equivalent.

In our next and final lesson of this module, we will address a critical aspect of observable-based programming: memory management. We will learn how to write a test to detect potential memory leaks from unterminated subscriptions, ensuring our library is not only correct but also robust and efficient.

Can't find a good explanation? Sign up and we'll make it for you

Sign up