DEV Community

Julio Olivera
Julio Olivera

Posted on

Writing tests for redux-observable

I attended a talk about redux-observable recently and, having been playing with Rx and liking it, I decided to give it a try. I won't get into the details of how to use it, but I did spend some time figuring out a way to easily test my epics that I wanted to share.

Let's say that, for authenticating users of our app, we have an epic that looks like this:

function loginEpic(action$) {
  return action$.ofType(LOGIN_START).mergeMap(action =>
    api
      .login(action.payload.email, action.payload.password)
      .map(loginSuccessful)
      .catch(err => Observable.of(loginFailed(err))),
  );
}

The epic takes our main stream of actions and for each LOGIN_START action, generates either an action with the loginSuccessful action creator, or one with the loginFailed action creator.

Here I see three things that deserve their own unit test:

  • The proper API call is being made.
  • We generate the success action if the login succeeded.
  • We generate the error action if the login fails.

The rationale behind all of the tests is going to be the same: We'll create an observable with the LOGIN_START action and pass it to the epic, subscribe to it and assert on the actions generated. Let's take a look at the first one, to check the API call:

I'm using Jest for the assertions and mocking here, but the same could be done with any other framework

it('logins through the api on LOGIN_START', (done) => {
  const email = 'test@test.com';
  const password = '123456';
  const action$ = ActionsObservable.from([login(email, password)]);

  api.login.mockImplementation(() => ActionsObservable.of({}));

  epic(action$)
    .subscribe(() => {
      expect(api.login).toHaveBeenCalledWith(email, password);
      done();
    });
});

A couple of things to note:

  • The login function is the action creator that generates LOGIN_START actions. Since we have it already, it makes sense to use it.
  • The API is implemented to return observables, so that's why the mock implementation returns one that simply emits an empty object (we don't really care about the response in this test)
  • api is mocked with Jest's mock facilities outside this test, like this:
jest.mock('../lib/api', () => ({ login: jest.fn() }));

Other than that, we pass the action stream to the epic, we subscribe and then we expect that after the first action is generated then we should've called the API already with the right parameters. Let's take a look at the tests that check the generated actions:

it('emits a LOGIN_SUCCESS action if the API call succeeds', (done) => {
  const action$ = ActionsObservable.from([login('test@test.com', '123456')]);
  const user = {};

  api.login.mockImplementation(() => ActionsObservable.of(user));

  epic(action$)
    .filter(action => action.type === LOGIN_SUCCESS)
    .subscribe((action) => {
      expect(action.payload).toBe(user);
      done();
    });
});

it('emits a LOGIN_FAILED action if the API call fails', (done) => {
  const action$ = ActionsObservable.from([login('test@test.com', '123456')]);
  const error = new Error();

  api.login.mockImplementation(() => ActionsObservable.throw(error));

  epic(action$)
    .filter(action => action.type === LOGIN_FAILED)
    .subscribe((action) => {
      expect(action.payload).toBe(error);
      done();
    });
});

The gist of it is that we filter the actions generated by the epic to ensure that we got the right type, and then when we subscribe we check that the payload of those actions are the right ones.

Note: I'm using filter instead of ofType like I would use to filter by action type inside an epic. This is because I can't be sure that the observable returned by the epic is going to be an ActionsObservable instead of a regular observable.

And that's it! I think this is a simple way to test epics. It might not be enough for more complicated cases, but personally I found it very simple and easy to reason about.

Top comments (0)