jeffbski / redux-logic-test

redux-logic test utilities to facilitate the testing of logic. Create mock store
MIT License
37 stars 3 forks source link

How to test injectedDeps and middleware #4

Open kopax opened 7 years ago

kopax commented 7 years ago

After writing the following test, I did try to import import authService from 'services/auth' and use it has in my logic.

test

  let store;
  beforeAll(() => {
    // specify as much as necessary for your particular test
    store = createMockStore({
      initialState: loginState,
      reducer: loginReducer, // default: identity reducer
      logic: getAuthorizeLogic, // default: []
      injectedDeps: { authService }, // default {}
      middleware: [] // optionalArr, other mw, exclude logicMiddleware
    });
  });
  describe('getAuthorizeLogic', () => {
    it('should make request and dispatch', () => {
      authService
      store.dispatch(onSuccessLoginRequest()) // use as necessary for your test
      store.dispatch(jwtLoaded({ toto: true }));
      store.whenComplete(() => expect(store.actions).toEqual([
        { type: ON_SUCCESS_LOGIN_REQUEST },
        {
          type: LOAD_JWT_SUCCESS,
          jwt: { toto: true },
        },
      ]));
    });
  });

logic

export const getAuthorizeLogic = createLogic({
  type: SUBMIT_LOGIN_REQUEST, // trigger on this action
  cancelType: LOCATION_CHANGE, // cancel if route changes
  latest: true, // use response for the latest request when multiple
  async process({ authService, forwardTo, pages, action }, dispatch, done) {
    const { username, password } = action.data;
    try {
      const jwt = await performLogin(authService, username, password);
      dispatch(onSuccessLoginRequest());
      dispatch(jwtLoaded(jwt));
      forwardTo(pages.pageDashboard.path); // Go to dashboard page
    } catch (err) {
      dispatch(onErrorLoginRequest(err));
      forwardTo(pages.pageLogin.path); // Go to dashboard page
    }
    done();
  },
});

async function performLogin(authService, username, password) {
  await authService.login(username, password);
  const isAuthed = await authService.code(oauthClient.clientId, oauthClient.redirectUri) // eslint-disable-line no-unused-vars
    .catch((e) => e.message);
  await authService.login(username, password);
  const codeRes = await authService.code(oauthClient.clientId, oauthClient.redirectUri);
  const code = getParameter('code', codeRes.url);
  if (!code) {
    throw new Error('It appear there is a problem with your account, please contact an administrator.');
  }
  const jwt = await authService.token(oauthClient.clientId, oauthClient.clientSecret, code, oauthClient.redirectUri, oauthClient.scopes);
  if (!jwt) {
    throw new Error('It appear there is a problem with your account, please contact an administrator.');
  }
  return jwt;
}

Could you show an example case of usage for injectedDeps and middleware?

jeffbski commented 7 years ago

Here is an example of using injectedDeps https://github.com/jeffbski/redux-logic-test/blob/master/examples/browser-basic/src/App.test.js (in the test I make a mock api that returns a promise resolving to 42. I could just as easily have rejected with an error).

It's hard to speculate without seeing all of the code, but I would expect that maybe you had imported the authService so the variable was already in scope without using a dependency. If you use a dependency then you an eliminate the import in your logic file (but you would still have it where you are injecting the dependency in configureStore and createMockStore).

Yes, if your code is depending on a dependency then it will fail if you didn't provide it. With the try/catch the error will probably be caught so it will end up dispatching an error action. I like to console.error any error as well as dispatching just so it is real obvious what happened. So if you are comparing the resulting actions then the assert would fail since it would be an error action rather than the success action.

kopax commented 7 years ago

I would expect that maybe you had imported the authService so the variable was already in scope without using a dependency.

This is the whole file:

import { createLogic } from 'redux-logic';
import { LOCATION_CHANGE } from 'react-router-redux';
import { oauthClient } from 'config';
import { SUBMIT_LOGIN_REQUEST } from '../constants';
import { onSuccessLoginRequest, onErrorLoginRequest, jwtLoaded } from '../actions';
export const getAuthorizeLogic = createLogic({
  type: SUBMIT_LOGIN_REQUEST,
  cancelType: LOCATION_CHANGE,
  latest: true,
  async process({ authService, requestUtil, forwardTo, pages, action }, dispatch, done) {
    const { username, password } = action.data;
    try {
      const jwt = await performLogin(authService, requestUtil, username, password);
      dispatch(onSuccessLoginRequest());
      dispatch(jwtLoaded(jwt));
      forwardTo(pages.pageDashboard.path); // Go to dashboard page
    } catch (err) {
      dispatch(onErrorLoginRequest(err));
      forwardTo(pages.pageLogin.path); // Go to dashboard page
    }
    done();
  },
});

