Marble diagrams in redux-observable
In this tutorial, I’ll show you a very interesting way to test epics in redux-observable: by using marble diagrams. This testing method has become possible only recently in version 1 of redux-observable. Although v1 is still technically in alpha, the library itself is quite stable and has been around since April 2016. I’ve used redux-observable in production for nearly a year with no trouble. (Note: since the writing of this article, redux-observable’s v1 has transitioned out of alpha into stable release.)
The end result of this tutorial is a very simple web app that fetches and displays the content of GitHub file URLs. These URLs are what you see in the browser’s address bar when browsing a file in GitHub’s web UI, for example, https://github.com/ReactiveX/rxjs/blob/master/index.js. You can play with the app here. The redux-observable epic we’ll write will be responsible for fetching data from GitHub when user input changes. The code of this tutorial is contained in this repo.
This tutorial does assume that you have a basic understanding of Observables and RxJS. It also assumes that you have read redux-observable’s (very short) documentation. Here are the technical highlights:
- Use TypeScript in production code and modern JavaScript in test code.
- Use the latest version of RxJS (v6) with pipeable operators.
- Use the latest version of redux-observable (v1.0.0-alpha.2) which allows consuming the state as an Observable of state changes.
- Demonstrate unit testing of epics in redux-observable using marble diagrams in Jest.
Quick recap of redux-observable
The nature of side effects runs counter to the purity and memoryless-ness of reducer functions in redux.
However, because no real-world applications can work without side effects, we have to find a way to make them work with redux.
The main idea of all side-effect management approaches in redux is to execute functions that have access to the redux store’s dispatch
method.
These functions can orchestrate and dispatch their own sequence of actions to the store.
However, each approach dresses up the function execution and the inherent statefulness of side effects (e.g. waiting for an API call to finish before dispatching the “done” action to the store) in a different way.
There are currently many ways to do this, three of which I’ll summarize here.
The imperative solution is thunks, implemented by the redux-thunk
middleware.
Thunks are just plain old functions that receive the store’s dispatch
function as a parameter.
When the store receives a thunk, the store executes it, which uses dispatch
to create its own sequence of actions.
Although thunks are the easiest of the three to understand, unit testing thunks is tricky because it involves setting up an entire redux store.
You can read more about the shortcomings of redux-thunk here.
In contrast, the redux-saga middleware is a declarative solution in which the application dispatches plain-object actions instead of functions.
Statefulness is contained inside generator functions called sagas, which are executed when agreed-upon actions are dispatched from the application code.
Side effects are described by effects, which are plain objects that contain instructions to the middleware to perform certain tasks.
For example, suppose that the side effect is fetching from an API by calling fetchFromServer(a, b, c)
.
When the application dispatches, say, {type: 'Fetch'}
actions, the saga will run and emit an “function invocation” effect like this: yield call(fetchFromServer, a, b, c)
, causing the middleware to execute fetchFromServer(a, b, c)
.
Unit testing a saga is fairly easy because it only involves asserting on the effects emitted by that saga and doesn’t require setting up a redux store.
The redux-observable middleware is another declarative solution. Like redux-saga, it uses plain-object actions. However, unlike redux-saga, to manage statefulness, redux-observable uses Observables, which are really just object wrappers around functions that take no arguments but “return” many values. redux-observable treats actions (dispatched by the application) and redux state changes as streams of events over time. Developers create a root epic, which is a single Observable that can in turn contain many child epics via composition, to transform and combine these streams using the machinery of RxJS into a single final stream of desired actions that will be dispatched to the store. If you think of the actions and state changes as streams of water then the root epic is a system of pipes that splits, channels and combines those streams to produce a single output stream. redux-observable is the perfect library to coordinate complex sequences of events that may overlap while avoiding race conditions. However, prior to v1 of redux-observable, unit testing epics without setting up a redux store was quite difficult, as shown by this long running GitHub issue. Furthermore, because epics represent streams of events over time, it can be difficult for beginners to instinctively understand how they work. This has changed for the better with the v1 release.
Pipeable operators in RxJS 6
The most important change from from version 5 to version 6 of RxJS is the replacement of instance-based operators with pipeable operators, which I think was a great decision by their team. The practical effect of that change on this tutorial is that if you’ve used RxJS 5 before and are accustomed to using instance-based operators (i.e. available on each Observable instance) like this:
1const output$ = source$.filter(x => x > 10);
just know that they have been replaced by pipeable counterparts that need to be imported from rxjs/operators
:
1import {2 filter,3} from 'rxjs/operators'45const output$ = source$.pipe(6 filter(x => x > 10)7);
Marble diagrams
Marble diagrams are spatial representations of temporal event streams in RxJS.
They are probably the most intuitive way to visualize RxJS operators.
For example, this interactive marble diagram on rxmarbles.com is the graphical representation of how the filter
operator works.
In marble diagrams, each observable is represented by a timeline in which time flows from left to right.
Each circle, called a marble, is an event that can optionally be associated with a value e.g. 2, 30, 22 etc in that diagram.
Looking at the resulting Observable (bottom timeline), we can see that the effect of the filter
operator is to remove values less than or equal to 10 from the source Observable (top timeline).
Because these diagrams are so easy to understand, they are great for visually testing whether epics behave in the intended manner. However, because we can’t use those colorful pictures in tests, there’s an equivalent text-based syntax. Although this syntax can represent many types of events, we’ll only use a few in this tutorial:
- Within a marble string, whitespaces have no significance within marble diagrams. They are used mostly to vertically align diagrams for ease of viewing.
- A dash
'-'
represents one “frame,” which is the unit of time in marble tests. - An alphanumeric character (
[a-zA-Z0-9]
) represents a normal event. The value of this event can either be the actual character itself or some other value if a mapping of the character to that other value is provided. Each character is considered emitted at the start of the frame whose position is occupied by that character. Except for synchronous grouping (next bullet point), if a frame is occupied by a character, that frame cannot accommodate any other character. - Parentheses
'()'
represents synchronous grouping of events. All events enclosed within a pair of parentheses are considered emitted at the same time as the opening parenthesis. - A pipe
|
represents the successful completion of an Observable. - A hash sign
'#'
represents an error that terminates an Observable. The error can also be associated with a value (shown later).
Let’s try to recreate the colorful marble diagrams of filter
above using the text-based syntax in a unit test.
Because RxJS only provides marble testing functionality for Jasmine, we’ll use the excellent rxjs-marbles library, which provides a wrapper so that we can test marble diagrams in Jest:
1// https://github.com/huy-nguyen/redux-observable-marble-diagrams/blob/98861df6/src/__tests__/basic.js2import {3 marbles,4} from 'rxjs-marbles/jest';5import {6 filter,7} from 'rxjs/operators';89it('filter operator', marbles(m => {10 const values = {11 a: 2,12 b: 30,13 c: 22,14 d: 5,15 e: 60,16 f: 1,17 };18 const source = m.cold(' -a-b-c-d-e-f---|', values);19 const expected = m.cold('---b-c---e-----|', values);20 const actual = source.pipe(21 filter(x => x > 10),22 );23 m.expect(actual).toBeObservable(expected);24}));
A few points to note in this test:
- We wrap our test inside the
marbles
function provided by rxjs-marbles.marbles
provides us with access to the functionality of RxJS’s built-in TestScheduler, which allows us to create a “cold” Observable withm.cold()
and to assert that the resulting Observable matches our expectation withm.expect(...).toBeObservable(...)
. - We add two spaces at the beginning of
source
’s marble string so that it aligns withexpected
’s marble string. - We use
|
to mark the end of both Observables. - We associate the marbles
a
,b
,c
etc with numeric values 2, 30, 22 etc by passing a mapping (values
) from the marble character to their corresponding values to the observable creator (m.cold
). It is these numeric values that are passed to the predicate function inside thefilter
operator.
At this point, the repo should look like commit 98861df6.
If you run yarn run test
, the marble test should pass.
To see more marble diagrams, check out commit 6f944998 where I’ve written marble tests for most RxJS operators used in this tutorial.
Unlike the diagram in the test for filter
, the diagrams in these additional tests are not intended to replicate those on rxmarbles.com.
A few things to note from these additional tests:
- In the test for
map
, we didn’t provide any values for either the letters (a
,b
,c
) so their emitted values are the actual letters or numbers themselves:
1// https://github.com/huy-nguyen/redux-observable-marble-diagrams/blob/6f944998/src/__tests__/basic.js2const source = m.cold(' -a-b-c--|');3const expected = m.cold('-A-B-C--|');4const actual = source.pipe(5 map(x => x.toUpperCase())6);
- The tests for
switchMap
andconcat
show how whitespaces are very handy for visualizing temporal alignment of Observables. It’s important to remember that each Observable doesn’t start emitting until the first non-whitespace character in its marble diagram:
1// https://github.com/huy-nguyen/redux-observable-marble-diagrams/blob/6f944998/src/__tests__/basic.js2const source1 = m.cold(' --a--b--|');3const source2 = m.cold(' c---d---|');4const expected = m.cold('--a--b--c---d---|');5const actual = concat(source1, source2);
- The test for
debounceTime
demonstrates how to properly marble test time-related operators. Each frame in the marble string corresponds to one “time unit” (whatever that unit actually is). In this test, we want to ensure that the source observable is debounced so that the resulting Observable doesn’t emit any event unless the source Observable has been quiet for 4 frames. Note that because timing in a marble test is controled by RxJS’s ownTesScheduler
(exposed asm.scheduler
by themarbles
wrapper), we need to pass it to thedebounceTime
operator. Without this custom scheduler,debounceTime
will use the regular scheduler and actually debounce for 4 milliseconds, surely causing our test to fail.
1// https://github.com/huy-nguyen/redux-observable-marble-diagrams/blob/6f944998/src/__tests__/basic.js2const source = m.cold(' -a-a-a-a---------|');3const expected = m.cold('-----------a-----|');4const actual = source.pipe(5 debounceTime(4, m.scheduler)6);
- The test for the
catchError
operator shows how to test errors in marble diagrams with the third argument tom.cold
.
1// https://github.com/huy-nguyen/redux-observable-marble-diagrams/blob/6f944998/src/__tests__/basic.js2const errorMessage = 'This is an error!';3const error = {4 name: 'Error',5 message: errorMessage,6};7// ...8const source = m.cold(' --a---|', values, error);9// ...10const expectedUncaught = m.cold('--#', values, error);11// ...12const actualUncaught = source.pipe(13 map(() => {14 throw new Error(errorMessage);15 })16);
- Note that to test the
retry
operator, we created a special Observable (getRetryTestObservable
) that will error for the firstn
subscriptions before behaving normally afterward.
1// https://github.com/huy-nguyen/redux-observable-marble-diagrams/blob/6f944998/src/testUtils.ts2export const getRetryTestObservable = <Error, Success>(3 errors: Error[],4 success: Success,5 ) =>6 () => {78 const errorsCopy = [...errors];9 const customObservable = new Observable((observer) => {10 const error = errorsCopy.shift();11 if (error !== undefined) {12 observer.error(error);13 } else {14 observer.next(success);15 observer.complete();16 }17 });18 return customObservable;19};
And here’s how we use it:
1// https://github.com/huy-nguyen/redux-observable-marble-diagrams/blob/6f944998/src/__tests__/basic.js2const errorMessage = 'This is an error';3// ...4const values = {5 x: successMessage,6};7const source = m.cold(' -a-|', values);8const expected1 = m.cold('-x-|', values);9// ...10// This observable will error on the first 2 subscriptions11// but succeed on the third subscription onward.12const failTwiceThenSucceed = getRetryTestObservable(13 [errorMessage, errorMessage], successMessage14)();1516const actual1 = source.pipe(17 switchMap(() => failTwiceThenSucceed.pipe(retry(2))),18);19m.expect(actual1).toBeObservable(expected1);
Redux store and reducer
Let’s talk briefly about the redux store, reducer and actions used in this tutorial.
They are all pretty simple.
The store is split into two substores: url
holding the URL input from the user and result
holding the fetch result from GitHub, which can be either “initial” (blank user input), “success” with fetched file content, “failure” with error message or “fetch in progress”.
1// https://github.com/huy-nguyen/redux-observable-marble-diagrams/blob/2c11ace1/src/types.ts2export interface RootState {3 url: string;4 result: FetchResult;5}67export enum FetchStatus {8 Initial = 'Initial',9 InProgress = 'InProgress',10 Failed = 'Failed',11 Success = 'Success',12}1314export type FetchResult = {15 status: FetchStatus.Initial,16} | {17 status: FetchStatus.InProgress,18} | {19 status: FetchStatus.Failed,20 message: string;21} | {22 status: FetchStatus.Success,23 data: string;24};
The actions are Flux Standard Actions that correspond to either URL updates or fetch status updates (success, fail, start etc).
1// https://github.com/huy-nguyen/redux-observable-marble-diagrams/blob/2c11ace1/src/types.ts2export enum ActionTypes {3 FETCH_BEGIN = 'FETCH_BEGIN',4 FETCH_SUCCESS = 'FETCH_SUCCESS',5 FETCH_FAIL = 'FETCH_FAIL',6 FETCH_INITIAL = 'FETCH_INITIAL',7 UPDATE_URL = 'UPDATE_URL',8}910export interface FetchBeginAction {11 type: ActionTypes.FETCH_BEGIN;12}1314export interface FetchSuccessAction {15 type: ActionTypes.FETCH_SUCCESS;16 payload: {17 data: string;18 };19}2021export interface FetchFailAction {22 type: ActionTypes.FETCH_FAIL;23 payload: {24 message: string;25 };26}2728export interface FetchInitialAction {29 type: ActionTypes.FETCH_INITIAL;30}3132export interface UpdateURLAction {33 type: ActionTypes.UPDATE_URL;34 payload: {35 url: string;36 };37}3839export type Action =40 FetchBeginAction | FetchSuccessAction | FetchFailAction | FetchInitialAction |41 UpdateURLAction;
I won’t show the reducer here because it’s very simple and not very essential to this tutorial but you can see it here .
Creating user input epic
Now we have all the ingredients to create and test a redux-observable epic. Starting in version 1 of redux-observable, each epic is passed two parameters:
action$
: an Observable representing a stream of dispatched actions after these actions have been processed by the reducer.state$
: an Observable representing a stream of Redux state changes.
From these, each epic has to return a new Observable representing a stream of actions to be dispatched to the store.
Because our epic will manage data fetching from GitHub and we only want to fetch data when the URL changes, the first thing we want our epic to do is to only respond to UpdateURLAction
s by using the filter
operator:
1// https://github.com/huy-nguyen/redux-observable-marble-diagrams/blob/2c11ace1/src/epic.ts2const isUpdateURLAction = (action: Action): action is UpdateURLAction =>3 action.type === ActionTypes.UPDATE_URL;45export const epic =6 (action$: Observable<Action>) =>7 action$.pipe(8 filter<Action, UpdateURLAction>(isUpdateURLAction),9 );
In case you’re wondering, the strange-looking type annotation action is UpdateURLAction
for isUpdateURLAction
is a user-defined type guard, which is needed to satisfy the type checker due to the type definition of filter
in RxJS.
Below is the marble test for the epic we’ve just written.
We can see that the epic has filtered out action b
because it is not an UpdateURLAction
:
1// https://github.com/huy-nguyen/redux-observable-marble-diagrams/blob/2c11ace1/src/__tests__/epic.js2test('Should only act on UpdateURLAction', marbles(m => {3 const values = {4 a: {type: ActionTypes.UPDATE_URL},5 b: {type: 'Unknown'},6 };7 const action$ = m.cold(' -a-b-a-aaa----------|', values);8 const expected$ = m.cold('-a---a-aaa----------|', values);910 const actual$ = epic(action$);1112 m.expect(actual$).toBeObservable(expected$);13}));
The repo is now at commit 2c11ace1.
Next, because the URL updates originate from user input, we should debounce them with the debounceTime
operator so that we don’t wastefully send out AJAX requests for every keystroke.
Because timing works differently in marble testing than in production as shown above, we should allow the time duration and scheduler to be injectable so that the operator behaves normally during production but is completely controlled by the TestScheduler
during testing.
An easy way to do dependency injection is to replace epic
with a function getEpic
that receives parameters to customize the epic for testing but uses default values when in production:
1// https://github.com/huy-nguyen/redux-observable-marble-diagrams/blob/59546414/src/epic.ts2export const getEpic = (3 // These arguments allow for dependency injection during testing:4 dueTime: number = 250,5 // Note: `undefined` scheduler passed to `debounceTime` in production means6 // it'll use the "natural" scheduler:7 scheduler: Scheduler | undefined = undefined,8 ) =>9 (action$: Observable<Action>) =>10 action$.pipe(11 filter<Action, UpdateURLAction>(isUpdateURLAction),12 debounceTime(dueTime, scheduler),13 );
Below is our updated marble test.
Note that the five UpdateURLAction
s in the last version have been debounced into only one in this version:
1// https://github.com/huy-nguyen/redux-observable-marble-diagrams/blob/59546414/src/__tests__/epic.js2import {3 getEpic,4} from '../epic';5test('Should only act on UpdateURLAction', marbles(m => {6 const values = {7 a: {type: ActionTypes.UPDATE_URL},8 b: {type: 'Unknown'},9 };10 const action$ = m.cold(' -a-b-a-aaa----------|', values);11 const expected$ = m.cold('-------------a------|', values);1213 // Inject `dueTime` and `TestScheduler` into epic:14 const epic = getEpic(4, m.scheduler);1516 const actual$ = epic(action$);1718 m.expect(actual$).toBeObservable(expected$);19}));
One immediate advantage of dependency injection is that we can choose a very small debounce duration of four “frames” for ease of testing. The repo is now at commit 59546414.
Access redux state from state$ stream
In order to make a fetch request, we need to get the GitHub URL from which to fetch a file’s content.
We could easily have gotten it from the payload of UpdateURLAction
s but for demonstration purposes, we’ll use the new state$
stream provided by redux-observable:
1// https://github.com/huy-nguyen/redux-observable-marble-diagrams/blob/cf389ab0/src/epic.ts2action$.pipe(3 filter<Action, UpdateURLAction>(isUpdateURLAction),4 debounceTime(dueTime, scheduler),5 withLatestFrom(state$),6);
The withLatestFrom
operator combines the current stream’s value with the most recent value from another stream.
Below is the marble diagram showing how withLatestFrom
works in isolation to jog your memory.
Note how a
from the source
stream is combined with s
from the other
stream into the array ['a', 's']
:
1// https://github.com/huy-nguyen/redux-observable-marble-diagrams/blob/cf389ab0/src/__tests__/basic.js2const values = {3 x: ['a', 's'],4 y: ['b', 't'],5 z: ['c', 't'],6};7const source = m.cold(' -a---b---c--|');8const other = m.cold(' s--t--------|');9const expected = m.cold('-x---y---z--|', values);10const actual = source.pipe(11 withLatestFrom(other),12);
Now that our epic uses state$
, we also have to create a state$
stream in our marble test, which, for the purpose of testing, only emits a single value.
Not that state$
starts before the action$
stream to replicate how redux-observable actually works:
1// https://github.com/huy-nguyen/redux-observable-marble-diagrams/blob/cf389ab0/src/__tests__/epic.js2 const stateValue = {url: 'http://example.com'};3 const urlUpdateAction = {type: ActionTypes.UPDATE_URL};45 const values = {6 a: urlUpdateAction,7 b: {type: 'Unknown'},8 s: stateValue,9 x: [urlUpdateAction, stateValue],10 };1112 const state$ = m.cold(' s----------------------', values);13 const action$ = m.cold(' -a-b-a-aaa----------|', values);14 const expected$ = m.cold('-------------x------|', values);1516 // Inject `dueTime` and `TestScheduler` into epic:17 const epic = getEpic(4, m.scheduler);1819 const actual$ = epic(action$, state$);
The repo is now at commit cf389ab0.
Add data fetching
A few quick notes before we dive into data fetching:
- When you browse a file in GitHub’s web interface, say:
1https://github.com/ReactiveX/rxjs/blob/master/index.js
the URL to which to send a GET request to get that file’s content is slightly different:
1https://api.github.com/repos/ReactiveX/rxjs/contents/index.js?ref=master
If the URL points to a file, the AJAX response from GitHub is a JSON object, of which we only care about the content
property, a base64-encoded string of the file’s content:
1{2 "name": "index.js",3 "path": "index.js",4 "sha": "6c3b0dc17947969cf907e94dac9426b6effcc53f",5 "size": 47,6 "url": "https://api.github.com/repos/ReactiveX/rxjs/contents/index.js?ref=master",7 "html_url": "https://github.com/ReactiveX/rxjs/blob/master/index.js",8 "git_url": "https://api.github.com/repos/ReactiveX/rxjs/git/blobs/6c3b0dc17947969cf907e94dac9426b6effcc53f",9 "download_url": "https://raw.githubusercontent.com/ReactiveX/rxjs/master/index.js",10 "type": "file",11 "content": "bW9kdWxlLmV4cG9ydHMgPSByZXF1aXJlKCcuL2Rpc3QvcGFja2FnZS9SeCcp\nOwo=\n",12 "encoding": "base64",13 "_links": {14 "self": "https://api.github.com/repos/ReactiveX/rxjs/contents/index.js?ref=master",15 "git": "https://api.github.com/repos/ReactiveX/rxjs/git/blobs/6c3b0dc17947969cf907e94dac9426b6effcc53f",16 "html": "https://github.com/ReactiveX/rxjs/blob/master/index.js"17 }18}
However, if the URL points to a directory, the response will be an array in which each element looks like the JSON object above.
To make an AJAX request in RxJS, we use the
ajax
method to get an Observable that will resolves to either a response or an error. However, in testing, we need the ability to inject a custom Observable in order to control the AJAX request’s behavior e.g. make it succeed, fail or fail aftern
tries then succeed. As such, the epic accepts as parameter a functiongetAjax
that defaults to RxJS’sajax
method in production but can return a custom Observable in testing.We should emit a “fetch begins” action before sending out the AJAX request and a “fetch fail” with the appropriate message when it fails.
Based on the above pointers, we can now fetch data from GitHub by mapping each debounced and filtered UpdateURLAction
to an AJAX Observable.
Note that because mapping each URL to an AJAX Observable creates an Observable of Observable, we need to flatten the result by using switchMap
instead of just map
.
1// https://github.com/huy-nguyen/redux-observable-marble-diagrams/blob/722e39bb/src/epic.ts2export const getEpic = (3 // These arguments allow for dependency injection during testing:4 getAjax: typeof ajax = ajax,5 dueTime: number = 250,6 // Note: `undefined` scheduler passed to `debounceTime` in production means7 // it'll use the "natural" scheduler:8 scheduler: Scheduler | undefined = undefined,9 ) =>10 (action$: Observable<Action>, state$: Observable<RootState>) =>11 action$.pipe(12 // ...13 switchMap<[UpdateURLAction, RootState], FetchOutcome>(([, {url}]): Observable<FetchOutcome> => {14 if (url === '') {15 // If the user input is blank, show the initial prompt:16 return of<FetchInitialAction>({type: ActionTypes.FETCH_INITIAL});17 } else {18 const parseResult = parse(url);19 if (parseResult === null) {20 // If we cannot parse the URL into a GitHub URL at all, show an error message:21 return of<FetchFailAction>({type: ActionTypes.FETCH_FAIL, payload: {message: 'Invalid GitHub URL'}});22 } else {23 // Otherwise, initiate a fetch:24 const fetchBegin$ = of<FetchBeginAction>({type: ActionTypes.FETCH_BEGIN});25 const {branch, path, repo, user} = parseResult;26 const fetchURL = `https://api.github.com/repos/${user}/${repo}/contents/${path}?ref=${branch}`;2728 let ajaxRequest: AjaxRequest;29 if (GITHUB_TOKEN === undefined) {30 // If no github token is provided, send an unauthenticated request:31 ajaxRequest = {32 url: fetchURL,33 };34 } else {35 // Otherwise, send credentials along to avoid rate limit:36 ajaxRequest = {37 url: fetchURL,38 headers: {39 Authorization: `token ${GITHUB_TOKEN}`,40 },41 };42 }4344 const fetchPipeline$ = getAjax(ajaxRequest).pipe(45 map<AjaxResponse, FetchSuccessAction>(({response}) => {46 if (Array.isArray(response)) {47 // This means the fetched URL is a directory instead48 // of a file:49 throw new Error('URL is a GitHub directory instead of a file');50 }51 const {content} = response;52 const decoded = atob(content);53 return {type: ActionTypes.FETCH_SUCCESS, payload: {data: decoded}};54 }),5556 );57 return concat(fetchBegin$, fetchPipeline$);58 }59 }60 }),
In the marble test, we can now inject a custom Observable testAjax
that succeeds with some dummy data after waiting for one frame (to simulate real-world conditions).
We can see that the correct “fetch success” action is emitted by the epic:
1// https://github.com/huy-nguyen/redux-observable-marble-diagrams/blob/722e39bb/src/__tests__/epic.js2const stateValue = {url: 'https://github.com/ReactiveX/rxjs/blob/master/index.js'};3const urlUpdateAction = {type: ActionTypes.UPDATE_URL};45const contentAsString = 'This is a test.';6// Encode to base64 for testing:7const contentAsBase64 = btoa(contentAsString);89const values = {10 a: urlUpdateAction,11 b: {type: 'Unknown'},12 s: stateValue,13 x: {type: ActionTypes.FETCH_BEGIN},14 y: {type: ActionTypes.FETCH_SUCCESS, payload: {data: contentAsString}},15};1617const state$ = m.cold(' s----------------------', values);18const action$ = m.cold(' -a-b-a-aaa----------|', values);19const expected$ = m.cold('-------------xy-----|', values);202122const testAjax = jest.fn().mockReturnValueOnce(23 of({response: { content: contentAsBase64}}).pipe(delay(1))24);2526// Inject `dueTime` and `TestScheduler` into epic:27const epic = getEpic(testAjax, 4, m.scheduler);
The repo is now at commit 722e39bb.
Handling errors
So far, we’ve only handled one type of error: the user input is not a valid GitHub URL at all.
Other errors can happen.
An AJAX request can fail because of a network error, because the resource that the URL points to doesn’t exist on GitHub (404s) or because the URL points to a directory instead of a file.
We can provide an error handler by retry
ing failed requests three times and adding the catchError
operator within the fetchPipeline$
stream:
1// https://github.com/huy-nguyen/redux-observable-marble-diagrams/blob/320ed09b/src/epic.ts2switchMap<[UpdateURLAction, RootState], FetchOutcome>(([, {url}]): Observable<FetchOutcome> => {3 // ...4 const fetchPipeline$ = getAjax(ajaxRequest).pipe(5 retry<AjaxResponse>(3),6 map<AjaxResponse, FetchSuccessAction>(({response}) => {7 // ...8 }),9 catchError<any, FetchFailAction>((err: AjaxError) => {10 // Try to use error message in ajax response because we assume11 // it's more relevant that the `message` property of the `err`12 // observable:13 let message: string;14 if (err.xhr && err.xhr.response && err.xhr.response.message) {15 message = err.xhr.response.message;16 } else {17 message = err.message;18 }19 return of<FetchFailAction>({type: ActionTypes.FETCH_FAIL, payload: {message}});20 }),21 );22 // ...23}),
Note that the location of the catchError
operator is important.
We put it within switchMap
but not in action$
’s pipeline (action$.pipe(...)
) in order to prevent any error from bubbling up to action$
’s pipeline.
If that is allowed to happen, whatever Observable the catchError
returns will replace the entire epic, causing the epic to terminate and stop listening to new actions.
To test that the error handling actually works, we need to introduce some AJAX errors by making the special getRetryTestObservable
we created earlier fail a certain number of times.
By making it fail three times, we test that AJAX requests are indeed retry
ed three times.
By making it fail four times, we test that the epic emits a “fetch fail” action when it has exhausted all the retry
s.
1// https://github.com/huy-nguyen/redux-observable-marble-diagrams/blob/320ed09b/src/__tests__/epic.js2it('Should retry when encountering fetch error', marbles(m => {3 const contentAsString = 'This is a test.';4 const contentAsBase64 = btoa(contentAsString);56 const ajaxSuccess = new AjaxResponse(undefined, {7 status: 200, response: {content: contentAsBase64}, responseType: 'json',8 });9 const ajaxFailure = new AjaxError('This is an error', {10 status: 404, response: {message: 'Not Found'}, responseType: 'json',11 });1213 // The AJAX request will fail three times and succeed when retried on the fourth time:14 const getTestAjaxObservable = getRetryTestObservable([ajaxFailure, ajaxFailure, ajaxFailure], ajaxSuccess);1516 const values = {17 a: {type: ActionTypes.UPDATE_URL},18 b: {type: 'Unknown'},19 s: {url: 'https://github.com/ReactiveX/rxjs/blob/master/index.js'},20 x: {type: ActionTypes.FETCH_BEGIN},21 y: {type: ActionTypes.FETCH_SUCCESS, payload: {data: contentAsString}},22 };2324 const state$ = m.cold(' s-------------------------', values);25 const action$ = m.cold(' -a-b-a-aaa----------|', values);26 const expected$ = m.cold('-------------(xy)---|', values);27}));2829it('Should dispatch "fetch fail" action when retries are unsuccessful due to 404s', marbles(m => {30 const errorMessage = 'Not Found';3132 const ajaxFailure = new AjaxError('This is a test error', {33 status: 404, response: {message: errorMessage}, responseType: 'json',34 });3536 // The AJAX request will fail four times and succeed when retried on the fifth time.37 // However, because the epic only retries three times, only errors will be emitted:38 const getTestAjaxObservable = getRetryTestObservable(39 [ajaxFailure, ajaxFailure, ajaxFailure, ajaxFailure], undefined40 );41 const values = {42 a: {type: ActionTypes.UPDATE_URL},43 b: {type: 'Unknown'},44 s: {url: 'https://github.com/ReactiveX/rxjs/blob/master/index.js'},45 x: {type: ActionTypes.FETCH_BEGIN},46 z: {type: ActionTypes.FETCH_FAIL, payload: {message: errorMessage}},47 };4849 const state$ = m.cold(' s-------------------------', values);50 const action$ = m.cold(' -a-b-a-aaa----------|', values);51 const expected$ = m.cold('-------------(xz)---|', values);52}));
We’re now done with creating the pic. The repo is now at commit 320ed09b.
Create user interface
Now that we have the epic, we need to create the input text field and code block to show the fetched GitHub file. I won’t elaborate on this part because it’s a straightforward react-redux application but you can see the final version of the code here.
Note that in development mode, to avoid GitHub’s rate limit of 60 requests/hour for unauthenticated requests, you need to provide the application with a personal access token from GitHub (the public_repo
scope is sufficient).
Create a .env
file in the project’s root directory with the content GITHUB_TOKEN=yourGitHubToken
.
To start a local development server, run yarn run dev
1.
The URL of the dev server (by default http://localhost:8080
) will be copied to the clipboard for you.
If you want to see all the dispatched actions and how they change the redux store over time, run yarn run devtools
before running yarn run dev
.
This will start redux-devtools on port 8004 (http://localhost:8004
) by default, which should look like this:
You can change the port number of redux-devtools to whatever you want by adding a new line MAIN_THREAD_REDUX_DEV_TOOLS_PORT=portNumber
to .env
.
To make a production build, run yarn run build
.
The build output is in the dist
directory.
Run yarn run serve
to start a simple web server to serve up the build.
Note that because the production build does not include your GitHub token (to prevent unintended exposure), you may run into GitHub’s API rate limit.
- For live reload in development mode, this project uses webpack-serve, the designated successor of webpack-dev-server because webpack-dev-server has entered maintenance-only mode.↩