На примере обычного блога (получение из API данных для posts-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". Исходники доступны тут.
Автор: comerc