4.React中的TDD和单元测试
4.1 什么是TDD
TDD的开发流程:(Red-Green development)
1.编写测试用例;
2.运行测试,测试用例无法通过测试;
3.编写代码,使测试用例通过测试
4.优化代码,完成开发
5.重复上述步骤
TDD优势
1.长期减少回归bug;
2.代码质量良好(组织,可维护性)
3.测试覆盖率高,一般测试覆盖率在80%,90%,不能做到100%
4.错误测试代码不容易出现
接下来通过一个TodoList项目来了解TDD的流程
4.2 React环境中配置Jest
执行下面 命令
指定这个版本会有问题,npm install create-react-app@3.0.1 -g
npm install create-react-app
create-react-app jest-react
进入jest-react目录执行
npm install
jest-react目录下面有个隐藏的git文件夹,我们可以使用git来管理代码。
通过下面的命令创建一个分支
git branch lesson1
git checkout lesson1 //切换到lesson1这个分支
npm run start //启动项目
执行npm run eject命令之前执行下面命令,不然会报错
git add .
gti commint -m 'add lock file'
其实脚手架已经集成了jest命令,执行npm run eject命令会把隐藏的配置项都弹射出来
jest有2个要求,有一个是通过git来管理,还有一个是要安装jest,这里已经都满足了。
jest的配置项但可以写在文件 jest.config.js中,也可以写在package.json文件中(该文件中有配置项叫'jest")
关于jest配置项的介绍后面再补充。
使用默认保存的格式化工具保存jsx形式的react代码,格式会有问题,
解决方法:右下角,把语言模式 JavaScript
改成 JavaScript React
4.3 Enzyme的配置
删除脚手架里面的冗余代码,只剩下App.js代码,index.js代码和App.test.js文件。
App.js代码如下
import React from 'react';
function App() {
return (
<div className="App">
hello world2
</div>
);
}
export default App;
App.test.js文件代码如下:
import React from 'react';
import ReactDOM from 'react-dom';
import {
render
} from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
// const {
// getByText
// } = render( < App / > );
// const linkElement = getByText(/learn react/i);
// expect(linkElement).toBeInTheDocument();
expect(2).toBe(2);
});
test('renders without crashing', () => {
const div = document.createElement('div');
ReactDOM.render( < App / > , div);
ReactDOM.unmountComponentAtNode(div);
});
如下代码,可以测试渲染的组件的内容元素
import React from 'react';
import ReactDOM from 'react-dom';
import {
render
} from '@testing-library/react';
import App from './App';
test('renders without crashing', () => {
const div = document.createElement('div');
ReactDOM.render(< App />, div);
// ReactDOM.unmountComponentAtNode(div);
//如果没有抛出异常的话,则测试用例通过
// 如下:抛出异常的话,测试用例不通过,
// throw new Error();
// 测试渲染的元素内容,找到className="App"的标签
const container = div.getElementsByClassName('App');
console.log(container)
// HTMLCollection { }
expect(container.length).toBe(1);
});
前端单元测试中如果直接去写这种面向DOM的测试用例,是有局限性的,在做前端单元测试的时候,有的时候想要测试组件的state和prop状态是否正确,不仅要测试DOM的展开,还要测试组件里面的数据细节,直接通过DOM做测试就没办法实现我们对组件内部数据做测试的需求了。
面向DOM的测试用例有局限性,有时候要测试测试一个组件的prop和state(组件上的状态),DOM的测试只能让我们测试组件的渲染,所以airbnb公司的enzyme的引入就是为了解决这个问题。
enzyme其实是对 ReactDOM.render做了一些包装,提供了一些额外的方法供我们调用,是我们能够对组件进行更灵活的测试。
首先安装enzyme,可以去github上搜索airbnb公司的enzyme,(https://github.com/enzymejs/enzyme),查看相关介绍
npm i --save-dev enzyme enzyme-adapter-react-16
如何配置呢?在测试文件中添加下面的内容
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({ adapter: new Adapter() });
将这段代码直接复制到App.test.js文件中去,这样测试用例中就可以使用enzyme了。
enzyme其实是对 ReactDOM.render做了一些包装,有了Enzyme,就不需要ReactDOM了,删除相关的代码
如下代码:
import React from 'react';
import App from './App';
import Enzyme, {
shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({
adapter: new Adapter()
});
test('renders without crashing', () => {
// const div = document.createElement('div');
// ReactDOM.render(< App />, div);
// const container = div.getElementsByClassName('App');
// expect(container.length).toBe(1);
//shallow是浅复制,或者浅渲染。App可能包含多个组件,shallow仅仅是对App组件进行完整的渲染,对于App内部的组件可能只有一个标记来代替
//shallow只关注App这一组件,不关心下面的,只渲染这一层渲染速度比较快
//适合于对一个组件做单元测试
//如下shallow是用Enzyme生成的语法,所以可以用Enzyme里面的方法了,上面注释的代码就可以用下面简单的形式了
const wrapper = shallow(< App />);
//console.log(wrapper.find('.App').length);
// 1,find函数里面是一个选择器,利用选择器就可以选择到div了
expect(wrapper.find('.App').length).toBe(1);
});
接下来试试shallow的其他方法,修改App.js内容,给div添加title属性,如下代码:
import React from 'react';
function App() {
return (
<div className="App" title="Dell">
hello world2
</div>
);
}
export default App;
如何测试呢?
import React from 'react';
import App from './App';
import Enzyme, {
shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({
adapter: new Adapter()
});
test('renders without crashing', () => {
const wrapper = shallow(< App />);
// console.log(wrapper.find('.App').prop('title'));
//"Dell"
//测试用例通过
expect(wrapper.find('.App').prop('title')).toBe("Dell");
//测试用例通不过
// expect(wrapper.find('.App').prop('name')).toBe("Dell");
//如果有时候测试的时候发现测试用例没通过,但是不知道为什么,可以开启enzyme的调试模式
// console.log(wrapper.debug()),可以将渲染的内容打印输出,如下
console.log(wrapper.debug());
{/* <div className="App" title="Dell" data-test="container">
hello world2
</div> */}
//修改之后,,测试用例可以通过了
expect(wrapper.find('.App').prop('title')).toBe("Dell");
});
上面的测试代码有个小问题,就是测试用例的代码和要测试代码耦合很高,上面是通过div的className属性来获取元素的,如果我们觉得div的className不太合适的,会对其进行修改,修改之后测试代码也要跟着修改,代码是耦合的,这样就会比较麻烦。可以用下面的方式来解决这个问题。给要测试的div添加一个专门的测试属性:
App.js代码如下:
import React from 'react';
function App() {
return (
<div className="App" title="Dell" data-test='container'>
hello world2
</div>
);
}
export default App;
App.test.js代码如下:
import React from 'react';
import App from './App';
import Enzyme, {
shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({
adapter: new Adapter()
});
test('renders without crashing', () => {
const wrapper = shallow(< App />);
console.log(wrapper.debug())
expect(wrapper.find('[data-test="container"]').length).toBe(1);
expect(wrapper.find('[data-test="container"]').prop('title')).toBe("Dell");
});
这样的过程就是测试代码和要测试代码解耦的过程。
Enzyme里面有jest-enzyme,下面链接里面可以看到一些API方法
https://github.com/FormidableLabs/enzyme-matchers/tree/master/packages/jest-enzyme
这个链接里面有很多可以使用的比较简单的API方法,如下所示,之前写的代码,可以简化成下面的形式,但是发现运行的时候报错了,toExist未定义
const wrapper = shallow(< App />);
expect(wrapper.find('[data-test="container"]').length).toBe(1);
expect(wrapper.find('[data-test="container"]')).toExist();
这时需要安装Jest-enzyme
npm install jest-enzyme --save-dev
在使用的时候,还需要在package.json文件中引入jest-enzyme,如下所示,github连接里面也有介绍
"setupFilesAfterEnv": [
"./node_modules/jest-enzyme/lib/index.js"
],
重启启动npm run test命令后,就可以使用这些简化语法的API了。
Jest-enzyme 里面是有很多匹配器的,可以使用这个里面的匹配器
import React from 'react';
import App from './App';
import Enzyme, {
shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({
adapter: new Adapter()
});
test('renders without crashing', () => {
const wrapper = shallow(< App />);
console.log(wrapper.debug())
// expect(wrapper.find('[data-test="container"]').length).toBe(1);
//expect(wrapper.find('[data-test="container"]').prop('title')).toBe("Dell");
//引入jest-enzyme之后,就可以使用下面的匹配器了,和上面相比,比较简单
expect(wrapper.find('[data-test="container"]')).toExist();
expect(wrapper.find('[data-test="container"]')).toHaveProp('title', 'Dell');
});
可以优化为下面的形式
import React from 'react';
// import {
// render
// } from '@testing-library/react';
import App from './App';
import Enzyme, {
shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({
adapter: new Adapter()
});
test('renders without crashing', () => {
const wrapper = shallow(< App />);
const continer = wrapper.find('[data-test="container"]');
expect(continer).toExist();
expect(continer).toHaveProp('title', 'Dell');
});
接下来我们看看mount方法
//mount和shallow是对应的,当App组件有子组件时候,mount会把子组件也渲染出来
//在做集成测试的时候,需要测试一堆组件的时候,使用mount比较合适
//单元测试适合用shallow,集成测试适合用mount
import React from 'react';
import App from './App';
import Enzyme, {
mount
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({
adapter: new Adapter()
});
test('renders without crashing', () => {
const wrapper = mount(< App />);
console.log(wrapper.debug())
// < App >
// <div className="App" title="Dell" data-test="container">
// hello world2
// </div>
// </App >
const continer = wrapper.find('[data-test="container"]');
expect(continer).toExist();
expect(continer).toHaveProp('title', 'Dell');
});
其实这里组件测试也可以使用toMatchSnapshot() 匹配器,这个匹配器适用于测试组件内容不发生改变的组件,当组件内容更新之后,再更新snapshot。
expect(wrapper).toMatchSnapshot();
4.4 利用TDD的方式来开发项目
接下来通过一个TodoList的实例来理解TDD的开发流程
4.4.1 利用TDD的方式开发Header组件
在项目的src目录下新建containers文件夹,在containers文件夹下面新建TodoList文件夹,在其目录下新建index.js文件,index.js文件代码如下
import React from 'react';
function TodoList() {
return (
<div>TodoList</div>
);
}
export default TodoList;
App.js文件代码如下:
import React from 'react';
import TodoList from './containers/TodoList'
function App() {
return (
<div>
<TodoList />
</div>
);
}
export default App;
一般测试文件会在同级目录下面建立,一般我们会在containers/TodoList同级目录下建立__ tests __文件夹,然后其目录下建立unit文件夹,表示是该组件的单元测试文件代码。
在TodoList文件夹下面建立components文件夹,文件目录下放与TodoList相关的代码,在这个目录下面新建Header.js文件,代码如下
import React, { Component } from 'react';
class Header extends Component {
render() {
return (<div>Header</div>);
}
}
export default Header;
在unit目录下建立Header.js文件,表示是Header.js文件的测试用例。
Header组件实现了输入文本框的内容,按下回车键后,可以追加到代办列表中
TDD的开发流程是写测试用例,测试用例是失败的,然后写代码让测试用例通过,最后再优化代码的过程。
首先来开发测试用例,Header组件里面有一个input组件,Header组件的测试代码如下:
__ tests __文件夹下面的Header.js文件代码如下:
import React from 'react';
import Header from '../../components/Header'
import Enzyme, {
shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({
adapter: new Adapter()
});
test('header组件包含一个input框', () => {
//单元测试适合用shallow
const wrapper = shallow(< Header />);
const inputElme = wrapper.find("[data-test='input']");
expect(inputElme.length).toBe(1);
});
执行npm run test命令发现测试用例没有通过,然后我们去写源代码让测试用例通过,这就是TDD的开发流程
修改components目录下面的Header.js文件代码,代码如下,这样我们编写的测试用例就可以通过了。
import React, { Component } from 'react';
class Header extends Component {
render() {
return (
<div>
<input data-test='input' />
</div>);
}
}
export default Header;
第2个测试用例是往input框里面输入内容时,input框里面的内容会发生变化。下面代码的第2个测试用例执行会报错,
import React from 'react';
import Header from '../../components/Header'
import Enzyme, {
shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({
adapter: new Adapter()
});
test('header组件包含一个input框', () => {
//单元测试适合用shallow
const wrapper = shallow(< Header />);
const inputElme = wrapper.find("[data-test='input']");
expect(inputElme.length).toBe(1);
});
test('header组件 input框 内容,初始化应该为空', () => {
const wrapper = shallow(< Header />);
const inputElme = wrapper.find("[data-test='input']");
expect(inputElme.prop('value')).toEqual('');
});
Header是一个受控组件,所以其value值是输入值来决定的,修改Header.js文件内容,使测试用例通过
import React, { Component } from 'react';
class Header extends Component {
constructor(props) {
super(props);
this.state = {
value: ''
}
}
render() {
const { value } = this.state;
return (
<div>
<input data-test='input' value={value} />
</div>);
}
}
export default Header;
第3个测试用例是往input框里面输入内容时,input框里面的内容会发生变化,如下的测试用例,第3个测试用例未通过,
import React from 'react';
import Header from '../../components/Header'
import Enzyme, {
shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({
adapter: new Adapter()
});
test('header组件包含一个input框', () => {
//单元测试适合用shallow
const wrapper = shallow(< Header />);
const inputElme = wrapper.find("[data-test='input']");
expect(inputElme.length).toBe(1);
});
test('header组件 input框 内容,初始化应该为空', () => {
const wrapper = shallow(< Header />);
const inputElme = wrapper.find("[data-test='input']");
expect(inputElme.prop('value')).toEqual('');
});
//测试用例没通过
test('header组件 input框 内容,当用户输入时,会跟随变化', () => {
//模拟用户输入的动作,通过simulate方法可以模拟这个事件,这个方法里面有e,e里面有target
const wrapper = shallow(< Header />);
const inputElme = wrapper.find("[data-test='input']");
const userInput = '今天要学习Jest';
inputElme.simulate('change', {
target: { value: userInput }
})
expect(wrapper.state('value')).toEqual(userInput);
});
修改Header.js文件,添加Input控件的onChange方法,使测试用例通过。
import React, { Component } from 'react';
class Header extends Component {
constructor(props) {
super(props);
this.handleInputChange = this.handleInputChange.bind(this);
this.state = {
value: ''
}
}
handleInputChange(e) {
this.setState({
value: e.target.value
})
}
render() {
const { value } = this.state;
return (
<div>
<input data-test='input' value={value} onChange={this.handleInputChange} />
</div>);
}
}
export default Header;
我们不仅可以测试state的内容,还可以测试input标签的属性等等。如下的代码:
test('header组件 input框 内容,当用户输入时,会跟随变化', () => {
//模拟用户输入的动作,通过simulate方法可以模拟这个事件,这个方法里面有e,e里面有target
const wrapper = shallow(< Header />);
const inputElme = wrapper.find("[data-test='input']");
const userInput = '今天要学习Jest';
//对用户的动作做测试,单元测试里面一般是面向数据的测试,一般是这种测试
inputElme.simulate('change', {
target: { value: userInput }
})
expect(wrapper.state('value')).toEqual(userInput);
//当用户操作后,对组件的DOM属性做测试,继承测试中一般是对DOM属性做测试
//组件属性发生变化之后,一般都要重新获取,要不然取到的还是老的组件
// const newInputElme = wrapper.find("[data-test='input']");
// expect(newInputElme.prop('value')).toBe(userInput);
});
接下来编写其他的测试用例,当在input框里面输入内容时候,点击回车,我们希望把input框里面的内容存入到最外层的TodoList组件中,这个测试用例怎么写呢?如下代码
test('header组件 input框 输入回车时,如果input 无内容,无操作', () => {
//当在input框中添加内容时,会调用外层的方法,把内容添加到TodoList组件中
const fn = jest.fn();
const wrapper = shallow(< Header addUndoItem={fn} />);
const inputElme = wrapper.find("[data-test='input']");
//确保内容是空
wrapper.setState({
value: ''
})
//keyUp为13时,表示输入回车键
inputElme.simulate('keyUp', {
keyCode: 13
})
expect(fn).not.toHaveBeenCalled();
});
//这个测试用例没有通过,因为代码没写,回车的时候什么都没有执行
test('header组件 input框 输入回车时,如果input 无内容,函数应该被调用', () => {
//当在input框中添加内容时,会调用外层的方法,把内容添加到TodoList组件中
const fn = jest.fn();
const wrapper = shallow(< Header addUndoItem={fn} />);
const inputElme = wrapper.find("[data-test='input']");
//确保内容是空
wrapper.setState({ value: '学习React' })
//keyUp为13时,表示输入回车键
inputElme.simulate('keyUp', {
keyCode: 13
})
expect(fn).toHaveBeenCalled();
});
接着我们补充没有实现的功能,让测试用例通过,Header.js代码如下:
import React, { Component } from 'react';
class Header extends Component {
//addUndoItem
constructor(props) {
super(props);
this.handleInputChange = this.handleInputChange.bind(this);
this.handleInputKeyUp = this.handleInputKeyUp.bind(this);
this.state = {
value: ''
}
}
handleInputKeyUp(e) {
const { value } = this.state;
if (e.keyCode === 13 && value) {
this.props.addUndoItem(value)
}
}
handleInputChange(e) {
this.setState({
value: e.target.value
})
}
render() {
const { value } = this.state;
return (
<div>
<input data-test='input'
value={value}
onChange={this.handleInputChange}
onKeyUp={this.handleInputKeyUp}
/>
</div>);
}
}
export default Header;
测试代码还可以进一步优化,使用 toHaveBeenLastCalledWith,如下代码所示:
test('header组件 input框 输入回车时,如果input 无内容,函数应该被调用', () => {
//当在input框中添加内容时,会调用外层的方法,把内容添加到TodoList组件中
const fn = jest.fn();
const wrapper = shallow(< Header addUndoItem={fn} />);
const inputElme = wrapper.find("[data-test='input']");
//确保内容是空
wrapper.setState({ value: '学习React' })
//keyUp为13时,表示输入回车键
inputElme.simulate('keyUp', {
keyCode: 13
})
expect(fn).toHaveBeenCalled();
//不但有测试函数是否被调用过的API函数,还可以测试函数被调用的参数
expect(fn).toHaveBeenLastCalledWith('学习React');
});
最后写完的Header.js测试代码如下
import React from 'react';
import Header from '../../components/Header'
import Enzyme, {
shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({
adapter: new Adapter()
});
test('header组件包含一个input框', () => {
//单元测试适合用shallow
const wrapper = shallow(< Header />);
const inputElme = wrapper.find("[data-test='input']");
expect(inputElme.length).toBe(1);
});
test('header组件 input框 内容,初始化应该为空', () => {
const wrapper = shallow(< Header />);
const inputElme = wrapper.find("[data-test='input']");
expect(inputElme.prop('value')).toEqual('');
});
test('header组件 input框 内容,当用户输入时,会跟随变化', () => {
//模拟用户输入的动作,通过simulate方法可以模拟这个事件,这个方法里面有e,e里面有target
const wrapper = shallow(< Header />);
const inputElme = wrapper.find("[data-test='input']");
const userInput = '今天要学习Jest';
//对用户的动作做测试,单元测试里面一般是面向数据的测试,一般是这种测试,下面模拟e
inputElme.simulate('change', {
target: { value: userInput }
})
expect(wrapper.state('value')).toEqual(userInput);
//当用户操作后,对组件的DOM属性做测试,继承测试中一般是对DOM属性做测试
//组件属性发生变化之后,一般都要重新获取,要不然取到的还是老的组件
// const newInputElme = wrapper.find("[data-test='input']");
// expect(newInputElme.prop('value')).toBe(userInput);
});
test('header组件 input框 输入回车时,如果input 无内容,无操作', () => {
//当在input框中添加内容时,会调用外层的方法,把内容添加到TodoList组件中
const fn = jest.fn();
const wrapper = shallow(< Header addUndoItem={fn} />);
const inputElme = wrapper.find("[data-test='input']");
//确保内容是空
wrapper.setState({
value: ''
})
//keyUp为13时,表示输入回车键
inputElme.simulate('keyUp', {
keyCode: 13
})
expect(fn).not.toHaveBeenCalled();
});
test('header组件 input框 输入回车时,如果input 有内容,函数应该被调用', () => {
//当在input框中添加内容时,会调用外层的方法,把内容添加到TodoList组件中
const fn = jest.fn();
const wrapper = shallow(< Header addUndoItem={fn} />);
const inputElme = wrapper.find("[data-test='input']");
//确保内容是空
wrapper.setState({ value: '学习React' })
//keyUp为13时,表示输入回车键
inputElme.simulate('keyUp', {
keyCode: 13
})
expect(fn).toHaveBeenCalled();
//不但有测试函数是否被调用过的API函数,还可以测试函数被调用的参数
expect(fn).toHaveBeenLastCalledWith('学习React');
});
test('header组件 input框 输入回车时,如果input 有内容,最后应该被清除', () => {
const fn = jest.fn();
const wrapper = shallow(< Header addUndoItem={fn} />);
const inputElme = wrapper.find("[data-test='input']");
//确保内容是空
wrapper.setState({ value: '学习React' })
//keyUp为13时,表示输入回车键
inputElme.simulate('keyUp', {
keyCode: 13
})
//当输入回车后,清空input框里面的内容
//这里要等回车之后再次获取,如果直接用inputElme还是,初始化的inputElme,本来是空的
const newInputElme = wrapper.find("[data-test='input']");
expect(newInputElme.prop('value')).toBe('');
});
Header.js的源代码如下
import React, { Component } from 'react';
class Header extends Component {
//addUndoItem
constructor(props) {
super(props);
this.handleInputChange = this.handleInputChange.bind(this);
this.handleInputKeyUp = this.handleInputKeyUp.bind(this);
this.state = {
value: ''
}
}
handleInputKeyUp(e) {
const { value } = this.state;
if (e.keyCode === 13 && value) {
this.props.addUndoItem(value);
this.setState({
value: ''
})
}
}
handleInputChange(e) {
this.setState({
value: e.target.value
})
}
render() {
const { value } = this.state;
return (
<div>
<input data-test='input'
value={value}
onChange={this.handleInputChange}
onKeyUp={this.handleInputKeyUp}
/>
</div>);
}
}
export default Header;
4.4.2 TodoList的测试代码编写
在unit目录下新建文件TodoList.js,来编写TodoList的单元测试
TodoList.js的测试文件代码如下:通过一个个编写测试用例,让测试用例通过完成我们的代码开发过程
import React from 'react';
import TodoList from '../../index'
import Enzyme, {
shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({
adapter: new Adapter()
});
test('TodoList 初始化列表为空', () => {
//TodoList里面undoList为空
const wrapper = shallow(< TodoList />);
expect(wrapper.state('undoList')).toEqual([]);
});
test('TodoList 应该给 Header组件传递一个增加undoList的方法', () => {
const wrapper = shallow(< TodoList />);
const Header = wrapper.find('Header');
//TodoList组件里面给Header组件传递了一个方法,叫做addUndoItem
//wrapper.instance().addUndoItem表示这个方法是TodoList实例上的一个方法,是类里面的一个方法(this.addUndoItem)
expect(Header.prop('addUndoItem')).toBe(wrapper.instance().addUndoItem)
});
test('当Header 回车时,TodoList应该新增内容', () => {
//这是个单元测试,因此不要考虑Header点击回车的方法,尽量让TodoList和Header完全解耦
//实际上当Header回车的时候,实际上调用Header上的addUndoItem方法
//在做TodoList单元测试的时候,不要想着Header里面的东西,能在这个组件内部完成的东西,直接在这个组件上完成
const wrapper = shallow(< TodoList />);
const Header = wrapper.find('Header');
const addFunc = Header.prop('addUndoItem');
addFunc('学习React')
expect(wrapper.state('undoList').length).toBe(1);
expect(wrapper.state('undoList')[0]).toBe('学习React');
});
index.js的源代码如下:
import React, { Component } from 'react';
import Header from './components/Header'
class TodoList extends Component {
constructor(props) {
super(props);
this.addUndoItem = this.addUndoItem.bind(this);
this.state = {
undoList: []
}
}
addUndoItem(value) {
this.setState({
undoList: [...this.state.undoList, value]
})
}
render() {
return (
<div>
<Header addUndoItem={this.addUndoItem} />
</div>
);
}
}
export default TodoList;
测试代码通过之后,我们也不知道界面UI写的是否正确,修改index.js代码如下:
import React, { Component } from 'react';
import Header from './components/Header'
class TodoList extends Component {
constructor(props) {
super(props);
this.addUndoItem = this.addUndoItem.bind(this);
this.state = {
undoList: []
}
}
addUndoItem(value) {
this.setState({
undoList: [...this.state.undoList, value]
})
}
render() {
return (
<div>
<Header addUndoItem={this.addUndoItem} />
{
this.state.undoList.map((item, index) => {
return <div key={index}>{item}</div>
})
}
</div>
);
}
}
export default TodoList;
以上过程可以看出,利用TDD的方式编写TodoList组件,能够发现代码中的大部分bug。
4.4.3 Header 组件样式新增及快照测试
在TodoList文件夹下面新建文件style.css,代码内容如下:
* {
margin: 0;
padding: 0
}
.header {
line-height: 60px;
background: #333;
font-size: 24px;
color: #fff;
}
.header-content {
600px;
margin: 0 auto;
font-size: 24px;
color: #fff;
}
.header-input {
outline: none;
360px;
margin-top: 15px;
float: right;
line-height: 24px;
border-radius: 5px;
padding: 0 10px;
}
在index.js文件里引入css文件
import React, { Component } from 'react';
import Header from './components/Header'
import './style.css'
在Header.js文件里面添加一些样式
import React, { Component } from 'react';
class Header extends Component {
//addUndoItem
constructor(props) {
super(props);
this.handleInputChange = this.handleInputChange.bind(this);
this.handleInputKeyUp = this.handleInputKeyUp.bind(this);
this.state = {
value: ''
}
}
handleInputKeyUp(e) {
const { value } = this.state;
if (e.keyCode === 13 && value) {
this.props.addUndoItem(value);
this.setState({
value: ''
})
}
}
handleInputChange(e) {
this.setState({
value: e.target.value
})
}
render() {
const { value } = this.state;
return (
<div className="header">
<div className="header-content">
TodoList
<input
placeholder="Todo"
className="header-input"
data-test='input'
value={value}
onChange={this.handleInputChange}
onKeyUp={this.handleInputKeyUp}
/>
</div>
</div>);
}
}
export default Header;
当Header组件的样式写完之后,我们不希望它做频繁的变化,可以写个快照测试,如下代码
test('header渲染样式正常', () => {
//单元测试适合用shallow
const wrapper = shallow(< Header />);
expect(wrapper).toMatchSnapshot();
});
之后UI发生变化之后,快照测试不会通过,提醒我们验证一下修改后的样式是否正确。
4.4.4 通用的测试代码提取封装
1.相似代码提取
2.enzyme引入文件封装到一个文件,这个文件配置到package.json文件中的setUpFilesAferEnv配置项里面
上面的例子中虽然额外写了一些测试代码,但是当项目里面新添加一个功能时,需要验证以前的老代码,如果有自动化测试代码,只要确保这些测试用例通过就可以了,如果没有自动化测试代码的话,老代码手动点击回归测试的时间会比较多,非常耗费人力。
4.4.5 UndoList的实现
当实现了文本框输入,输入回车之后,需要将内容添加到undoList中,当点击确认后,内容添加到已经完成项。
接下来我们通过TDD的形式来 实现UndoList。
在components文件目录下新建文件UndoList.js文件,在测试目录unit目录下新建文件UndoList.js,测试文件代码如下所示,每编写一个测试用例,然后再写源代码,
import React from 'react';
import UndoList from '../../components/UndoList'
import Enzyme, {
shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({
adapter: new Adapter()
});
test('未完成列表当数据为空数组时 count数目为0,,列表无内容', () => {
const wrapper = shallow(< UndoList list={[]} />);
const countElem = wrapper.find("[data-test='count']");
const listItems = wrapper.find("[data-test='list-item']");
expect(countElem.text()).toEqual("0");
expect(listItems.length).toEqual(0);
});
test('未完成列表当数据有内容时 count数目显示数据长度,,列表不为空', () => {
const listData = ['学习Jest', '学习TDD', '学习单元测试'];
const wrapper = shallow(< UndoList list={listData} />);
const countElem = wrapper.find("[data-test='count']");
const listItems = wrapper.find("[data-test='list-item']");
expect(countElem.text()).toEqual("3");
expect(listItems.length).toEqual(3);
});
test('未完成列表当数据有内容时 要存在删除按钮', () => {
const listData = ['学习Jest', '学习TDD', '学习单元测试'];
const wrapper = shallow(< UndoList list={listData} />);
const deleteItems = wrapper.find("[data-test='delete-item']");
expect(deleteItems.length).toEqual(3);
});
test('未完成列表当数据有内容时 点击某个删除按钮,,会调用删除方法', () => {
const listData = ['学习Jest', '学习TDD', '学习单元测试'];
const fn = jest.fn();
const index = 1;
const wrapper = shallow(< UndoList deleteItem={fn} list={listData} />);
const deleteItems = wrapper.find("[data-test='delete-item']");
//jest里面先通过数组找某一项,不能通过下标的形式,而是要通过at()方法
deleteItems.at(index).simulate('click');
expect(fn).toHaveBeenLastCalledWith(index);
});
UndoList.js代码如下:
import React, { Component } from 'react';
class UndoList extends Component {
render() {
const { list, deleteItem } = this.props;
return (
<div>
<div data-test="count">{list.length}</div>
<ul>
{
list.map((item, index) => {
return (
<li
data-test='list-item'
key={`${item}-${index}`}
>
{item}
<span
data-test='delete-item'
onClick={() => { deleteItem(index) }}>
-
</span>
</li>)
})
}
</ul>
</div>
)
}
}
export default UndoList;
上面的测试代码就实现了UndoList内部的单元测试,删除的功能就放在TodoList来做,测试代码如下
import React from 'react';
import TodoList from '../../index'
import Enzyme, {
shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({
adapter: new Adapter()
});
test('TodoList 初始化列表为空', () => {
//TodoList里面undoList为空
const wrapper = shallow(< TodoList />);
expect(wrapper.state('undoList')).toEqual([]);
});
test('TodoList 应该给 Header组件传递一个增加undoList的方法', () => {
const wrapper = shallow(< TodoList />);
const Header = wrapper.find('Header');
//TodoList组件里面给Header组件传递了一个方法,叫做addUndoItem
//wrapper.instance().addUndoItem表示这个方法是TodoList实例上的一个方法,是类里面的一个方法(this.addUndoItem)
expect(Header.prop('addUndoItem')).toBeTruthy();
});
test('当addItem 被执行时,undoList应该新增内容', () => {
//这是个单元测试,因此不要考虑Header点击回车的方法,尽量让TodoList和Header完全解耦
//实际上当Header回车的时候,实际上调用Header上的addUndoItem方法
//在做TodoList单元测试的时候,不要想着Header里面的东西,能在这个组件内部完成的东西,直接在这个组件上完成
const wrapper = shallow(< TodoList />);
wrapper.instance().addUndoItem('学习React')
expect(wrapper.state('undoList').length).toBe(1);
expect(wrapper.state('undoList')[0]).toBe('学习React');
});
test('TodoList 应该给 UndoList组件传递list数据,以及deleteItem方法', () => {
const wrapper = shallow(< TodoList />);
const UndoList = wrapper.find('UndoList');
//传递的属性数据
expect(UndoList.prop('list')).toBeTruthy();
expect(UndoList.prop('deleteItem')).toBeTruthy();
});
test('当 deleteItem方法被执行时,undoList应该删除内容', () => {
const wrapper = shallow(< TodoList />);
wrapper.setState({
undoList: ['学习Jest', 'dell', 'lee']
})
wrapper.instance().deleteItem(1);
expect(wrapper.state('undoList')).toEqual(['学习Jest', 'lee']);
//上面的addItem已经
});
index.jsx代码如下:
import React, { Component } from 'react';
import Header from './components/Header'
import './style.css'
import UndoList from './components/UndoList';
class TodoList extends Component {
constructor(props) {
super(props);
this.addUndoItem = this.addUndoItem.bind(this);
this.deleteItem = this.deleteItem.bind(this);
this.state = {
undoList: []
}
}
addUndoItem(value) {
this.setState({
undoList: [...this.state.undoList, value]
})
}
deleteItem(index) {
const newList = [...this.state.undoList];
newList.splice(index, 1);
this.setState({
undoList: newList
})
}
render() {
const { undoList } = this.state;
return (
<div>
<Header addUndoItem={this.addUndoItem} />
<UndoList list={undoList} deleteItem={this.deleteItem} />
</div>
);
}
}
export default TodoList;
4.4.6 给UndoList添加样式
4.4.7 测试代码优化
每个组件测试用例的描述都比较长,可以将每个test测试用例放到describe里面,describe的名称是组件的名称,这样测试代码看起来,可读性就会更高一些。
4.4.8 UndoList编辑功能实现
当编辑undoList的每项时,可以让其变成编辑状态,当失焦或者按下回车时,保存为修改后的名称。
我们存储的undoList数据结构是一个数组,只用于展示,并不是识别input框,所以这里要改变其数据结构,识别是不是input框的状态。
这样添加数据项的时候也应该修改一下数据结构,index.jsx代码如下
import React, { Component } from 'react';
import Header from './components/Header'
import './style.css'
import UndoList from './components/UndoList';
class TodoList extends Component {
constructor(props) {
super(props);
this.addUndoItem = this.addUndoItem.bind(this);
this.deleteItem = this.deleteItem.bind(this);
this.changeStatus = this.changeStatus.bind(this);
this.state = {
undoList: []
}
}
addUndoItem(value) {
this.setState({
undoList: [...this.state.undoList, {
status: 'div',
value
}]
})
}
deleteItem(index) {
const newList = [...this.state.undoList];
newList.splice(index, 1);
this.setState({
undoList: newList
})
}
changeStatus(index) {
console.log(index)
}
render() {
const { undoList } = this.state;
return (
<div>
<Header addUndoItem={this.addUndoItem} />
<UndoList list={undoList} deleteItem={this.deleteItem} changeStatus={this.changeStatus} />
</div>
);
}
}
export default TodoList;
修改代码的数据结构之后,UndoList的测试文件代码也要跟着修改,如下代码
import React from 'react';
import TodoList from '../../index'
import Enzyme, {
shallow
} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({
adapter: new Adapter()
});
test('TodoList 初始化列表为空', () => {
//TodoList里面undoList为空
const wrapper = shallow(< TodoList />);
expect(wrapper.state('undoList')).toEqual([]);
});
test('TodoList 应该给 Header组件传递一个增加undoList的方法', () => {
const wrapper = shallow(< TodoList />);
const Header = wrapper.find('Header');
//TodoList组件里面给Header组件传递了一个方法,叫做addUndoItem
//wrapper.instance().addUndoItem表示这个方法是TodoList实例上的一个方法,是类里面的一个方法(this.addUndoItem)
expect(Header.prop('addUndoItem')).toBeTruthy();
});
test('当addItem 被执行时,undoList应该新增内容', () => {
//这是个单元测试,因此不要考虑Header点击回车的方法,尽量让TodoList和Header完全解耦
//实际上当Header回车的时候,实际上调用Header上的addUndoItem方法
//在做TodoList单元测试的时候,不要想着Header里面的东西,能在这个组件内部完成的东西,直接在这个组件上完成
const wrapper = shallow(< TodoList />);
wrapper.instance().addUndoItem('学习React')
expect(wrapper.state('undoList').length).toBe(1);
expect(wrapper.state('undoList')[0]).toEqual({
status: 'div',
value: '学习React'
});
});
test('TodoList 应该给 UndoList组件传递list数据,以及deleteItem以及changeStatus方法', () => {
const wrapper = shallow(< TodoList />);
const UndoList = wrapper.find('UndoList');
//传递的属性数据
expect(UndoList.prop('list')).toBeTruthy();
expect(UndoList.prop('deleteItem')).toBeTruthy();
expect(UndoList.prop('changeStatus')).toBeTruthy();
});
test('当 deleteItem方法被执行时,undoList应该删除内容', () => {
const wrapper = shallow(< TodoList />);
wrapper.setState({
undoList: ['学习Jest', 'dell', 'lee']
})
wrapper.instance().deleteItem(1);
expect(wrapper.state('undoList')).toEqual(['学习Jest', 'lee']);
//上面的addItem已经
});
UndoList.js代码如下
import React, { Component } from 'react';
class UndoList extends Component {
render() {
const { list, deleteItem, changeStatus } = this.props;
return (
<div className='undo-list'>
<div className="undo-list-title">
正在进行
<div data-test="count" className="undo-list-count">{list.length}</div>
</div>
<ul className="undo-list-content">
{
list.map((item, index) => {
return (
<li className='undo-list-item'
data-test='list-item'
key={index}
onClick={() => changeStatus(index)}
>
{item.value}
<span
className="undo-list-delete"
data-test='delete-item'
onClick={() => { deleteItem(index) }}>
-
</span>
</li>)
})
}
</ul>
</div>
)
}
}
export default UndoList;
修改数据结构后,修改相关报错代码,当单元测试全部通过的时候,页面却是挂的;
使用TDD加上单元测试方式的问题:真正的数据结构或者组件内容发生变化的时候,需要回头重新修改测试用例;因为测试用例里面用了大量耦合的数据;当有需求变更的时候,会导致之前的测试用例不可用。
即便所有的单元测试用例都通过了测试,也无法保证项目在浏览器上可以正确无误地运行,因为单元测试测试的是每个组件,并没有将每个组件集成在一起做测试,这样每个组件是好用的,但是合在一起是否好用不知道。
4.4.9 UndoList编辑功能实现2
该小节实现当输入框失去焦点时,就 不是输入框,而是显示状态了。通过先写测试用例,然后写代码的方式实现这个功能。
4.4.10 CodeCoverage代码覆盖率
看看测试代码覆盖了多少业务逻辑代码
在package.json文件里添加一个命令coverage,执行npm run coverage命令就可以看到测试用例的覆盖率了,可以index.html中详细看出,coverage这2行命令都可以被成功执行。
"scripts": {
"start": "node scripts/start.js",
"build": "node scripts/build.js",
"test": "node scripts/test.js",
"coverage": "node scripts/test.js --coverage --watchAll=false"
//"coverage": "jest --coverage --watchAll=false"
},
4.5 TDD和单元测试总结
TDD和单元测试是2个不同的概念,TDD也可以和集成测试在一起。
TDD的好处:代码质量提高,在写代码之前,反复思考过代码和测试用例分别怎么写才合适
单元测试:
好处:测试覆盖率高;
劣势:业务耦合度高;代码量大(测试代码比源代码还要多); 过于独立(单元测试通过,项目不一定运行正常)
当写函数库的时候,非常适合用单元测试来写。
业务场景下,单元测试的劣势很明显,业务代码使用集成测试更好地保证项目的质量。
5.BDD和集成测试
BDD(Behavaior Driven Development) 行为驱动开发
先写代码,再写测试代码
开发模式 | 介绍 |
---|---|
TDD | 1.先写测试再写代码; |
TDD
1.先写测试再写代码;
2一般结合单元测试使用,是白盒测试;
3.测试重点在代码;
4.安全感第;
5.速度快;
BDD
1.先写代码再写测试;
2.一般结合集成测试使用,是黑盒测试;
3.测试重点在于UI(DOM)
4.安全感高
5.速度慢