Hello! Welcome back to our journey of building a reactive library.
In the last lesson, we built a crucial piece of our testing infrastructure: the parseMarbles function. It allows us to translate expressive marble strings into the TestNotification arrays our TestScheduler understands. This was a big step towards more readable and declarative tests. However, our test code still involves a manual step of calling the parser and passing the result to our assertion.
Today, we will build the final, elegant API that connects all the pieces. This lesson directly addresses the learning outcome: Create an assertion helper to test an observable's output against an expected marble diagram. By the end of this lesson, you will be able to write a test as concisely as this:
expectObservable(myStream).toBe('--a--b|', { a: 10, b: 20 });
This is the standard for testing reactive streams, and having it in our toolkit will make testing our operators a robust and pleasant experience.
1. The Anatomy of a Marble Assertion
Before we jump into code, let's look at the API we're aiming to build. The industry standard, established by RxJS, is a great model to follow.
Testing RxJS Code with Marble Diagrams
The official RxJS documentation for 'Testing RxJS Code with Marble Diagrams' describes the very API we want to create. Let's focus on the expectObservable helper.
Read the description of the expectObservable function. Notice its structure: it takes an observable and returns an object with a .toBe method. This .toBe method then takes the marble string and values to complete the assertion.
This API elegantly splits the assertion process into two logical steps, which our implementation will mirror:
-
Recording (
expectObservable): This step tells the test runner which observable's output we want to capture. Our helper will subscribe to this observable within theTestScheduler's virtual time and record every notification (next,error,complete) along with its virtual timestamp into anactualnotifications array. -
Asserting (
.toBe): This step defines what the output should look like, using a marble diagram. Our helper will use theparseMarblesfunction from our previous lesson to convert this string into anexpectednotifications array. It will then schedule a final assertion to compare theactualandexpectedarrays after all virtual time has passed.
With this mental model, let's implement it.
2. Implementing the expectObservable Helper
We will modify our TestScheduler.ts file to create the new helper. The expectObservable function needs to be defined inside the run method, because it requires access to the scheduler's context (like the current frame and the assertions queue).
Here is the updated TestScheduler.run method. I've included comments to explain each part of the new expectObservable logic.
// src/testing/TestScheduler.ts
import { Observable } from '../core/Observable';
import { Scheduler } from '../types';
import { Action, Subscription } from '../core/Subscription';
import { parseMarbles, TestNotification } from './marble-parser';
export class TestScheduler implements Scheduler {
// ... (previous properties: frame, actions, assertions, assertDeepEqual)
// ... (previous methods: constructor, schedule)
public flush(): void {
// ... (flush implementation remains the same)
}
public run(callback: (helpers: {
expectObservable: <T>(source: Observable<T>) => {
toBe: (marbles: string, values?: { [key: string]: T }, error?: any) => void;
};
}) => void) {
// 1. Reset scheduler state for a clean run
this.frame = 0;
this.actions = [];
this.assertions = [];
// 2. Define and provide the `expectObservable` helper
const helpers = {
expectObservable: <T>(source: Observable<T>) => {
// This array will store the actual notifications from the source observable.
const actual: TestNotification<T>[] = [];
// Return an object with the .toBe method to chain the assertion.
return {
toBe: (marbles: string, values?: { [key: string]: T }, errorValue?: any) => {
// -- RECORDING PHASE SETUP --
// When .toBe is called, we schedule the subscription to the source.
// This ensures that subscription happens at the very beginning of virtual time (frame 0).
this.schedule(() => {
source.subscribe({
next: (value: T) => {
// When a value is emitted, record it with the current virtual frame.
actual.push({ frame: this.frame, kind: 'N', value });
},
error: (err: any) => {
// Record an error notification.
actual.push({ frame: this.frame, kind: 'E', error: err });
},
complete: () => {
// Record a complete notification.
actual.push({ frame: this.frame, kind: 'C' });
},
});
}, 0);
// -- ASSERTION PHASE SETUP --
// Use our parser from the last lesson to get the expected notifications.
const expected = parseMarbles(marbles, values, errorValue);
// Schedule the final assertion. This function will be called by flush()
// *after* all other actions have been executed.
this.assertions.push(() => this.assertDeepEqual(actual, expected));
},
};
},
};
// 3. Execute the user's test logic with the provided helpers
callback(helpers);
// 4. Flush the scheduler to run all actions and assertions
this.flush();
}
}
Let's break down the most important part of this implementation: the timing.
- The user calls
expectObservable(source).toBe(...)inside theruncallback. This happens synchronously before virtual time begins. - The call to
.toBedoes two things:- It schedules the subscription to the
sourceobservable to happen atframe: 0. - It schedules the final assertion (comparing
actualvsexpected) to happen after all frames have been processed.
- It schedules the subscription to the
- When
runcallsthis.flush(), the simulation starts. The subscription action is executed at frame 0. As the source observable emits values (perhaps due to scheduled delays), our observer records them into theactualarray, capturing thethis.frameat the moment of emission. - After all scheduled actions are complete,
flushruns the assertion we pushed, comparing the fully populatedactualarray with theexpectedarray from the marble diagram.
This ensures that we capture the entire lifecycle of the observable within the virtual timeline and assert against it correctly.
Test your understanding!
Imagine we have a simple map operator. Consider the following test:
scheduler.run(({ expectObservable }) => {
const source = of('a', 'b'); // Emits 'a', 'b', then completes synchronously
const stream = source.pipe(map(char => char.toUpperCase()));
expectObservable(stream).toBe('(AB|)');
});
What will the actual and expected notification arrays look like when the final assertion is run?
Show answer
Both actual and expected will be identical:
[
{ "frame": 0, "kind": "N", "value": "A" },
{ "frame": 0, "kind": "N", "value": "B" },
{ "frame": 0, "kind": "C" }
]
Explanation:
of('a', 'b')emits its values and completes synchronously at frame 0.- The
mapoperator is also synchronous, so it transforms 'a' to 'A' and 'b' to 'B' at frame 0. - The
completenotification is passed through at frame 0. - Our observer inside
expectObservablecaptures all these events atthis.framewhich is 0. - The
parseMarbles('(AB|)')function also generates an array of notifications all occurring at frame 0, resulting in a passing test.
3. Finalizing Our Test with the New Helper
Now for the rewarding part. Let's refactor the delay operator test from our previous lesson to use our new, polished assertion helper.
See how the test becomes a clean, declarative statement about the observable's behavior.
// src/operators/delay.test.ts (Final Version)
import { describe, it, expect } from 'vitest';
import { of } from '../creators/of';
import { TestScheduler } from '../testing/TestScheduler';
import { delay } from './delay';
describe('delay', () => {
it('should delay emissions by the specified duration', () => {
// 1. Create the scheduler with an assertion function from our test framework
const scheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
// 2. Call run() to start the test in a virtual time context
scheduler.run(({ expectObservable }) => {
// 3. Define the source observable and the operator chain to test
const source = of(1);
const delayTime = 200;
const delayedStream = source.pipe(delay(delayTime, scheduler));
// 4. Declare the expected behavior using a marble diagram
const expectedMarbles = '200ms (a|)';
const expectedValues = { a: 1 };
// 5. Assert that the stream behaves as expected
expectObservable(delayedStream).toBe(expectedMarbles, expectedValues);
});
});
});
This is a professional-grade testing pattern. The test is easy to read, clearly states the input (of(1)), the transformation (delay(200)), and the expected output ('200ms (a|)'). We have successfully abstracted away all the implementation details of notification arrays and manual scheduling.
Conclusion
Excellent work today! You have now implemented the centerpiece of a reactive testing library: a beautiful and ergonomic expectObservable(...).toBe(...) assertion helper. By combining our TestScheduler, parseMarbles function, and this new helper, you have a complete, powerful system for testing complex asynchronous logic deterministically.
Key Takeaways:
- Assertion API Design: You've implemented a fluent API (
expectObservable(...).toBe(...)) that mirrors the industry standard set by RxJS, promoting readability. - Recording and Asserting: The process is split into two phases: scheduling a subscription to record
actualevents and scheduling an assertion to compare them againstexpectedevents from a marble diagram. - Declarative Testing: Your tests now clearly declare what behavior is expected, rather than imperatively describing how to check for it. This makes tests easier to write, read, and maintain.
In our next lesson, we'll start looking outward. Now that we have a solid foundation for our own library, we will explore interoperability. We will implement adapters to convert between your library's observables and RxJS observables, a crucial step for ensuring your library can be adopted in projects that already use the RxJS ecosystem.
Can't find a good explanation? Sign up and we'll make it for you
Sign up