2
votes

All of the logic in my application lives in action creators (thunks). Most of the action creators' logic isn't very complicated, and consists of conditional expressions with the conditions being values from the store: if this value exists in the store, dispatch these action creators, otherwise dispatch this action. There's also some "aggregators", which are action creators that dispatch several other action creators, often based on the existence of some state values; and api wrappers, which conditionally call the api abstraction thunks with parameters from the state - and then handle the response.

Point is, most of them use the getState function to get everything they need themselves, instead of receiving it as arguments. Now, this approach has served me well and is dead-simple to work with, however I'm kind of struggling with testing it. Up until now, I wrote all my tests following this suggestion: https://github.com/reactjs/redux/issues/2179. Basically, at the beginning I set up the required state using some other actions, mock fetch calls, then dispatch the thunk I intended to test, and check the state afterwards using various selectors. This tests multiple actions, reducers and selectors all at the same time, in a single test. I like the fact my tests verify particular use-cases entirely, but I'm not sure if this actually is a good practice. My main issue is that some thunks are untestable, because they dispatch 5 other action creators, and I'm confused how to at least verify they were called, except for checking if the state has changed, which in turn makes the Promise chain huge, and tests the same functions over and over again across multiple tests.

I'm new to this entire testing thing, and all the examples on the Internet are TODO lists or other ridiculously simple CRUD apps, which doesn't help. How do you actually do redux testing in complex applications, that use a lot of conditional logic, with action creators that depend on multiple state nodes?

1
not sure if it can help you with all use cases but have you considered using middleWares? - Sagiv b.g
Here is example using a middleware. stackoverflow.com/questions/68202239/… - Dinesh Devkota

1 Answers

1
votes

Short answer: mock it. =)

Long answer: I do personally prefer to use as much of real code (i.e. without Test Doubles) in tests as possible. But sometimes it's just doesn't worth it and you have to fall back to mocking.

In cases like one you described there are several things you may want/need to check in your tests:

  1. That some sub-thunk was actually dispatched from your thunk under test.
  2. That thunk under test deals correctly with the result of a sub-thunk it dispatches.
  3. That thunk under test deals correctly with the updated state, which was updated by dispatched sub-thunk.

Depending on which combination of stuff above you want to test different strategies may be used. For instance, there is not need to check if sub-thunk were dispatched in case your thunk under test relies on sub-thunk's result: just mock sub-thunk so that it return specific data which will affect behavior of thunk under test in a specific, sensible way (see auth mock for details in the snippet below).

Let's consider the following example to illustrate possible mocking strategy. Imagine you have to implement authorization feature. Let's say your server authorizes user via http end-point and, in case of success, sends authorization token back which is used later to open websocket connection. Let's assume you designed that feature in the following way:

There is connect thunk, which dispatches auth sub-thunk with user's login and password, which in turn sends given credentials via http. When server responds auth thunk stores received token in a store (just for illustrative reasons) and resolves. When auth resolves, connect dispatches otherStuff thunk which will, well, do some other stuff with token. And at the end connect opens socket connection via wsApi.

// =======  connect.js ======= 

import { auth, getToken } from './auth';
import * as wsApi from './ws';
import { otherStuff } from './other-stuff';

export const connect = (login, password) => (dispatch, getState) => {
  // ...

  return dispatch(auth(login, password))
    .then(() => {
      const token = getToken(getState());
      dispatch(otherStuff(token));
      wsApi.connect(token);
    });

  // ...
};


// =======  auth.js ======= 

import * as httpApi from './http';

const saveToken = token => ({ type: 'auth/save-token', payload: token });

export const auth = (login, password) =>
  dispatch =>
  httpApi.login(login, password)
  .then(token => dispatch(saveToken(token)));

export const getToken = state => state.auth.token;

export default (state = {}, action) => action.type === 'auth/save-token' ? { token: action.payload } : state;


// ======= other-stuff.js ======= 

export const otherStuff = token => (dispatch) => {
  // ...
};

What we are gonna do is to mock two thunks: auth and otherStuff. connect is highly dependent on auth so we will make sure auth is called just by the checking connect behavior depending on what mock behavior we passed to auth. The case with otherStuff is a bit more complex. There is not way to check it was actually dispatched other then implementing custom middleware, which will log all dispatched actions. All in all test will look as follows (I am using jest for mocking):

import { createStore, applyMiddleware, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import { connect } from './connect';
import { auth, getToken } from './auth';
import { otherStuff } from './other-stuff';
import * as wsApi from './ws';
const authReducer = require.requireActual('./auth').default;

jest.mock('./auth');
jest.mock('./ws');
jest.mock('./other-stuff');

const makeSpyMiddleware = () => {
  const dispatch = jest.fn();
  return {
    dispatch,
    middleware: store => next => action => {
      dispatch(action);
      return next(action);
    }
  };
};

describe('connect', () => {
  let store;
  let spy;

  beforeEach(() => {
    jest.clearAllMocks();
    spy = makeSpyMiddleware();

    store = createStore(authReducer, {}, applyMiddleware(spy.middleware, thunk));

    auth.mockImplementation((login, password) => () => {
      if (login === 'user' && password == 'password') return Promise.resolve();
      return Promise.reject();
    });
  });

  test('happy path', () => {
    getToken.mockImplementation(() => 'generated token');
    otherStuff.mockImplementation(token => ({ type: 'mocked/other-stuff', token }));

    return store.dispatch(connect('user', 'password')).then(() => {
      expect(wsApi.connect).toHaveBeenCalledWith('generated token');
      expect(spy.dispatch).toHaveBeenCalledWith({ type: 'mocked/other-stuff', token: 'generated token'});
    });
  });

  test('auth failed', () => {
    return store.dispatch(connect('user', 'wrong-password')).catch(() => {
      expect(wsApi.connect).not.toHaveBeenCalled();
    });
  });
});

If you need any comments on the given snippets, feel free to ask.