Redux 整合笔记 六 测试Redux

219 阅读6分钟

测试action创建器

测试同步action创建器

同步action创建器可选地接收参数并返回一个普通对象,称为action。它们是产生确定性结果的纯函数。请务必导出action创建器,以使其在测试文件中可用

action.js

export function createTasksSucceeded(task){
	return {
    	type: CREATE_TASK_SUCCEEDED,
        payload: {
        	task
        }
    }
}

测试同步action创建器代码

import {createTasksSucceeded} from './actions/';

describe('action creators', ()=>{
	it('should handle successful task creation', ()=>{
    	const task = {
        	title: 'Get schwifty',
            description: 'Show me what you got'
        }
        const expectdAction = {
        	type: CREATE_TASK_SUCCEEDED,
            payload: { task }
        }
        
        expect(createTaskSucceeded(task)).toEqual(expectedAction);
    })
})

测试异步action创建器

createTask异步创建器

export function createTask({title, description, status = 'Unstarted'}) {
	return dispatch => {
    	dispatch(createTaskRequested());
        return api.createTask({title, description, status}).then(resp =>{
        	dispatch(createTaskSucceeded(resp.data))
        })
    }
}

需要注意的是,将在action创建器内返回由api.createTask返回的promise。让异步action创建器返回promise通常是一种很好的开发实践,因为这可让调用者灵活响应promise的结果。

我们需要一个额外的软件包 redux-mock-store。它为我们提供了一个便捷的接口 - store.getActions(),该接口返回已经派发至模拟store的action列表。可据此断言createTask正确派发了用于表示请求开始/成功的action。另外唯一需要的是Jest,可用它手动模拟API响应。从配置模拟store开始,最终将使用此配置派发createTask。还可导入并应用createTask依赖的redux-thunk中间件

配置模拟Redux store

import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import {createTask} from './';

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

接下来,模拟api.createTask.可使用Jest模拟工具模拟api.createTask。可确保api.createTask返回在测试中直接控制的promise,并且不需担心任何与HTTP相关的问题

jest.unmock('../api');
import * as api from '../api';
api.createTask = jest.fn(
	() => new Promise((resolve, reject) => resolve({data: 'foo'}))
)

至此,我们完成了大部父配置,是时候开始测试了。

describe('createTask', ()=>{
	it('works', ()=>{
    	const expectedActions = [
        	{type: CREATE_TASK_STARTED},
            {type: CREATE_TASK_SUCCEEDED, payload: {task: 'foo'}}
        ]
        const store = mockStore({
        	tasks: {
            	tasks: []
            }
        })
        
        return store.dispatch(createTask({})).then(()=>{
        	expect(store.getActions()).toEqual(expectedActions);
            expect(api.createTask).toHaveBeenCalled();
        })
    })
}) 

测试saga

saga最适合用于处理更复杂的副作用,比如长时间运行的进程。

测试一个计时器saga

import {delay} from 'redux-daga';
import {call, put} from 'redux-saga/effects';

export function* handleProgressTimer({type, payload}){
	if(type === TIMER_STATED){
    	while(true){
        	yield call(delay, 1000);
            yield put({
            	type: TIMER_INCREMENT,
                payload: {taskId: payload.taskId}
            })
        }
    }
}

saga中间件可执行ajax请求或其他副作用。你所编写的saga会返回一个effect:一个描述中间件如何处理逻辑的对象。在测试saga时,需要断言的是生成器函数是否返回期望的effect对象。

生成器的每个返回值都是具有value和done两个键的对象。在测试TIMER_STARTED时,每次生成器调用next函数时,都将断言value键是否符合期望。但可通过调用saga方法来生成预期输出,而非手动输入effect对象。

import {delay} from 'redux-saga';
import {call, put} from 'redux-saga/effects';
import {handleProgressTimer} from '../sagas';

describe('sagas', ()=>{
	it('handles the handleProgressTimer happy path', ()=>{
    	const iterator = handleProgressTimer({
        	type: TIMER_STARTED,
            payload: {taskId: 12}
        })
        
        const expectedAction = {
            type: TIMER_INCREMENT,
            payload: {taskId: 12}
        }
        
        expect(iterator.next().value).toEqual(call(delay, 1000));
        expect(iterator.next().value).toEqual(put(expectedAction));
        expect(iterator.next().value).toEqual(call(delay, 1000));
        expect(iterator.next().value).toEqual(put(expectedAction));
        expect(iterator.next().done).toBe(false);
    })
    
    it('handles the handleProgressTimer sad path', ()=>{
    	const iterator = handleProgressTimer({
        	type: TIMER_STOPPED,
        })
        expect(iterator.next().done).toBe(true)
    })
    
})

测试saga很简单,最重要的想法是逐步遍历生成器函数的结果。假设saga有结论,那么最终可断言done的值为true.

测试reducer

tasks reducer

const initialState = {
	tasks: [],
    isLoading: false,
    error: null,
    seachTerm: '',
}

export default function tasks(state = initialState, action){
	switch(action.type){
    	case FETCH_TASKS_STARTED: {
        	return {
            	...state,
                isLoading: true,
            }
        }
        case FETCH_TASKS_SUCCEEDED: {
        	return {
            	...state,
                tasks: action.payload.tasks,
                isLoading: false
            }
        }
        ...
        default: {
        	return state;
        }
    }
}

因为reducer是纯函数,所以测试时不需要任何特殊的辅助函数与其他业务逻辑。 测试tasks reducer

