На примере обычного блога (получение из API данных для post-comments), продемонстрирую, как покрываю тестами redux-слой. Исходники доступны тут.


Вместо разделенных actions и reducers, применяю ducks-pattern, который сильно упрощает как разработку, так и тестирование redux-а в приложении. А ещё использую крайне полезный инструмент — redux-act, но важно в поле description метода createAction() добавлять исключительно: цифры, заглавные буквы и подчеркивания (proof).


Для начала тест для простого "action creator" типа { type, payload } — app.setLoading():


// src/ducks/app.js
import { createAction, createReducer } from 'redux-act'

export const REDUCER = 'APP'
const NS = `${REDUCER}__`

export const initialState = {
  isLoading: false,
}

const reducer = createReducer({}, initialState)

export const setLoading = createAction(`${NS}SET`)
reducer.on(setLoading, (state, isLoading) => ({ ...state, isLoading }))

export default reducer

Минимум для первого запуска теста:


// src/ducks/__tests__/app.test.js
import thunk from 'redux-thunk'
import configureMockStore from 'redux-mock-store'
import { setLoading } from '../app'
import reducer from '..'

const middlewares = [thunk]
const mockStore = configureMockStore(middlewares)

describe('sync ducks', () => {
  it('setLoading()', () => {
    let state = {}
    const store = mockStore(() => state)
    store.dispatch(setLoading(true))
    const actions = store.getActions()
    console.log(actions)
    // ...остальной код отсюда - далее по тексту
  })
})

Копирую из консоли значение для expectedActions:


    const expectedActions = [{ type: 'APP__SET', payload: true }];
    expect(actions).toEqual(expectedActions);

Применяю actions (с данными в payload для каждого action) к рутовому редюсеру, полученному из combineReducers():


    actions.forEach(action => {
      state = reducer(state, action)
    })
    expect(state).toEqual({
      ...state,
      app: { ...state.app, isLoading: true },
    })

Следует пояснить, что store создается с функцией обратного вызова mockStore(() => state) — чтобы обеспечить текущее состояние при вызовах getState() внутри сайд-эффектов redux-thunk.

Вот и всё, первый тест готов!


Далее интереснее, нужно покрыть тестами сайд-эффект post.load():


// src/ducks/post.js
import { createAction, createReducer } from 'redux-act'
import { matchPath } from 'react-router'
import axios from 'axios'
import { load as loadComments } from './comments'

export const REDUCER = 'POST'
const NS = `${REDUCER}__`

export const initialState = {}

const reducer = createReducer({}, initialState)

const set = createAction(`${NS}SET`)
reducer.on(set, (state, post) => ({ ...state, ...post }))

export const load = () => (dispatch, getState) => {
  const state = getState()
  const match = matchPath(state.router.location.pathname, { path: '/posts/:id' })
  const id = match.params.id
  return axios.get(`/posts/${id}`).then(response => {
    dispatch(set(response.data))
    return dispatch(loadComments(id))
  })
}

export default reducer

Хотя comments.load() тоже экспортируется, но тестировать его отдельно не имеет особого смысла, т.к. он используется только внутри нашего post.load():


// src/ducks/comments.js
import { createAction, createReducer } from 'redux-act'
import axios from 'axios'

export const REDUCER = 'COMMENTS'
const NS = `${REDUCER}__`

export const initialState = []

const reducer = createReducer({}, initialState)

const set = createAction(`${NS}SET`)
reducer.on(set, (state, comments) => [...comments])

export const load = postId => dispatch => {
  return axios.get(`/comments?postId=${postId}`).then(response => {
    dispatch(set(response.data))
  })
}

export default reducer

Тест сайд-эффекта:


// src/ducks/__tests__/post.test.js
import thunk from 'redux-thunk'
import configureMockStore from 'redux-mock-store'
import axios from 'axios'
import AxiosMockAdapter from 'axios-mock-adapter'
import { combineReducers } from 'redux'
import post, { load } from '../post'
import comments from '../comments'

const middlewares = [thunk]
const mockStore = configureMockStore(middlewares)
const reducerMock = combineReducers({
  post,
  comments,
  router: (state = {}) => state,
})
const axiosMock = new AxiosMockAdapter(axios)

