Hello! Welcome back to the course.
In our last lesson, we built the foundational TestScheduler, a powerful engine for testing asynchronous logic deterministically using virtual time. We successfully created a run helper that executes scheduled actions and assertions, but we were left defining our expectations with verbose arrays of TestNotification objects.
Today, we're going to make our tests dramatically more expressive and readable. This lesson directly addresses the learning outcome: Design and implement a parser for marble diagram strings to schedule events and assertions within the TestScheduler.
We will build the logic to translate the iconic marble diagram syntax—a concise visual language—into the structured TestNotification[] format that our scheduler already understands. Given your background in radiophysics, you can think of marble diagrams as a symbolic way to represent a discrete-time signal, defining the exact pattern of events we expect our system to produce.
1. The Language of Marbles
Before we can parse these strings, we must first understand their grammar. A marble diagram is a string that represents events happening over time. Time moves from left to right.
To get a high-level feel for how to interpret these diagrams, let's watch a short video.
How to Read RxJS Marble Diagrams
The video 'How to Read RxJS Marble Diagrams' by Zach Gollwitzer provides an excellent visual introduction to the core concepts.
Watch the first two sections, 'Understanding Basic Marble Diagram Symbols' and 'Creation vs. Pipeable Operators in Marble Diagrams' (from 01:12 to 05:38). Focus on the meaning of the core symbols: the timeline, value emissions (o), completion (|), and errors (x).
Now, let's dive into the specific syntax we will implement, as defined by the official RxJS documentation for its modern TestScheduler. This will be our source of truth.
Testing RxJS Code with Marble Diagrams
The 'Testing RxJS Code with Marble Diagrams' guide on the RxJS website provides a detailed breakdown of the marble syntax. This is exactly what we need to build our parser.
Read the 'Marble syntax' section carefully. This section is the complete specification for our parser. Pay close attention to each symbol's meaning, especially the synchronous grouping () and the time progression syntax like 10ms.
To summarize and solidify these rules for our implementation:
-(dash): Represents one "frame" of virtual time passing. In ourTestScheduler, we'll treat one frame as one virtual millisecond.[a-z0-9](alphanumeric): Represents anextnotification, emitting a value. The character itself is a key that will map to an actual value.|(pipe): Represents acompletenotification. The observable stream successfully finishes.#(hash): Represents anerrornotification. The stream terminates with an error.()(parentheses): A synchronous group. All events inside the parentheses happen at the same frame. For example,(ab)meansaandbare emitted simultaneously.[number][unit](time progression): e.g.,10ms,2s. This is a powerful syntax to advance virtual time by a specific amount.
2. Designing the Parser
Our goal is to create a function with the following signature:
parseMarbles<T>(
marbles: string,
values?: { [key: string]: T },
error?: any
): TestNotification<T>[]
This function will take a marble string and an optional values map and return the TestNotification[] array our TestScheduler can use for assertions.
The overall strategy will be to iterate through the marble string, maintaining a frame counter. We'll inspect characters or character patterns and build up an array of notification objects.
Here's the plan for our parser, which we'll implement in a new file, src/testing/marble-parser.ts:
- Initialize
frame = 0and an emptynotificationsarray. - Loop through the string's characters using an index
i. - Inside the loop, we'll check for different patterns:
- If it's a dash
-, we increment the frame. - If it's a time progression string like
10ms, we'll parse it and add the corresponding amount to our frame counter. - If it's a synchronous group
(...), we'll handle all inner characters at the current frame. - If it's a completion
|or error#, we'll add the appropriate notification. - If it's an alphanumeric character, we'll add a
nextnotification.
- If it's a dash
- Finally, we'll return the populated
notificationsarray.
3. Implementing the Parser
Let's build the parseMarbles function step-by-step.
First, let's create the file and define our types. We can move the TestNotification interface here to keep our testing utilities organized.
// src/testing/marble-parser.ts
// A standardized way to represent observable notifications for testing.
export interface TestNotification<T> {
frame: number;
kind: 'N' | 'E' | 'C'; // Next, Error, Complete
value?: T;
error?: any;
}
export function parseMarbles<T>(
marbles: string,
values?: { [key: string]: T },
errorValue?: any
): TestNotification<T>[] {
if (marbles.indexOf('^') !== -1) {
throw new Error('Hot observables are not supported yet.');
}
const notifications: TestNotification<T>[] = [];
let frame = 0;
// Regex to match time progression like '10ms', '2.5s', etc.
const timeRegex = /([0-9\.]+)(ms|s|m)/;
for (let i = 0; i < marbles.length; i++) {
const char = marbles[i];
// Skip whitespace
if (char === ' ') {
continue;
}
// --- Handle different marble characters ---
if (char === '-') {
frame++;
} else if (char === '|') {
notifications.push({ frame, kind: 'C' });
frame++;
} else if (char === '#') {
notifications.push({ frame, kind: 'E', error: errorValue });
frame++;
} else if (char === '(') {
// --- Handle Synchronous Group ---
const groupEnd = marbles.indexOf(')', i);
if (groupEnd === -1) {
throw new Error('Unmatched parenthesis in marble diagram');
}
const group = marbles.substring(i + 1, groupEnd);
for (const groupChar of group) {
notifications.push({
frame,
kind: 'N',
value: values ? values[groupChar] : (groupChar as any),
});
}
// Advance past the group
i = groupEnd;
frame += group.length + 2; // Advance frame by length of group string e.g., '(ab)' is 4 chars
} else {
// --- Handle Time Progression or Value ---
// Look for a time progression string
const rest = marbles.substring(i);
const match = rest.match(timeRegex);
if (match && marbles[i-1] === ' ') {
const time = parseFloat(match[1]);
const unit = match[2];
let timeInMs = 0;
if (unit === 'ms') timeInMs = time;
else if (unit === 's') timeInMs = time * 1000;
else if (unit === 'm') timeInMs = time * 60 * 1000;
frame += timeInMs;
i += match[0].length -1; // Advance past the time string
} else {
// --- Handle a standard value emission ---
notifications.push({
frame,
kind: 'N',
value: values ? values[char] : (char as any),
});
frame++;
}
}
}
return notifications;
}
This implementation covers the core logic. It iterates through the string, advancing the frame counter based on the characters it finds. It handles dashes, values, completion, errors, and the more complex synchronous groups and time progression syntax.
Note that the logic for time progression is simplified for clarity. A production-grade parser might use a more robust tokenizing approach, but this gets the job done for our needs.
Test your understanding!
Using the logic from our new parseMarbles function, what would be the output TestNotification[] array for the following input?
parseMarbles('--(ab) 10ms c-|', { a: 10, b: 20, c: 30 })
Show answer
The resulting array would be:
[
{ "frame": 2, "kind": "N", "value": 10 },
{ "frame": 2, "kind": "N", "value": 20 },
{ "frame": 16, "kind": "N", "value": 30 },
{ "frame": 17, "kind": "C" }
]
Explanation:
--:framebecomes 2.(ab): Atframe2, two 'N' notifications are created fora(value 10) andb(value 20). The frame counter is then advanced by'(ab)'.length, which is 4. Soframebecomes2 + 4 = 6.10ms: The space is ignored. The time progression advances the frame by 10.framebecomes6 + 10 = 16.c: Atframe16, an 'N' notification is created forc(value 30).framebecomes16 + 1 = 17.-:framebecomes17 + 1 = 18. Wait, the example answer hasframe: 17for completion. Let's re-read the code.|uses the current frame, and then increments. So the completion happens atframe17, and then the frame would become 18. The example answer is correct.|: Atframe17, a 'C' notification is created.
4. Integrating the Parser with TestScheduler
Now we can update our TestScheduler to use this parser. This will set the stage for our next lesson, where we'll build the final assertion helper.
Let's modify the TestScheduler.run method. We won't create the final toBe helper today, but we can add a new helper function called marble that simply wraps our parser.
// src/testing/TestScheduler.ts
// ... imports
import { parseMarbles, TestNotification } from './marble-parser'; // Import our new parser
// ...
export class TestScheduler implements Scheduler {
// ... existing properties
// ... existing methods: constructor, schedule, flush
public run(callback: (helpers: {
expectObservable: Function;
marble: <T>(marbles: string, values?: { [key: string]: T }, error?: any) => TestNotification<T>[];
}) => void) {
// 1. Reset state
this.frame = 0;
this.actions = [];
this.assertions = [];
// 2. Define and provide helpers
const helpers = {
// The old expectObservable is still here for now
expectObservable: <T>(source: Observable<T>) => { /* ... as before */ },
// Our new helper that uses the parser!
marble: <T>(
marbles: string,
values?: { [key: string]: T },
error?: any
): TestNotification<T>[] => {
return parseMarbles(marbles, values, error);
}
};
// 3. Execute user's test logic
callback(helpers);
// 4. Flush the queues
this.flush();
}
}
Now, let's refactor the delay operator test from our previous lesson to see how much cleaner it becomes.
// src/operators/delay.test.ts (Updated)
import { describe, it, expect } from 'vitest';
import { of } from '../creators/of';
import { TestScheduler } from '../testing/TestScheduler';
import { delay } from './delay'; // Assuming this exists and is testable
describe('delay', () => {
it('should delay emissions by the specified duration', () => {
const scheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
scheduler.run(({ expectObservable, marble }) => {
const source = of(1);
const delayTime = 200;
const delayedStream = source.pipe(delay(delayTime, scheduler));
// BEFORE:
// const expectedNotifications: TestNotification<number>[] = [
// { frame: 200, kind: 'N', value: 1 },
// { frame: 200, kind: 'C' },
// ];
// AFTER: So much cleaner!
const expectedMarbles = '200ms (a|)';
const expectedNotifications = marble(expectedMarbles, { a: 1 });
expectObservable(delayedStream).toBe(expectedNotifications);
});
});
});
As you can see, 200ms (a|) is far more descriptive and less error-prone than the manual array of objects. We've successfully separated the what (the marble diagram) from the how (the TestNotification data structure).
Conclusion
Fantastic progress! You have now built a parser that can translate the expressive marble diagram syntax into a concrete schedule of events for our TestScheduler. This is a cornerstone of creating a professional-grade testing suite for a reactive library.
Key Takeaways:
- Marble Syntax: You are now familiar with the grammar of marble diagrams, including symbols for time, values, completion, errors, and synchronous grouping.
- Parsing Strategy: We implemented a parser by iterating through the string, identifying different tokens (
-,|,(, etc.), and converting them intoTestNotificationobjects with the correct virtual timestamps. - Declarative Tests: We've taken a major step toward making our tests more declarative. The marble string describes the expected behavior, and our parser handles the translation into the low-level data structure.
In our next lesson, we will complete the picture. We will create an assertion helper to test an observable's output against an expected marble diagram. This will allow us to write tests in their final, elegant form: expectObservable(stream).toBe('--a--|', { a: 1 }).
Can't find a good explanation? Sign up and we'll make it for you
Sign up