import tasks from '../reducers/';

describe('the tasks reducer', ()=>{
	const initialState = {
    	tasks: [],
        isLoading: false,
        error: null,
        searchTerm: '',
    }
    
    it('should return the initialState', ()=>{
    	expect(tasks(undefined, {})).toEqual(initialState);
    })
    
    it('should handle the FETCH_TASKS_STARTED action', ()=>{
    	const action = {type: FETCH_TASKS_STARTED};
        const expectedState = {
        	...initialState,
            lsLoading: true
        }
        expect(tasks(initialState, action)).toEqual(expectedState);
    })
    
    it('should handle the FETCH_TASKS_SUCCEEDED action', ()=>{
    	const tasksList = [{title: 'Test the reducer', description: 'Very meta'}];
        const action = {
        	type: FETCH_TASKS_SCCEEDED,
            payload: {tasks: tasksList}
        }
        const expectedState = {
        	...initialState,
            tasks: taskList
        }
        
        expect(tasks(initialState, action)).toEqual(expectedState);
    })
})

在断言之前声明action和期望状态的变量。这种模式是为了提高断言的可读性。

测试选择器

选择器可以是纯函数,但是使用reselect创建的选择器具有附加功能 - memoization。 下面代码引入了两个普通选择器和一个reselect创建的选择器。

示例选择器

import {createSelector} from 'reselect';

export const getTasks = state => state.tasks.tasks;
export const getSearchTerm = state => state.tasks.searchTerm;

export const getFilteredTasks = createSelector(
	[getTasks, getSearchTerm],
    (tasks, searchTerm) => {
    	return tasks.filter(tasks => task.title.match(new RegExp(searchTerm, 'i')))
    }
)

使用reselect编写的选择器更有趣一些,因此reselect提供了额外的辅助函数。除了返回期望的输出外,另一个值得测试的关键功能是,函数在缓存正确的数据并限制其执行的重新计算的次数。辅助函数recomputations专用于此。

普通选择器接收状态作为输入,并产生状态切片作为输出。reselect选择器执行类似的任务,但会尽其所能避免多余的计算工作。如果getFilteredTasks函数具有相同的输入,则无须重复计算输出,而是选择返回最近存储的输出。

在不同测试之间,可用resetRecomputations函数将数字重置为0

import {getTasks, getSearchTerm, getFilteredTasks} from '../reducers/';
import cloneDeep from 'lodash/cloneDeep';

describe('tasks selectors', ()=>{
	const state = {
    	tasks: {
        	tasks: [
            	{title: 'Test selectors', description: 'Very meta'},
                {title: 'Learn Redux', description: 'Oh my'},
            ],
            searchTerm: 'red',
            isLoading: false,
            error: null,
        }
    };
    
    afterEach(()=>{
    	getFilteredTasks.resetRecomputations();
    })
    
    it('should retrieve tasks from the getTasks selector', ()=>{
    	expect(getTasks(state)).toEqual(state.tasks.tasks);
    })
    
    it('should retrieve the searchTerm from the getSearchTerm selector', ()=>{
    	expect(getSearchTerm(state)).toEqual(state.tasks.searchTerm);
    })
    
    it('should minimally recompute the state when getFilteredTasks is called', ()=>{
    	const similarSearch = cloneDeep(state);
        similarSearch.tasks.searchTerm = 'redu';
        
        const uniqueSearch = cloneDeep(state);
        uniqueSearch.tasks.searchTerm = 'selec';
        
        // 验证选择器执行了最少次数的重新计算
        expect(getFilteredTasks.recomputations()).toEqual(0);
        getFilteredTasks(state);
        getFilteredTasks(similarDearch);
        expect(getFilteredTasks.recomputations()).toEqual(1);
        getFilteredTasks(uniqueSearch);
        expect(getFilteredTasks.recomputations()).toEqual(2);
    })
})

测试容器组件

容器组件是可访问redux store的组件。当使用来自react-redux的connect方法包装组件并导出时,表明组件是容器组件。

测试容器组件时需考虑普通组件之外的一些额外问题。例如,除了dispatch方法和mapStateToProps函数中指定的任何其他属性,组件将获取额外的可用属性。另一个问题是,实际导出的组件并非编写的组件,而是增强后的组件。

测试容器组件时,为避免未发现实例错误,需在类声明中添加export关键字

export class App extends Component
describe('the app container', () => {
	const spy = jest.fn();
    const wrapper = mount(<App dispatch={sqy} erro ="Boom!" tasks={[]} .>);
    
    expect(spy).toHaveBeenCalled();
})

以这种方式测试容器组件通常足以满足期望的测试覆盖率。但是,你可能会对测试Redux功能感兴趣,此时需要使用一个包含store实例的Provider组件包装容器组件。

访问store实例是使用工具软件包的一种常见需求。可使用redux-mock-store

npm i -D redux-mock-store

import {Provider} from 'react-redux';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import ConnectedApp, {App} from '../App';

describe('the App container', ()=>{
	it('should fetch tasks on mount', ()=>{
    	const middlewares = [thunk];
        const initialState = {
        	tasks: {
            	tasks: [],
                isLoading: false,
                error: null,
                searchTerm: '',
            }
        };
        const mockStore = configureMockStore(middlewares)(initialState);
        const wrapper = mount(<Provider store={mockStore}><ConnectedApp/></Provider>);
        const expectedAction = {type: FETCH_TASKS_STARTED};
        expect(mockStore.getActions()[0]).toEqual(expectedAction);
    })
})