async function performLogin(authService, requestUtil, username, password) {
  await authService.login(username, password);
  const isAuthed = await authService.code(oauthClient.clientId, oauthClient.redirectUri) // eslint-disable-line no-unused-vars
    .catch((e) => e.message);
  await authService.login(username, password);
  const codeRes = await authService.code(oauthClient.clientId, oauthClient.redirectUri);
  const code = requestUtil.getParameter('code', codeRes.url);
  if (!code) {
    throw new Error('It appear there is a problem with your account, please contact an administrator.');
  }
  const jwt = await authService.token(oauthClient.clientId, oauthClient.clientSecret, code, oauthClient.redirectUri, oauthClient.scopes);
  if (!jwt) {
    throw new Error('It appear there is a problem with your account, please contact an administrator.');
  }
  return jwt;
}

export default [
  getAuthorizeLogic,
];

This is how I configure my logicDependencies in store.js:

import { createStore, applyMiddleware, compose } from 'redux';
import { fromJS } from 'immutable';
import { routerMiddleware } from 'react-router-redux';
import browserHistory from 'react-router/lib/browserHistory';
import { createLogicMiddleware } from 'redux-logic';
import createReducer from 'store/reducers';
import request from 'utils/request';
import requestUtil from 'utils/request/utils';
import api from 'utils/request/kopax/api';
import follow from 'utils/request/kopax/follow';
import authService from 'services/auth';
import localService from 'services/local';
import twitterService from 'services/twitter';
import halBrowserService from 'services/halBrowser';
import { pages, url } from 'config';

export default function configureStore(initialState = {}, history) {
  const injectedHelpers = {
    api,
    follow,
    request,
    requestUtil,
    authService,
    localService,
    twitterService,
    halBrowserService,
    url,
    pages,
    forwardTo(location) {
      browserHistory.push(location);
    },
  };
  const logicMiddleware = createLogicMiddleware([], injectedHelpers);

  const middlewares = [
    logicMiddleware,
    routerMiddleware(history),
  ];

  const enhancers = [
    applyMiddleware(...middlewares),
  ];

  // If Redux DevTools Extension is installed use it, otherwise use Redux compose
  /* eslint-disable no-underscore-dangle */
  const composeEnhancers =
    process.env.NODE_ENV !== 'production' &&
    typeof window === 'object' &&
    window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ?
      window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ : compose;
  /* eslint-enable */

  const store = createStore(
    createReducer(),
    fromJS(initialState),
    composeEnhancers(...enhancers)
  );

  // Extensions
  store.logicMiddleware = logicMiddleware;
  store.asyncReducers = {}; // Async reducer registry

  // Make reducers hot reloadable, see http://mxs.is/googmo
  /* istanbul ignore next */
  if (module.hot) {
    module.hot.accept('./reducers', () => {
      System.import('./reducers').then((reducerModule) => {
        const createReducers = reducerModule.default;
        const nextReducers = createReducers(store.asyncReducers);

        store.replaceReducer(nextReducers);
      });
    });
  }

  return store;
}

This is my whole test:

import { createMockStore } from 'redux-logic-test';
import getAuthorizeLogic from '../getAuthorizeLogic';
import loginReducer, { loginState } from '../../reducer';
import { jwtLoaded, onSuccessLoginRequest } from '../../actions';
import { LOAD_JWT_SUCCESS } from '../../../App/constants';
import { ON_SUCCESS_LOGIN_REQUEST } from '../../constants';

