На примере обычного блога (получение из 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)
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 } } })
Хотя смысла тестировать столь простую логику я не вижу.
artalar
А почему не используете assignAll из redux-act, используете SSR?
Я вот написал к redux-act расширение, так dispatch вообще не нужен теперь
comerc Автор
SSR использую в полный рост, только не понял, к чему этот уточняющий вопрос — assignAll не работает на сервере?
Я отказался совсем от биндинга экшенов, не использую mapDispatchToProps, наоборот предпочитаю явно дергать dispatch(), но в результате боевой код имеет много лишнего:
Спасибо, записал в блокнотик "подумать, как спрятать dispatch". Только оно должно работать с redux-thunk и redux-saga.
artalar
assignAll (и bindAll) не работает с SSR, да.
Если использовать либу, что я указал выше, thunk не нужен :)
C сагами не подскажу, но зачем они, если уже есть redux-act?
comerc Автор
Я описал выше вариант "тесты после кода", когда интересно увидеть только общую картину. Но когда TDD, и тесты нужны на каждый чих, то в сагах детализация глубже.