describe('sideeffects', () => {
  afterEach(() => {
    axiosMock.reset()
  })
  it('load()', () => {
    const postResponse = {
      userId: 1,
      id: 1,
      title: 'title',
      body: 'body',
    }
    axiosMock.onGet('/posts/1').reply(200, postResponse)
    const commentsResponse = [
      {
        postId: 1,
        id: 1,
        name: 'name',
        email: 'email@example.com',
        body: 'body',
      },
    ]
    axiosMock.onGet('/comments?postId=1').reply(200, commentsResponse)
    let state = {
      router: {
        location: {
          pathname: '/posts/1',
        },
      },
    }
    const store = mockStore(() => state)
    return store.dispatch(load()).then(() => {
      const actions = store.getActions()
      const expectedActions = [
        {
          type: 'POST__SET',
          payload: postResponse,
        },
        { type: 'COMMENTS__SET', payload: commentsResponse },
      ]
      actions.forEach(action => {
        state = reducerMock(state, action)
      })
      expect(state).toEqual({
        ...state,
        post: { ...state.post, ...postResponse },
        comments: [...commentsResponse],
      })
    })
  })
})

Не знаю, как сделать лучше, но ради инициализации редюсера router, пришлось пересобрать рутовый редюсер в reducerMock. Плюс обманки для двух запросов к axios. Ещё к store.dispatch() добавился return, т.к. обернуто в Promise; но есть альтернатива — функция обратного вызова done():


  it('', done => {
    setTimeout(() => {
      //...
      done()
    }, 1000)
  }

А в остальном тест для сайд-эффекта не сложнее теста для простого "action creator". Исходники доступны тут.

Комментарии (5)


  1. artalar
    23.10.2017 20:07
    +1

    А почему не используете assignAll из redux-act, используете SSR?
    Я вот написал к redux-act расширение, так dispatch вообще не нужен теперь


    1. comerc Автор
      23.10.2017 20:24

      SSR использую в полный рост, только не понял, к чему этот уточняющий вопрос — assignAll не работает на сервере?


      Я отказался совсем от биндинга экшенов, не использую mapDispatchToProps, наоборот предпочитаю явно дергать dispatch(), но в результате боевой код имеет много лишнего:


      interface IOwnProps {}
      
      interface IConnectedState {
        router: RouterState;
      }
      
      interface IConnectedDispatch {
        dispatch: Dispatch<IStoreState>;
      }
      
      type IProps = IOwnProps & IConnectedState & IConnectedDispatch;
      
      interface IState {}
      
      const mapStateToProps = (state: IStoreState) => ({
        router: state.router,
      });
      
      const WrappedUser = connect<IConnectedState, IConnectedDispatch, IOwnProps>(mapStateToProps)(
        class User extends React.Component<IProps, IState> {
          componentDidMount() {
            this.props.dispatch(myAction())
          }
          //...
        }
      )
      
      export default WrappedUser;

      Спасибо, записал в блокнотик "подумать, как спрятать dispatch". Только оно должно работать с redux-thunk и redux-saga.


      1. artalar
        23.10.2017 20:35

        assignAll (и bindAll) не работает с SSR, да.
        Если использовать либу, что я указал выше, thunk не нужен :)
        C сагами не подскажу, но зачем они, если уже есть redux-act?


        1. comerc Автор
          23.10.2017 22:06

          Я описал выше вариант "тесты после кода", когда интересно увидеть только общую картину. Но когда TDD, и тесты нужны на каждый чих, то в сагах детализация глубже.


  1. vintage
    24.10.2017 09:49

    Люди, остановитесь! Зачем вы пишете столько запутанного кода, называя его "легко тестируемым"? Вы где-то потеряли простоту и элегантность яваскрипта. Смотрите, весь код из статьи можно было бы переписать так:


    Код неблокирующей загрузки данных в зависимости от адресной строки:


    export class $my_app extends $mol_view {
    
        post_id() {
            return $mol_state_arg.sub().value()
        }
    
        post() {
            return $mol_http.resource( `/posts/${ this.post_id() }` ).json()
        }
    
        comments() {
            return $mol_http.resource( `/comments?postId=${ this.post_id() }` ).json()
        }
    
    }

    Ну и тест на это дело:


    $mol_test({
    
        'Load post and comments'() {
    
            const app = new $my_app
            const arg = $mol_state_arg.sub()
            const post = $mol_http.resource( `/posts/1` )
            const comments = $mol_http.resource( `/comments?postId=1` )
    
            try {
    
                arg.value = ()=> '1'
    
                post.json = ()=> ({
                    userId: 1,
                    id: 1,
                    title: 'title',
                    body: 'body',
                })
    
                comments.json = ()=> ([
                    {
                        postId: 1,
                        id: 1,
                        name: 'name',
                        email: 'email@example.com',
                        body: 'body',
                    },
                ])
    
                $mol_assert_like( app.post() , comments.post() )
                $mol_assert_like( app.comments() , comments.json() )
    
            } finally {
    
                delete arg.value
                delete post.json
                delete comments.json
    
            }
    
        }
    
    })

    Хотя смысла тестировать столь простую логику я не вижу.