初次尝试 Jest Mock 我们的应用程序通常需要从外部的 API 获取数据。在编写测试时,外部 API 可能由于各种原因而失败。我们希望我们的测试是可靠和独立的,而最常见的解决方案就是 Mock。
改写 TodoList 组件 首先让我们改造组件,使其能够通过 API 获取数据。安装 axios:
然后改写 TodoList
组件如下:
src/TodoList.js 查看完整代码 [tuture-del]import React from 'react' ; [tuture-add]import React, { Component } from 'react' ; [tuture-add]import axios from 'axios' ; [tuture-add] import Task from './Task' ;[tuture-del]const ToDoList = (props ) => { [tuture-del] return ( [tuture-del] <ul> [tuture-del] {props.tasks.map((task ) => ( [tuture-del] <Task key={task.id} id={task.id} name={task.name} /> [tuture-del] ))} [tuture-del] </ul> [tuture-del] ); [tuture-del]}; [tuture-add]const apiUrl = 'https:/ /api.tuture.co'; [tuture-add] [tuture-add]class ToDoList extends Component { [tuture-add] state = { [tuture-add] tasks: [], [tuture-add] }; [tuture-add] [tuture-add] componentDidMount() { [tuture-add] return axios [tuture-add] .get(`${apiUrl}/ tasks`) [tuture-add] .then((tasksResponse) => { [tuture-add] this.setState({ tasks: tasksResponse.data }); [tuture-add] }) [tuture-add] .catch((error) => console.log(error)); [tuture-add] } [tuture-add] [tuture-add] render() { [tuture-add] return ( [tuture-add] <ul> [tuture-add] {this.state.tasks.map((task) => ( [tuture-add] <Task key={task.id} id={task.id} name={task.name} /> [tuture-add] ))} [tuture-add] </ul> [tuture-add] ); [tuture-add] } [tuture-add]} export default ToDoList;
TodoList
被改造成了一个“聪明组件”,在 componentDidMount
生命周期函数中通过 axios
模块异步获取数据。
编写 axios 模块的 mock 文件 Jest 支持对整个模块进行 Mock,使得组件不会调用原始的模块,而是调用我们预设的 Mock 模块。按照官方推荐,我们创建 mocks 目录并把 mock 文件放到其中。创建 axios 的 Mock 文件 axios.js,代码如下:
src/__mocks__/axios.js 查看完整代码 'use strict' ;module .exports = { get : () => { return Promise .resolve({ data: [ { id: 0 , name: 'Wash the dishes' , }, { id: 1 , name: 'Make the bed' , }, ], }); }, };
这里的 axios 模块提供了一个 get
函数,并且会返回一个 Promise,包含预先设定的假数据。
通过 spyOn 函数检查 Mock 模块调用情况 让我们开始 Mock 起来!打开 TodoList 的测试文件,首先在最前面通过 jest.mock
配置 axios 模块的 Mock(确保要在 import TodoList
之前),在 Mock 之后,无论在测试还是组件中使用的都将是 Mock 版本的 axios。然后创建一个测试用例,检查 Mock 模块是否被正确调用。代码如下:
src/TodoList.test.js 查看完整代码 import React from 'react' ;import { shallow, mount } from 'enzyme' ;[tuture-add]import axios from 'axios' ; [tuture-add] [tuture-add]jest.mock('axios' ); import ToDoList from './ToDoList' ;describe('ToDoList component' , () => { [tuture-add] [tuture-add] describe('when rendered' , () => { [tuture-add] it('should fetch a list of tasks' , () => { [tuture-add] const getSpy = jest.spyOn(axios, 'get' ); [tuture-add] const toDoListInstance = shallow(<ToDoList /> ); [tuture-add] expect(getSpy).toBeCalled(); [tuture-add] }); [tuture-add] }); });
测试模块中一个函数是否被调用实际上是比较困难的,但是所幸 Jest 为我们提供了完整的支持。首先通过 jest.spyOn
,我们便可以监听一个函数的使用情况,然后使用配套的 toBeCalled
Matcher 来判断该函数是否被调用。整体代码十分简洁,同时也保持了很好的可读性。
如果你忘记了 Jest Matcher 的含义,推荐阅读本系列的第一篇教程。
迭代 TodoList 组件 一个实际的项目总会不断迭代,当然也包括我们的 TodoList 组件。对于一个待办事项应用来说,最重要的当然便是添加新的待办事项。
修改 TodoList 组件,代码如下:
src/TodoList.js 查看完整代码 class ToDoList extends Component { state = { tasks: [], [tuture-add] newTask: '' , }; componentDidMount() { .catch((error ) => console .log(error)); } [tuture-add] addATask = () => { [tuture-add] const { newTask, tasks } = this .state; [tuture-add] [tuture-add] if (newTask) { [tuture-add] return axios [tuture-add] .post(`${apiUrl} /tasks` , { task : newTask }) [tuture-add] .then((taskResponse ) => { [tuture-add] const newTasksArray = [...tasks]; [tuture-add] newTasksArray.push(taskResponse.data.task); [tuture-add] this .setState({ tasks : newTasksArray, newTask : '' }); [tuture-add] }) [tuture-add] .catch((error ) => console .log(error)); [tuture-add] } [tuture-add] }; [tuture-add] [tuture-add] handleInputChange = (event ) => { [tuture-add] this .setState({ newTask : event.target.value }); [tuture-add] }; [tuture-add] render() { [tuture-add] const { newTask } = this .state; return ( [tuture-del] <ul> [tuture-del] {this .state.tasks.map((task ) => ( [tuture-del] <Task key={task.id} id={task.id} name={task.name} /> [tuture-del] ))} [tuture-del] </ul> [tuture-add] <div> [tuture-add] <h1>ToDoList</ h1>[tuture-add] <input onChange={this .handleInputChange} value={newTask} /> [tuture-add] <button onClick={this .addATask}>Add a task</button> [tuture-add] <ul> [tuture-add] {this.state.tasks.map((task) => ( [tuture-add] <Task key={task.id} id={task.id} name={task.name} / >[tuture-add] ))} [tuture-add] </ul> [tuture-add] </ div> ); } } export default ToDoList;
由于我们大幅改动了 TodoList 组件,我们需要更新快照:
如果你不熟悉 Jest 快照测试,请回看本系列第二篇教程。
更新后的快照文件反映了我们刚刚做的变化:
src/__snapshots__/TodoList.test.js.snap 查看完整代码 // Jest Snapshot v1, https://goo.gl/fbAQLP [tuture-del]exports[`ToDoList component when provided with an array of tasks should render correctly 1`] = `<ul />`; [tuture-add]exports[`ToDoList component when provided with an array of tasks should render correctly 1`] = ` [tuture-add]<div> [tuture-add] <h1> [tuture-add] ToDoList [tuture-add] </h1> [tuture-add] <input [tuture-add] onChange={[Function]} [tuture-add] value="" [tuture-add] /> [tuture-add] <button [tuture-add] onClick={[Function]} [tuture-add] > [tuture-add] Add a task [tuture-add] </button> [tuture-add] <ul /> [tuture-add]</div> [tuture-add]`;
在测试中模拟 React 组件的交互 在上面迭代的 TodoList 中,我们使用了 axios.post。 这意味着我们需要扩展 axios 的 mock 文件:
src/__mocks__/axios.js 查看完整代码 'use strict' ;[tuture-add]let currentId = 2 ; [tuture-add] module .exports = { get : () => { return Promise .resolve({ ], }); }, [tuture-add] post: (url, data ) => { [tuture-add] return Promise .resolve({ [tuture-add] data: { [tuture-add] task: { [tuture-add] name: data.task, [tuture-add] id: currentId++, [tuture-add] }, [tuture-add] }, [tuture-add] }); [tuture-add] }, };
可以看到上面,我们添加了一个 currentId
变量,因为我们需要保持每个 task 的唯一性。
让我们开始测试吧!我们测试的第一件事是检查修改输入值是否更改了我们的状态:
我们修改 app/components/TodoList.test.js
如下:
import React from 'react' ;import { shallow } from 'enzyme' ;import ToDoList from './ToDoList' ;describe('ToDoList component' , () => { describe('when the value of its input is changed' , () => { it('its state should be changed' , () => { const toDoListInstance = shallow( <ToDoList/> ); const newTask = 'new task name' ; const taskInput = toDoListInstance.find('input' ); taskInput.simulate('change' , { target : { value : newTask }}); expect(toDoListInstance.state().newTask).toEqual(newTask); }); }); });
这里要重点指出的就是 simulate 函数的调用。 这是我们几次提到的ShallowWrapper的功能。 我们用它来模拟事件。它第一个参数是事件的类型(由于我们在输入中使用onChange,因此我们应该在此处使用change),第二个参数是模拟事件对象(event)。
为了进一步说明问题,让我们测试一下用户单击按钮后是否从我们的组件发送了实际的 post 请求。我们修改测试代码如下:
import React from 'react' ;import { shallow } from 'enzyme' ;import ToDoList from './ToDoList' ;import axios from 'axios' ;jest.mock('axios' ); describe('ToDoList component' , () => { describe('when the button is clicked with the input filled out' , () => { it('a post request should be made' , () => { const toDoListInstance = shallow( <ToDoList/> ); const postSpy = jest.spyOn(axios, 'post' ); const newTask = 'new task name' ; const taskInput = toDoListInstance.find('input' ); taskInput.simulate('change' , { target : { value : newTask }}); const button = toDoListInstance.find('button' ); button.simulate('click' ); expect(postSpy).toBeCalled(); }); }); });
感谢我们的 mock 和 simulate 事件,测试通过了! 现在事情会变得有些棘手。 我们将测试状态是否随着我们的新任务而更新,其中比较有趣的是请求是异步的,我们继续修改代码如下:
import React from 'react' ;import { shallow } from 'enzyme' ;import ToDoList from './ToDoList' ;import axios from 'axios' ;jest.mock('axios' ); describe('ToDoList component' , () => { describe('when the button is clicked with the input filled out, the new task should be added to the state' , () => { it('a post request should be made' , () => { const toDoListInstance = shallow( <ToDoList/> ); const postSpy = jest.spyOn(axios, 'post' ); const newTask = 'new task name' ; const taskInput = toDoListInstance.find('input' ); taskInput.simulate('change' , { target : { value : newTask }}); const button = toDoListInstance.find('button' ); button.simulate('click' ); const postPromise = postSpy.mock.results.pop().value; return postPromise.then((postResponse ) => { const currentState = toDoListInstance.state(); expect(currentState.tasks.includes((postResponse.data.task))).toBe(true ); }) }); }); });
就像上面看到的,postSpy.mock.results 是 post 函数发送结果的数组,通过使用它,我们可以得到返回的 promise,我们可以从 value
属性中取到这个 promise。从测试返回 promise 是确保 Jest 等待其异步方法执行结束的一种方法。
小结 在本文中,我们介绍了 mock 模块,并将其用于伪造API调用。 由于没有发起实际的 post 请求,我们的测试可以更可靠,更快。 除此之外,我们还在整个 React 组件中模拟了事件。 我们检查了它是否产生了预期的结果,例如组件的请求或状态变化。 为此,我们了解了 spy 的概念。
尝试测试 React Hooks Hooks 是 React 的一个令人兴奋的补充,毫无疑问,它可以帮助我们将逻辑与模板分离。 这样做使上述逻辑更具可测试性。 不幸的是,测试钩子并没有那么简单。 在本文中,我们研究了如何使用 react-hooks-testing-library 处理它。
我们创建 src/useModalManagement.js
文件如下:
src/useModalManagement.js 查看完整代码 import { useState } from 'react' ;function useModalManagement ( ) { const [isModalOpened, setModalVisibility] = useState(false ); function openModal ( ) { setModalVisibility(true ); } function closeModal ( ) { setModalVisibility(false ); } return { isModalOpened, openModal, closeModal, }; } export default useModalManagement;
上面的 Hooks 可以轻松地管理模式状态。 让我们开始测试它是否不会引发任何错误,我们创建 useModalManagement.test.js
src/useModalManagement.test.js 查看完整代码 import useModalManagement from './useModalManagement' ;describe('The useModalManagement hook' , () => { it('should not throw an error' , () => { useModalManagement(); }); });
我们运行测试,得到如下的结果:
FAIL useModalManagement.test.js The useModalManagement hook ✕ should not throw an error按 ⌘+↩ 退出
不幸的是,上述测试无法正常进行。 我们可以通过阅读错误消息找出原因:
无效的 Hooks 调用, Hooks 只能在函数式组件的函数体内部调用。
让测试通过 React文档 里面提到: 我们只能从函数式组件或其他 Hooks 中调用 Hooks。 我们可以使用本系列前面部分介绍的 enzyme 库来解决此问题,而且使了一点小聪明,我们创建 testHook.js
:
src/testHook.js 查看完整代码 import React from 'react' ;import { shallow } from 'enzyme' ;function testHook (hook ) { let output; function HookWrapper ( ) { output = hook(); return <> </> ; } shallow(<HookWrapper /> ); return output; } export default testHook;
我们继续迭代 useModalManagement.test.js
,修改内容如下:
src/useModalManagement.test.js 查看完整代码 import useModalManagement from './useModalManagement' ;[tuture-add]import testHook from './testHook' ; describe('The useModalManagement hook' , () => { it('should not throw an error' , () => { [tuture-del] useModalManagement(); [tuture-add] testHook(useModalManagement); }); });
我们允许测试,得到如下结果:
PASS useModalManagement.test.js The useModalManagement hook ✓ should not throw an error
好多了! 但是,上述解决方案不是很好,并且不能为我们提供进一步测试 Hooks 的舒适方法。 这就是我们使用 react-hooks-testing-library 的原因,我们将在下一篇教程里讲解如何更加舒适的测试 React Hooks 的方法,敬请期待!
开始使用 react-hooks-testing-library package.json 查看完整代码 "dependencies": { "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.5.0", [tuture-add] "@testing-library/react-hooks": "^3.4.1", "@testing-library/user-event": "^7.2.1", "axios": "^0.20.0", "enzyme": "^3.11.0", "enzyme-adapter-react-16": "^1.15.3", "jest-enzyme": "^7.1.2", "react": "^16.13.1", "react-dom": "^16.13.1", "react-scripts": "3.4.3" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" }, "eslintConfig": { "extends": "react-app" }, "browserslist": { "production": [ ">0.2%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] } }
src/useModalManagement.test.js 查看完整代码 import useModalManagement from './useModalManagement' ;[tuture-del]import testHook from './testHook' ; [tuture-add]import { renderHook, act } from '@testing-library/react-hooks' ; describe('The useModalManagement hook' , () => { it('should not throw an error' , () => { [tuture-del] testHook(useModalManagement); [tuture-add] renderHook(() => useModalManagement()); [tuture-add] }); [tuture-add] [tuture-add] it('should describe a closed modal by default' , () => { [tuture-add] const { result } = renderHook(() => useModalManagement()); [tuture-add] expect(result.current.isModalOpened).toBe(false ); [tuture-add] }); [tuture-add] [tuture-add] describe('when the openModal function is called' , () => { [tuture-add] it('should describe an opened modal' , () => { [tuture-add] const { result } = renderHook(() => useModalManagement()); [tuture-add] act(() => { [tuture-add] result.current.openModal(); [tuture-add] }); [tuture-add] expect(result.current.isModalOpened).toBe(true ); [tuture-add] }); }); });
测试异步钩子 src/useCommentsManagement.js 查看完整代码 import { useState } from 'react' ;function useCommentsManagement ( ) { const [comments, setComments] = useState([]); function fetchComments ( ) { return fetch('https://jsonplaceholder.typicode.com/comments' ) .then((response ) => response.json()) .then((data ) => { setComments(data); }); } return { comments, fetchComments, }; } export default useCommentsManagement;
src/useCommentsManagement.test.js 查看完整代码 import { renderHook, act } from '@testing-library/react-hooks' ;import useCommentsManagement from './useCommentsManagement' ;describe('The useCommentsManagement hook' , () => { describe('when the fetchComments function is called' , () => { it('should update the state after a successful request' , async () => { const { result, waitForNextUpdate } = renderHook(() => useCommentsManagement()); act(() => { result.current.fetchComments(); }); await waitForNextUpdate(); return expect(result.current.comments.length).not.toBe(0 ); }); }); });
测试 Redux + Hooks package.json 查看完整代码 "jest-enzyme": "^7.1.2", "react": "^16.13.1", "react-dom": "^16.13.1", [tuture-del] "react-scripts": "3.4.3" [tuture-add] "react-redux": "^7.2.1", [tuture-add] "react-scripts": "3.4.3", [tuture-add] "redux": "^4.0.5" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" }, "eslintConfig": { "extends": "react-app" }, "browserslist": { "production": [ ">0.2%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] } }
src/actions/modal.js 查看完整代码 const OPEN_MODAL = 'OPEN_MODAL' ;const CLOSE_MODAL = 'CLOSE_MODAL' ;function openModal ( ) { return { type: OPEN_MODAL, }; } function closeModal ( ) { return { type: CLOSE_MODAL, }; } export { OPEN_MODAL, CLOSE_MODAL, openModal, closeModal };
src/reducers/index.js 查看完整代码 import { combineReducers } from 'redux' ;import modal from './modal' ;const rootReducer = combineReducers({ modal });export default rootReducer;
src/reducers/modal.js 查看完整代码 import { OPEN_MODAL, CLOSE_MODAL } from '../actions/modal' ;const initialState = { isOpened: false , }; export default function modal (state = initialState, action ) { if (action.type == OPEN_MODAL) { return { isOpened : true }; } else if (action.type == CLOSE_MODAL) { return { isOpened : false }; } else { return state; } }
src/store.js 查看完整代码 import { createStore } from 'redux' ;import rootReducer from './reducers' ;const store = createStore(rootReducer);export default store;
src/useModalManagement.js 查看完整代码 [tuture-del]import { useState } from 'react' ; [tuture-add]import { useDispatch, useSelector } from 'react-redux' ; [tuture-add]import * as modalActions from './actions/modal' ; function useModalManagement ( ) {[tuture-del] const [isModalOpened, setModalVisibility] = useState(false ); [tuture-add] const isModalOpened = useSelector((state ) => state.modal.isOpened); [tuture-add] const dispatch = useDispatch(); function openModal ( ) { [tuture-del] setModalVisibility(true ); [tuture-add] dispatch(modalActions.openModal()); } function closeModal ( ) { [tuture-del] setModalVisibility(false ); [tuture-add] dispatch(modalActions.closeModal()); } return { isModalOpened, openModal, closeModal, }; } export default useModalManagement;
src/useModalManagement.test.js 查看完整代码 [tuture-add]import React from 'react' ; import useModalManagement from './useModalManagement' ;import { renderHook, act } from '@testing-library/react-hooks' ;[tuture-add]import { Provider } from 'react-redux' ; [tuture-add]import store from './store' ; describe('The useModalManagement hook' , () => { it('should not throw an error' , () => { }); it('should describe a closed modal by default' , () => { [tuture-del] const { result } = renderHook(() => useModalManagement()); [tuture-add] const { result } = renderHook(() => useModalManagement(), { [tuture-add] wrapper: ({ children } ) => <Provider store ={store} > {children}</Provider > , [tuture-add] }); expect(result.current.isModalOpened).toBe(false ); }); describe('when the openModal function is called' , () => { it('should describe an opened modal' , () => { [tuture-del] const { result } = renderHook(() => useModalManagement()); [tuture-add] const { result } = renderHook(() => useModalManagement(), { [tuture-add] wrapper: ({ children } ) => <Provider store ={store} > {children}</Provider > , [tuture-add] }); act(() => { result.current.openModal(); }); expect(result.current.isModalOpened).toBe(true ); }); }); });