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:
- Define a source observable using RxJS's
TestSchedulerand marble diagrams. This is our controlled input signal. - Calculate the
expectedoutput: Pipe the source through the official RxJS operator. - Calculate the
actualoutput:
a. Convert the RxJS source observable to our type usingfromRxJs.
b. Pipe the result through our custom operator.
c. Convert the final stream back to an RxJS observable usingtoRxJs. - Assert Equality: Use the RxJS
TestScheduler'sexpectObservablehelper to assert that theactualstream's marble diagram is identical to theexpectedstream'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:
source$: We use thecoldhelper provided byscheduler.runto create an RxJS observable. The marble diagram'-a-b-c-|'specifies that it will emit valueaat frame 10,bat frame 30,cat frame 50, and complete at frame 70. (Remember each-is 10 frames by default in older RxJS, but insiderun()it is 1 frame. Let's assume 1-frame timing to be modern.)expected$: This is our "golden master." We apply the officialrxMapto the source. We define the marble diagram we expect this operation to produce.actual$: This is our implementation under test. We convert thesource$usingfromRxJs, apply our ownmapoperator, and convert the result back usingtoRxJsso it can be consumed byexpectObservable.expectObservable(actual$).toBe(...): This is the core assertion. It subscribes toactual$and records all its emissions, errors, and completions. When the scheduler flushes at the end of therunblock, it compares the recorded event sequence to theexpectedMarblesdiagram andexpectedValuesmap.
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