"编写软件是人类作的最难的事情" ———— Douglas Crockford
做为程序员,咱们的工做中不只仅是编写新代码,不少时候咱们是在维护和调试别人的代码。可测试的代码更加容易测试,意味着它更加容易维护;已维护则意味着它能让人更加容易理解——更加容易理解,又会让测试变得更加容易。
随着前端业务的日渐复杂,前端工程中的单元测试愈发重要。若是有可测试的代码组成的测试,就能够帮助咱们理解一些看似细小的变动所带来的的影响,以及能够保证本身的修改不会影响到其余的功能,从而可以更好的修复和更改代码。
本文将从单元测试角度来介绍一些相关的基本知识,进而去探索Jest和Enzyme在基于React开发的前端应用中的一些尝试。html
单元测试经过对最小的可测试单元(一般为单个函数或小组)进行测试和验证,来保证代码的健壮性。
单元测试是开发者的第一道防线。单元测试不只能强迫开发人员理解咱们的代码,也能帮助咱们记录和调试代码。好的单元测试用例甚至能够充当开发文档供开发者阅读。前端
须要访问数据库的测试
须要网络通讯的测试不是单元测试
须要调用文件系统的测试不是单元测试
须要对环境作特定配置(好比:编辑配置文件)才能运行的测试不是单元测试
--- 修改代码的艺术node
单元测试框架中最重要的部分就是将测试聚合到测试套件和测试用例中。
测试套件和测试用例分散在不少文件中,并且每一个测试文件一般只包括单个模块的测试。最好的方法就是将单个模块的全部测试整合到一个单独的测试套件中。这个测试套件包含多个测试用例,每一个测试模块只测试模块的很小一部分功能。经过使用测试套件和测试用例级别的setup
和teardown
函数,能够对测试先后的内容进行清理。react
单元测试的核心就是断言,经过单元咱们能够判断代码是否达到目的。
经常使用的有assert should expect
等断言关键字。assert
最为简单,相比之下expect
更接近正常阅读的顺序。 对断言关键字有兴趣的话,能够看看chai。这个断言库很强大,提供了对多种断言关键字的支持。ios
单元测试应该加载在所需测试的最小单元进行测试,任何额外的代码都有可能会影响测试或被测试代码。为了不加载外部依赖,咱们可使用模(mock)、桩(stub)以及测试替身 (test double)。它们都试图尽可能将被测试代码与其余代码隔离。git
测试替身描述的使用stub或mock模拟依赖对象进行测试。在同一时间,替身能够用stub
表示,也能够用mock
表示,以确保外部方法和api被调用,记录调用次数,捕获调用参数,并返回响应。
在方法被调用方面可以记录方法调用并捕获相关信息的测试替身,被称为间谍 (spy
).程序员
mock对象用于验证函数是否可以正确调用外部api。单元测试经过引入mock对象验证被测试函数是否传递正确的参数给外部对象。github
stub对象用于向被测试的函数返回所封装的值。stub对象不关心外部对象方法是如何调用的,它只是返回所选择的封装对象。typescript
spy一般附加到真正的对象上,经过拦截一些方法的调用(有时甚至是带有特定参数的拦截方法调用),来返回封装过的响应内容或追踪方法被调用的次数。没有被拦截的方法则按正常流程对真正的对象进行处理。数据库
代码覆盖率是用来衡量测试完整性的一项指标,一般分为两部分:代码行的覆盖率(line coverage)和函数的覆盖率(function coverage)。理论上来讲,“覆盖”的代码行数越多,测试就越完整。可是从我我的的角度来看:
单元测试的首要目的不是为了可以编写出大覆盖率的所有经过的测试代码,而是须要从使用者(调用者)的角度出发,尝试函数逻辑的各类可能性,进而辅助性加强代码质量。
Jest是Facebook开发的一款单元测试框架: Jest不只仅只适用于React,同时也提供了对于Node/Angular/Vue/Typescript等的支持。
Jasmine
,集成了expect
断言和多种matcherscallback
promise
async/await
的测试mock
系统,支持自动或手动mockJest中,经过expect断言结合matchers,能够帮助咱们用多种方式来测试代码。更多内容能够参见Expect, 如下为一些基本的示例:
describe('common use of matchers', () => {
it('two plus two equal four', () => {
expect(2 + 2).toBe(4);
});
it('check value of an object', () => {
const obj = { id: 1, name: 'test' };
obj['name'] = 'nameChanged';
expect(obj).toEqual({ id: 1, name: 'nameChanged' });
});
it('case of truthiness', () => {
const n = null;
expect(n).toBeNull();
expect(n).toBeDefined();
expect(n).not.toBeUndefined();
expect(n).not.toBeTruthy();
expect(n).toBeFalsy();
});
it('case of numbers', () => {
const value = 2 + 1;
expect(value).toBeGreaterThanOrEqual(3);
expect(value).toBeGreaterThan(2);
expect(value).toBeLessThan(4);
expect(value).toBeLessThanOrEqual(3);
});
it('case of float numbers', () => {
const value = 0.1 + 0.2;
expect(value).toBeCloseTo(0.3);
});
it('case of array and iterables', () => {
const fruits = ['apple', 'banana', 'cherry', 'pear', 'orange'];
expect(fruits).toContain('banana');
expect(new Set(fruits)).toContain('pear');
});
it('case of exceptions', () => {
const loginWithoutToken = () => {
throw new Error('You are not authorized');
};
expect(loginWithoutToken).toThrow();
expect(loginWithoutToken).toThrow('You are not authorized');
});
});
复制代码
在单元测试的编写中,咱们每每须要在测试开始前作一些准备工做,以及在测试结束运行后进行整理工做。Jest提供了相应的方法来帮助咱们作这些工做。
若是想进行一次性设置,咱们可使用beforeAll
和afterAll
来处理:
beforeAll(() => {
// 预处理操做
});
afterAll(() => {
// 整理工做
});
test('has foo', () => {
expect(testObject.foo).toBeTruthy();
})
复制代码
若是想在每次测试先后都进行设置和清理,咱们可使用beforeEach
和afterEach
:
beforeEach(() => {
// 每次测试前的预处理工做
});
afterEach(() => {
// 每次测试后的整理工做
});
test('has foo', () => {
expect(testObject.foo).toBeTruthy();
})
复制代码
Jest对于测试异步代码也提供了很好的支持,例如(如下为官网示例):
1.测试callback
, 假设咱们有一个fetchData(callback)
的回调函数:
const helloCallback = (name: string, callback: (name: string) => void) => {
setTimeout(() => {
callback(`Hello ${name}`);
}, 1000);
};
test('should get "Hello Jest"', done => {
helloCallback('Jest', result => {
expect(result).toBe('Hello Jest');
done();
});
});
复制代码
2.测试promise
以及async\await
:
const helloPromise = (name: string) => {
return new Promise(resolve => {
setTimeout(() => {
resolve(`Hello ${name}`);
}, 1000);
});
};
test('should get "Hello World"', () => {
expect.assertions(1);
return helloPromise('Jest').then(data => {
expect(data).toBe('Hello Jest');
});
});
test('should get "Hello World"', async () => {
expect.assertions(1);
const data = await helloPromise('Jest');
expect(data).toBe('Hello Jest');
});
复制代码
Jest提供了很方便的模拟函数的方法,如下为mocking modules
的示例代码,更多示例能够参考官网文档:
// users.js
import axios from 'axios';
class Users {
static all() {
return axios.get('/users.json').then(resp => resp.data);
}
}
export default Users;
// users.test.js
import axios from 'axios';
import Users from './users';
jest.mock('axios');
test('should fetch users', () => {
const users = [{name: 'Bob'}];
const resp = {data: users};
axios.get.mockResolvedValue(resp);
return Users.all().then(data => expect(data).toEqual(users));
});
复制代码
Enzyme是由Airbnb开源的一个React的JavaScript测试工具,是对官方测试工具库(react-addons-test-utils)的封装。它使用了cheerio库来解析虚拟DOM,提供了相似于JQuery的API来操做虚拟DOM,能够方便咱们在单元测试中判断、操纵和遍历React Components的输出。
shallow方法是对官方的Shallow Rendering
的封装。浅渲染只会渲染出组件的第一层DOM结构,其子组件不会被渲染,从而保证渲染的高效率和单元测试的高速度。
import { shallow } from 'enzyme';
describe('enzyme shallow rendering', () => {
it('todoList has three todos', () => {
const todoList = shallow(<App />); expect(todoList.find('.todo')).toHaveLength(3); }); }); 复制代码
mount方法会将React Components渲染为真实的DOM节点,适合于须要测试使用DOM API的组件的场景。测试若是在一样的DOM环境下进行,有可能会互相影响,这时候可使用Enzyme提供的unmount
方法来进行清理。
import { mount } from 'enzyme';
describe('enzyme full rendering', () => {
it('todoList has none todos done', () => {
const todoList = mount(<TodoList />); expect(todoList.find('.todo-done')).toHaveLength(0); }); }); 复制代码
render方法返回的是一个用CherrioWrapper包裹的React Components渲染成的静态HTML字符串。这个CherrioWrapper能够帮助咱们去分析最终代码的HTML代码结构。
import { render } from 'enzyme';
describe('enzyme static rendering', () => {
it('no done todo items', () => {
const todoList = render(<TodoList />);
expect(todoList.find('.todo-done')).toHaveLength(0);
expect(todoList.html()).toContain(<div className="todo" />);
});
});
复制代码
不管哪一种渲染方法,返回的wrapper都有一个find
方法,它接受一个selector参数并返回一个类型相同的wrapper对象。相似的还有:at
last
first
等方法能够选择具体位置的子组件,simulate
方法能够在组件上模拟某种事件。 Enzyme中的Selectors相似CSS选择器,若是须要支持复杂的CSS选择器,则须要从react-dom
中引入findDOMNode方法。
// class selector
wrapper.find('.bar')
// tag selector
wrapper.find('div')
// id selector
wrapper.find('#bar')
// component display name
wrapper.find('Foo')
// property selector
const wrapper = shallow(<Foo />) wrapper.find({ prop: 'value] })) 复制代码
Enzyme提供了相似setState
和setProps
之类的方法,能够用来模拟state和props的变化。相似的还有setContext
等等。注意setState方法只能在root instance上使用。
// set state
interface IState {
name: string;
}
class Foo extends React.Component<any, IState> {
state = { name: 'foo' };
render() {
const { name } = this.state;
return <div className={name}>{name}</div>;
}
}
const wrapper = shallow(<Foo />);
expect(wrapper.find('.foo')).toHaveLength(1);
expect(wrapper.find('.bar')).toHaveLength(0);
wrapper.setState({ name: 'bar' });
expect(wrapper.find('.foo')).toHaveLength(0);
expect(wrapper.find('.bar')).toHaveLength(1);
复制代码
// set props
interface IProps {
name: string;
}
function Foo({ name }: IProps) {
return <div className={name} />;
}
const wrapper = shallow(<Foo name="foo" />);
expect(wrapper.find('.foo')).toHaveLength(1);
expect(wrapper.find('.bar')).toHaveLength(0);
wrapper.setProps({ name: 'bar' });
expect(wrapper.find('.foo')).toHaveLength(0);
expect(wrapper.find('.bar')).toHaveLength(1);
复制代码
因为咱们的项目都是基于umi去开发的,并且umi框架下也已经集成了Jest,因此示例也基于Jest来建立。代码可见:Test React App with Jest and Enzyme 。在这里,我将演示如何去配置其余依赖,以及进行初步的测试编写。
打开src\pages\__tests__\index.test.tsx
, 能够看到umi中默认使用了react-test-renderer做为DOM测试工具,而且已经有了第一段测试代码:
describe('Page: index', () => {
it('Render correctly', () => {
const wrapper: ReactTestRenderer = renderer.create(<Index />);
expect(wrapper.root.children.length).toBe(1);
const outerLayer = wrapper.root.children[0] as ReactTestInstance;
expect(outerLayer.type).toBe('div');
expect(outerLayer.children.length).toBe(2);
});
})
复制代码
而后咱们开始须要添加Enzyme,在React 16.x中,咱们还须要Enzyme-Adapter-16,此外咱们还须要添加对应的typescript的类型定义依赖:
yarn add -D enzyme @types/enzyme enzyme-adapter-react-16 @types/enzyme-adapter-react-16
复制代码
而后在以前打开的文件中添加如下代码:
import { configure, mount } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
// 配置enzyme的adapter
configure({ adapter: new Adapter() });
复制代码
describe('Page: index', () => {
it('Render correctly', () => {
const wrapper = mount(<Index />);
expect(wrapper.children()).toHaveLength(1);
const outerLayer = wrapper.childAt(0);
expect(outerLayer.type()).toBe('div');
expect(outerLayer.children()).toHaveLength(2);
});
});
复制代码
运行umi test
,而后控制台中就会看到如下信息:
而后打开index.tsx
, 并引入useState, 而后在function顶部添加以下代码:
const [myState, setMyState] = useState('Welcome to Umi');
const changeState = () => setMyState('Welcome to Jest and Enzyme');
复制代码
而后将如下代码:
<a href="https://umijs.org/guide/getting-started.html">
Getting Started
</a>
复制代码
替换为:
<div id="intro">{myState}</div>
<button onClick={changeState}>Change</button
复制代码
而后增长以下的测试代码:
it('case of use state', () => {
const wrapper = shallow(<Index />);
expect(wrapper.find('#intro').text()).toBe('Welcome to Umi');
wrapper.find('button').simulate('click');
expect(wrapper.find('#intro').text()).toBe('Welcome to Jest and Enzyme');
})
复制代码
运行umi test
, 能够发现咱们的测试已经生效了。
以前讲过了Jest是支持快照测试的。如今给Index
添加快照。首先咱们要添加以下依赖:
yarn add -D enzyme-to-json @types/enzyme-to-json
复制代码
而后在在测试用例中增长以下代码:
it('matches snapshot', () => {
const wrapper = shallow(<Index />);
expect(toJson(wrapper)).toMatchSnapshot();
});
复制代码
再运行umi test
,咱们能够看到snapshot已经生成了:
接下来写一个相对完整的示例——todo list,该示例整合了redux,主要实现如下功能:
具体能够参考src\pages\todoDemo\index.tsx
, 测试代码以下:
describe('<TodoList />', () => {
it('matches snapshot', () => {
const todos: Array<todo> = [];
const wrapper = shallow(<TodoList todos={todos} />);
expect(toJson(wrapper)).toMatchSnapshot();
});
it('calls setState after input change', () => {
const wrapper = shallow(<TodoList todos={[]} />);
wrapper.find('input').simulate('change', { target: { value: 'Add Todo' } });
expect(wrapper.state('input')).toEqual('Add Todo');
});
it('calls addTodo with submit button click', () => {
const addTodo = jest.fn();
const todos: Array<todo> = [];
const wrapper = shallow(<TodoList todos={todos} addTodo={addTodo} />);
wrapper.find('input').simulate('change', { target: { value: 'Add Todo' } });
wrapper.find('.todo-add').simulate('click');
expect(addTodo).toHaveBeenCalledWith('Add Todo');
});
it('calls removeTodo with todo item click', () => {
const removeTodo = jest.fn();
const todos: Array<todo> = [{ text: 'Learn Jest' }, { text: 'Learn RxJS' }];
const wrapper = shallow(<TodoList todos={todos} removeTodo={removeTodo} />);
wrapper
.find('li')
.at(0)
.simulate('click');
expect(removeTodo).toHaveBeenCalledWith(0);
});
})
复制代码
基于Jest和Enzyme,咱们也能够很方便的去监听生命周期的变化:
import React from 'react';
import { shallow } from 'enzyme';
const orderCallback = jest.fn();
interface LifecycleState {
currentLifeCycle: string;
}
class Lifecycle extends React.Component<any, LifecycleState> {
static getDerivedStateFromProps() {
orderCallback('getDerivedStateFromProps');
return { currentLifeCycle: 'getDerivedStateFromProps' };
}
constructor(props: any) {
super(props);
this.state = { currentLifeCycle: 'constructor' };
orderCallback('constructor');
}
componentDidMount() {
orderCallback('componentDidMount');
this.setState({
currentLifeCycle: 'componentDidMount',
});
}
componentDidUpdate() {
orderCallback('componentDidUpdate');
}
render() {
orderCallback('render');
return <div>{this.state.currentLifeCycle}</div>;
}
}
describe('React Lifecycle', () => {
beforeEach(() => {
orderCallback.mockReset();
});
it('renders in correct order', () => {
const _ = shallow(<Lifecycle />);
expect(orderCallback.mock.calls[0][0]).toBe('constructor');
expect(orderCallback.mock.calls[1][0]).toBe('getDerivedStateFromProps');
expect(orderCallback.mock.calls[2][0]).toBe('render');
expect(orderCallback.mock.calls[3][0]).toBe('componentDidMount');
expect(orderCallback.mock.calls[4][0]).toBe('getDerivedStateFromProps');
expect(orderCallback.mock.calls[5][0]).toBe('render');
expect(orderCallback.mock.calls[6][0]).toBe('componentDidUpdate');
expect(orderCallback.mock.calls.length).toBe(7);
});
it('detect lify cycle methods', () => {
const _ = shallow(<Lifecycle />);
expect(Lifecycle.getDerivedStateFromProps.call.length).toBe(1);
expect(Lifecycle.prototype.render.call.length).toBe(1);
expect(Lifecycle.prototype.componentDidMount.call.length).toBe(1);
expect(Lifecycle.getDerivedStateFromProps.call.length).toBe(1);
expect(Lifecycle.prototype.render.call.length).toBe(1);
expect(Lifecycle.prototype.componentDidUpdate.call.length).toBe(1);
});
});
复制代码
Antd的源码中已经有很完备的单元测试支撑了,有兴趣能够去研究一下,这里不作展开,只对以前踩过的坑分析一下:
class Demo extends React.Component<FormComponentProps> {
reset = () => {
const { form } = this.props;
form.resetFields();
};
onSubmit = () => {
const { form } = this.props;
form.resetFields();
// 提交操做
};
render() {
const {
form: { getFieldDecorator },
} = this.props;
return (
<Form onSubmit={this.onSubmit}>
<Form.Item>{getFieldDecorator('input', { initialValue: '' })(<Input />)}</Form.Item>
<Form.Item>
{getFieldDecorator('textarea', { initialValue: '' })(<Input.TextArea />)}
</Form.Item>
<button type="button" onClick={this.reset}>
reset
</button>
<button type="submit">submit</button>
</Form>
);
}
}
复制代码
测试重置表单事件,咱们只须要模拟重置按钮的click
时间:
it('click to reset', () => {
const wrapper = mount(<FormDemo />);
wrapper.find('input').simulate('change', { target: { value: '111' } });
wrapper.find('textarea').simulate('change', { target: { value: '222' } });
expect(wrapper.find('input').prop('value')).toBe('111');
expect(wrapper.find('textarea').prop('value')).toBe('222');
wrapper.find('button[type="button"]').simulate('click');
expect(wrapper.find('input').prop('value')).toBe('');
expect(wrapper.find('textarea').prop('value')).toBe('');
});
复制代码
若是要测试表单的提交事件,则应该模拟表单的submit
事件(除非该提交事件是绑定在button元素上,并且button的type为“button”)
it('click to submit', () => {
const wrapper = mount(<FormDemo />);
wrapper.find('input').simulate('change', { target: { value: '111' } });
wrapper.find('textarea').simulate('change', { target: { value: '222' } });
expect(wrapper.find('input').prop('value')).toBe('111');
expect(wrapper.find('textarea').prop('value')).toBe('222');
wrapper.find('form').simulate('submit');
expect(wrapper.find('input').prop('value')).toBe('');
expect(wrapper.find('textarea').prop('value')).toBe('');
});
复制代码
Input.Search
:antd的Input
和Input.TextArea
能够直接模拟onChange事件,可是Input.Search
中的onSearch并非DOM原生事件,因此咱们须要这样去测试:
describe('antd event test', () => {
it('test search event', () => {
const mockSearch = jest.fn();
const wrapper = mount(
<div>
<Search onSearch={mockSearch} />
</div>,
);
const onSearch = wrapper.find(Search).props().onSearch;
if (onSearch !== undefined) {
onSearch(searchText);
expect(mockSearch).toBeCalledWith(searchText);
} else {
expect(mockSearch).not.toBeCalled();
}
});
});
复制代码
最后,咱们运行一下umi test --coverage
, 就能够看到最后的覆盖率数据了。其中未测试的代码为mapDispatchToProps
和mapStateToProps