describe('getAuthorizeLogic', () => {
  let store;
  let jwt;
  beforeAll(() => {
    const username = 'user';
    const password = 'password';

    const requestUtil = {
      getParameter: () => '',
    };

    const forwardTo = (path) => console.log(path);

    const authService = {
      code: () => {
        const isAuthed = true;
        return Promise.resolve(isAuthed);
      },
      login: () => Promise.resolve(42),
      token: () => Promise.resolve(42),
    };

    const injectedDeps = {
      action: {
        data: {
          username,
          password,
        },
      },
      forwardTo,
      authService,
      requestUtil,
    };

    store = createMockStore({
      initialState: loginState,
      reducer: loginReducer, // default: identity reducer
      logic: getAuthorizeLogic, // default: []
      injectedDeps,
      middleware: [], // optionalArr, other mw, exclude logicMiddleware
    });
    jwt = {
      access_token: 'ey...mxQ',
      token_type: 'bearer',
      refresh_token: 'eyJhbGc...zYHBw',
      expires_in: '2016-12-15T15:14:18.270Z',
      scope: 'trust read write',
      username: 'dka',
      jti: '4db52baa-ca11-40ef-a320-62691b36839b',
    };
  });
  describe('getAuthorizeLogic', () => {
    it('should make request and dispatch', () => {
      store.dispatch(onSuccessLoginRequest()); // use as necessary for your test
      store.dispatch(jwtLoaded(jwt));
      store.whenComplete(() => expect(store.actions).toEqual([
        { type: ON_SUCCESS_LOGIN_REQUEST },
        {
          type: LOAD_JWT_SUCCESS,
          jwt,
        },
      ]));
    });
  });
});

Test result

 PASS  app/containers/LoginPage/logics/tests/getAuthorizeLogic.test.js
  getAuthorizeLogic
    getAuthorizeLogic
      ✓ should make request and dispatch (5ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total

Coverage:

-------------------------------------------------|----------|----------|----------|----------|----------------|
File                                             |  % Stmts | % Branch |  % Funcs |  % Lines |Uncovered Lines |
-------------------------------------------------|----------|----------|----------|----------|----------------|
...
app/containers/LoginPage/logics                 |    37.04 |        0 |    33.33 |    38.46 |                |
  getAuthorizeLogic.js                           |    41.67 |        0 |       50 |    41.67 |... 42,43,44,46 |
  index.js                                       |        0 |        0 |        0 |        0 |            1,4 |
...

image

So the authService is not present in scope, I have the feeling these lines are just ignored and/or the injectedDeps doesn't seems to work.

jeffbski commented 7 years ago

I'll try to go through what you have posted to see if I can figure out what happened. I'm currently at React Conf so it may be a couple days till I get some time.

kopax commented 7 years ago

Thanks for the quick reply. No worries see you soon. I'll just add one more question when it comes to logics that export an array of more than one logic

export default [
  getEntityList,
  getEntityListOnNavigate,
];

Should I test an array or what seems to me the most obvious one by one? If one by one, shouldn't we change the types of the logic logic parameter to pass to createMockStore to avoid confusion ?

const initialState = {};
const reducer = (state, action) => { return state; };

I've read this in the doc, the few test I wrote doesn't require real state and real reducer. What is the first approach we should start to use, omit them or keep them ?

Enjoy the conference lucky you!

kopax commented 7 years ago

Any update on this ?

jeffbski commented 7 years ago

@kopax sorry for the delay, I'll try to follow up on Monday.

jeffbski commented 7 years ago
  1. Looking at your test, I would highly recommend that you use a beforeEach to reset the mockStore before each test rather than the beforeAll, that way you won't have any bleed over from other tests.

  2. You should return the store.whenComplete from your test and that will cause jest to wait for it to be done before considering the test successful. Basically without returning the promise, jest is not waiting for the async work to finish, so it just does a synchronous pass and since it encountered no errors it passes, however the real work hadn't even started yet since it is async.

  3. Regarding the question about how much logic to pass to your tests. I typically start by testing them individually so I'd just pass an array with a single logic in it, however there is nothing wrong with testing an array of logic. In fact if you want to mirror more closely what it happens when running, then giving the array of logic might be the smarter thing to do since that will take into account all the interactions between dispatches and all deployed logic. I usually have some tests that include all logic just to make sure that all things work as expected when deployed together.

So there are less moving parts if you use a smaller subset of logic, so it might be easier to see what happened when errors occur, however you probably should have at least some tests with the full array of logic as you would use it in production (except using your mocked injectedDeps).

If I had to choose only one style, then I'd go with the array of all logic since that will be how it works in production.