如何使用Jest测试基于fetch的复杂异步Redux Reducer
如何捕获Redux异步Thunk中分发的ACTION?
场景还原
我有一个基于fetch API实现的Redux Thunk Action Creator:
export const fetchRelatedFamilies = () => { return (dispatch, getState) => { if (isEmpty(getState().relatedFamiliesById)) { dispatch({ type: REQUEST_RELATED_FAMILIES_BY_ID }) new HttpRequestHelper('/api/related_families', (responseJson) => { dispatch({ type: RECEIVE_RELATED_FAMILIES_BY_ID, updates: responseJson.relatedFamiliesById }) }, e => dispatch({ type: RECEIVE_RELATED_FAMILIES_BY_ID, error: e.message, updates: {} }), ).get() } } }
对应的测试代码无法捕获异步分发的RECEIVE_RELATED_FAMILIES_BY_ID action:
import configureStore from 'redux-mock-store' import fetchMock from 'fetch-mock' import thunk from 'redux-thunk' import { cloneDeep } from 'lodash' import { fetchRelatedFamilies, REQUEST_RELATED_FAMILIES_BY_ID, RECEIVE_RELATED_FAMILIES_BY_ID } from 'redux/rootReducer' import { STATE1 } from '/shared/components/panel/fixtures.js' describe('fetchRelatedFamilies', () => { const middlewares = [thunk] const testActionsDispatch = async (currstate, expectedActions) => { const store = configureStore(middlewares)(currstate) store.dispatch(fetchRelatedFamilies()) // 尝试过用setTimeout等待,但要么捕获不到,要么测试无意义通过 //await new Promise((r) => setTimeout(r, 200)); expect(store.getActions()).toEqual(expectedActions) } afterEach(() => { fetchMock.reset() fetchMock.restore() }) it('Dispatches correct actions when data - relatedFamiliesById - is absent in state', () => { const relatedFamiliesById = cloneDeep(STATE1.relatedFamiliesById) fetchMock .getOnce('/api/related_families', { body: relatedFamiliesById, headers: { 'content-type': 'application/json' } }) STATE1.relatedFamiliesById = {} const expectedActions = [ { type: REQUEST_RELATED_FAMILIES_BY_ID }, { type: RECEIVE_RELATED_FAMILIES_BY_ID, updates: relatedFamiliesById } ] testActionsDispatch(STATE1, expectedActions) }) })
遇到的问题:
- 测试只能捕获到同步的
REQUEST_RELATED_FAMILIES_BY_ID,拿不到异步返回后的RECEIVE_RELATED_FAMILIES_BY_ID - 用
setTimeout模拟等待会导致测试无论预期是否正确都通过,断言逻辑被忽略 - 无法用
store.dispatch(...).then(),因为当前Thunk没有返回Promise - 项目版本限制,不能安装
waitFor这类库
解决方法
方法1:改造Thunk,让它返回Promise
修改fetchRelatedFamilies,把HTTP请求的过程包装成Promise,这样测试里就能用await等待异步完成:
export const fetchRelatedFamilies = () => { return (dispatch, getState) => { // 如果数据已存在,直接返回空Promise if (!isEmpty(getState().relatedFamiliesById)) { return Promise.resolve(); } dispatch({ type: REQUEST_RELATED_FAMILIES_BY_ID }) // 把HttpRequestHelper的调用包装成Promise return new Promise((resolve, reject) => { new HttpRequestHelper('/api/related_families', (responseJson) => { const action = { type: RECEIVE_RELATED_FAMILIES_BY_ID, updates: responseJson.relatedFamiliesById }; dispatch(action); resolve(action); // 成功时resolve }, e => { const action = { type: RECEIVE_RELATED_FAMILIES_BY_ID, error: e.message, updates: {} }; dispatch(action); reject(action); // 失败时reject }, ).get() }); } }
然后修改测试代码,等待dispatch完成:
const testActionsDispatch = async (currstate, expectedActions) => { const store = configureStore(middlewares)(currstate) // 等待Thunk的Promise完成 await store.dispatch(fetchRelatedFamilies()) expect(store.getActions()).toEqual(expectedActions) } // 测试用例也要改成async/await it('Dispatches correct actions when data is absent', async () => { const relatedFamiliesById = cloneDeep(STATE1.relatedFamiliesById) fetchMock .getOnce('/api/related_families', { body: { relatedFamiliesById }, headers: { 'content-type': 'application/json' } }) STATE1.relatedFamiliesById = {} const expectedActions = [ { type: REQUEST_RELATED_FAMILIES_BY_ID }, { type: RECEIVE_RELATED_FAMILIES_BY_ID, updates: relatedFamiliesById } ] await testActionsDispatch(STATE1, expectedActions) })
方法2:不修改原代码,用fetchMock的flush方法同步完成请求
fetchMock本身提供了flush方法,可以立即触发所有挂起的请求回调,不用等待定时器:
const testActionsDispatch = async (currstate, expectedActions) => { const store = configureStore(middlewares)(currstate) store.dispatch(fetchRelatedFamilies()) // 立即刷新所有fetchMock的挂起请求,触发回调 await fetchMock.flush() expect(store.getActions()).toEqual(expectedActions) } it('Dispatches correct actions when data is absent', async () => { const relatedFamiliesById = cloneDeep(STATE1.relatedFamiliesById) fetchMock .getOnce('/api/related_families', { body: { relatedFamiliesById }, headers: { 'content-type': 'application/json' } }) STATE1.relatedFamiliesById = {} const expectedActions = [ { type: REQUEST_RELATED_FAMILIES_BY_ID }, { type: RECEIVE_RELATED_FAMILIES_BY_ID, updates: relatedFamiliesById } ] await testActionsDispatch(STATE1, expectedActions) })
注意:原测试代码的fetchMock返回格式有误——Thunk期望responseJson包含relatedFamiliesById字段,所以返回的body应该是{ relatedFamiliesById },而非直接返回该字段的值,这也是之前捕获不到正确action的原因之一。
方法3:手动模拟事件循环
如果不想修改代码也不用fetchMock的flush,可以用await Promise.resolve()触发事件循环的微任务,让异步回调执行:
const testActionsDispatch = async (currstate, expectedActions) => { const store = configureStore(middlewares)(currstate) store.dispatch(fetchRelatedFamilies()) // 等待微任务队列执行,触发fetch的回调 await Promise.resolve() expect(store.getActions()).toEqual(expectedActions) }
这个方法依赖于fetchMock用微任务模拟fetch行为,大部分场景下都能生效。
关键问题总结
- 原测试代码的fetchMock返回格式错误,导致Thunk拿到的
responseJson结构不符合预期 - 异步Thunk未返回Promise,无法直接用
await等待异步流程完成 setTimeout是宏任务,会打乱异步回调的执行顺序,导致断言逻辑失效
内容的提问来源于stack exchange,提问作者Nikita Vlasenko
相关产品推荐
相关产品推荐

