前言
- Enzyme介绍
- React16 类组件测试
- React16 函数组件 + Hooks测试
- React17 测试
一. Enzyme介绍
1.1 介绍
Enzyme是 Airbnb 开源的专为 React 服务的测试框架,它的 Api 像 Jquery 一样灵活,因为 Enzyme 是用 cheerio 库解析 html,cheerio 经常用于 node 爬虫解析页面,因此也被成为服务端的 Jquery
1.2 配置Enzyme
要完成渲染测试,还需要 Enzyme Adapter 库的支持,由于React 版本的不同,Enzyme Adapter的版本也不一样。Enzyme Adapter 顾名思义是为了适应不同的 React 版本,它和 React 版本对应关系如下图
// enzyme.js
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16'; // 适应React-16
// Adapter: 在使用 enzyme 时,需要先适配React版本
Enzyme.configure({ adapter: new Adapter() }); // 适应React-16,初始化
export default Enzyme;
/*
// 也可以挂载到global全局环境中
global.shallow = shallow;
global.render = render;
global.mount = mount;
*/
1.3 渲染方式
react测试利器enzyme有三种渲染方式:shallow, mount, render。
1.3.1 shallow:
浅渲染
将组件渲染成虚拟DOM对象,只会渲染第一层,子组件将不会被渲染出来,使得效率非常高。
不需要DOM环境,并可以使用jQuery的方式访问组件的信息
大部分情况下,如果不深入组件内部测试,那么可以使用shallow渲染。
// todo.js
import React from 'react';
const Com = ({children}) => {
return (<h2 className="test">{children}</h2>)
}
const TodoList = (props) => {
return (
<div className="todo-list">
{props.list.map((todo, index) => (
<div key={index}>
<span className="item-text">{todo}</span>
<button onClick={() => props.deleteTodo(index)}>done</button>
</div>
))}
<Com>test</Com>
</div>
);
};
export default TodoList;
import React from 'react';
import Enzyme from '../enzyme'
const { shallow, render, mount } = Enzyme
import TodoList from './todo';
const props = {
list: ['first', 'second'],
deleteTodo: jest.fn()
};
const setupByShallow = () => {
const wrapper = shallow(<TodoList {...props} />);
// console.log('===shallow===', wrapper); // ShallowWrapper {}
return {
props,
wrapper,
};
};
describe('test Todo', () => {
/*
判断组件是否有button,因为不需要渲染子节点,
所以使用shallow方法进行组件的渲染,因为props的list有两项,所以预期应该有两个button按钮
*/
it('button length', () => {
const { wrapper } = setupByShallow();
expect(wrapper.find('button').length).toBe(2);
});
});
1.3.2 mount:
完整渲染
它将组件渲染加载成一个真实的DOM节点,用来测试DOM API的交互和组件的生命周期。用到了jsdom来模拟浏览器环境
import React from 'react';
import Enzyme from '../enzyme'
const { shallow, render, mount } = Enzyme
import TodoList from './todo';
const props = {
list: ['first', 'second'],
deleteTodo: jest.fn()
};
const setupByMount = () => {
const wrapper = mount(<TodoList {...props} />);
// console.log('===mount===', wrapper); // ReactWrapper {}
return {
props,
wrapper,
};
};
describe('test Todo', () => {
/*
判断组件的内容,使用mount方法进行渲染,然后使用forEach判断.item-text的内容是否和传入的值相等
*/
it('should render item equal', () => {
const { wrapper } = setupByMount();
wrapper.find('.item-text').forEach((node, index) => {
expect(node.text()).toBe(wrapper.props().list[index]);
});
});
});
1.3.3 render
静态渲染
它将React组件渲染成静态的HTML字符串,然后使用Cheerio这个库解析这段字符串,
并返回一个Cheerio的实例对象,可以用来分析组件的html结构
所以组件的生命周期方法内的逻辑都测试不到,所以render常常只用来测试一些数据(结构)一致性对比的场景
import React from 'react';
import Enzyme from '../enzyme'
const { shallow, render, mount } = Enzyme
import TodoList from './todo';
const props = {
list: ['first', 'second'],
deleteTodo: jest.fn()
};
const setupByRender = () => {
const wrapper = render(<TodoList {...props} />);
console.log('===render===', wrapper); // ===render=== LoadedCheerio {}
return {
props,
wrapper,
};
};
describe('test Todo', () => {
/*
判断组件是否有button这个元素,因为button是组件里的元素,所有使用render方法进行渲染,预期也会找到连个button元素
*/
it('should render 2 item', () => {
const { wrapper } = setupByRender();
expect(wrapper.find('button').length).toBe(2);
});
});
1.3.4 比较
三种方法中,shallow和mount因为返回的是DOM对象,可以用simulate进行交互模拟,而render方法不可以。
一般shallow方法就可以满足需求,如果需要对子组件进行判断,需要使用render,
如果需要测试组件的生命周期,需要使用mount方法。
1.3.5 性能测试
√ 测试 shallow 500次 (72 ms)
√ 测试 render 500次 (83 ms)
√ 测试 mount 500次 (347 ms)
结果:
shallow < render < mount
const Example = () => {
return (<h2>Example</h2>);
};
describe('test Performance', () => {
it('测试 shallow 500次', () => {
for (let i = 0; i < 500; i++) {
const app = shallow(<Example/>)
app.find('h2').text()
}
})
it('测试 render 500次', () => {
for (let i = 0; i < 500; i++) {
const app = render(<Example/>)
app.find('h2').text()
}
})
it('测试 mount 500次', () => {
for (let i = 0; i < 500; i++) {
const app = mount(<Example/>)
app.find('h2').text()
}
})
});
二. React16 类组件测试
2.1 UI测试
快照会生成一个组件的UI结构,并用字符串的形式存放在__snapshots__文件里,
通过比较两个字符串来判断UI是否改变,因为是字符串比较,所以性能很高
使用 snapshot 进行 UI 测试
当使用toMatchSnapshot的时候,会生成一份组件DOM的快照,
以后每次运行测试用例的时候,都会生成一份组件快照和第一次生成的快照进行对比,
如果对组件的结构进行修改,那么生成的快照就会对比失败。
可以通过更新快照重新进行UI测试
--updateSnapshot:
执行此命令,当UI变化会同步更新快照文件
2.1.1 react-test-renderer
import renderer from 'react-test-renderer';
import React from 'react';
const Button = (props) => {
return (
<div>
<h1>{props.text}</h1>
</div>
);
};
const props = {
text: '按钮测试用例'
};
describe('test Button', () => {
// 第一种方式:通过react-test-renderer
it('Button render correctly', () => {
// 生成快照
const tree = renderer.create(<Button {...props} />).toJSON();
// 匹配之前的快照
expect(tree).toMatchSnapshot();
});
});
2.1.2 enzyme + enzyme-to-json
import toJson from 'enzyme-to-json';
import React from 'react';
import Enzyme from './enzyme'
const { shallow } = Enzyme
const Button = (props) => {
return (
<div>
<h1>{props.text}</h1>
</div>
);
};
const props = {
text: '按钮测试用例'
};
describe('test Button', () => {
// 第二种方式: enzyme-to-json
it('Button render correctly2', () => {
const wrapper = shallow(<Button {...props} />);
expect(toJson(wrapper, {
// 是否返回渲染到最大深度的测试对象, 仅适用于mount包装器
mode: 'deep',
})).toMatchSnapshot();
});
});
2.2 Enzyme常用API
simulate(event, mock):模拟事件,用来触发事件,event为事件名称,mock为一个event object
instance():返回组件的实例
find(selector):根据选择器查找节点,selector可以是CSS中的选择器,或者是组件的构造函数,组件的display name等
at(index):返回一个渲染过的对象
get(index):返回一个react node,要测试它,需要重新渲染
contains(nodeOrNodes):当前对象是否包含参数重点 node,参数类型为react对象或对象数组
text():返回当前组件的文本内容
html(): 返回当前组件的HTML代码形式
props():返回根组件的所有属性
prop(key):返回根组件的指定属性
state():返回根组件的状态
setState(nextState):设置根组件的状态
setProps(nextProps):设置根组件的属性
import renderer from 'react-test-renderer';
import React from 'react';
import Enzyme from './enzyme'
const { shallow, mount } = Enzyme
class Button extends React.Component {
constructor() {
super();
this.type = 'default'
this.state = {
content: 'default'
}
}
changeType(type){
this.type = type
}
render() {
const { content } = this.state;
return (
<>
<h1>{this.props.bar}</h1>
<p>{content}</p>
</>
);
}
};
// 设置props
it('allows us to set props', () => {
const wrapper = mount(<Button bar="baz" />);
expect(wrapper.props().bar).toEqual('baz');
wrapper.setProps({ bar: 'foo' });
expect(wrapper.props().bar).toEqual('foo');
});
// 设置state
it('allows us to set state', () => {
const wrapper = mount(<Button bar="baz" />);
expect(wrapper.find('p').text()).toEqual('default');
wrapper.setState({content: 'new'});
expect(wrapper.find('p').text()).toEqual('new');
});
// 更改实例对象
it('allows us to change instance', () => {
const wrapper = mount(<Button bar="baz" />);
expect(wrapper.instance().type).toEqual('default');
wrapper.instance().changeType('test')
expect(wrapper.instance().type).toEqual('test');
});
2.3 模拟事件操作
2.3.1 模拟click
import React from 'react';
export default class Counter extends React.Component {
constructor(props) {
super(props);
this.state = {count: 0};
this.handleClick = this.handleClick.bind(this);
}
componentDidMount() {
document.title = `You clicked ${this.state.count} times`;
}
componentDidUpdate() {
document.title = `You clicked ${this.state.count} times`;
}
handleClick() {
this.setState(state => ({
count: state.count + 1,
}));
}
render() {
return (
<div>
<p className="para">You clicked {this.state.count} times</p>
<button onClick={this.handleClick}>
Click me
</button>
</div>
);
}
}
describe('Counter', () => {
const counter = shallow(<Counter />); // 浅渲染
it('p tag content', () => {
expect(counter.find('p').text()).toBe('You clicked 0 times');
});
it('simulate handleClick', () => {
counter.find('button').simulate('click')
expect(counter.find('p').text()).toBe('You clicked 1 times');
});
});
2.3.2 模拟change + keyup事件
import React, { useState } from 'react'
const AddTodoView = ({ onAddClick }) => {
const [value, setValue] = useState('');
const onChangeHandle = (e) => {
setValue(e.target.value)
}
const onKeyUpHandle = (e) => {
if (e.keyCode === 13) {
value && onAddClick(value);
setValue('')
}
}
return (
<header className="header">
<h1>todos-{value}</h1>
<input
className="new-todo"
type="text"
value={value}
onKeyUp={e => onKeyUpHandle(e)}
onChange={e => onChangeHandle(e)}
placeholder="input todo item"
/>
</header>
)
}
const setup = () => {
// 模拟 props
const props = {
// Jest 提供的mock 函数
onAddClick: jest.fn()
}
// 通过 enzyme 提供的 shallow(浅渲染) 创建组件
const wrapper = shallow(<AddTodoView {...props} />)
return {
props,
wrapper
};
};
describe('AddTodoView', () => {
const { wrapper, props } = setup();
it('When the Enter key was pressed', () => {
// mock input 输入和 Enter事件
const mockChangeEvent = {
target: {
value: 'Test'
}
};
const mockKeyupEvent = {
keyCode: 13, // enter 事件
};
/*
使用 Enzyme 提供的 .simulate('keyup', mockEvent)来模拟点击事件,
这里的 keyup 会自动转换成 React 组件中的 onKeyUp 并调用
*/
wrapper.find('input').simulate('change', mockChangeEvent);
expect(wrapper.find('h1').text()).toBe('todos-Test');
wrapper.find('input').simulate('keyup', mockKeyupEvent);
// 判断 props.onAddClick 是否被调用
expect(props.onAddClick).toBeCalled();
expect(wrapper.find('h1').text()).toBe('todos-');
});
});
2.4 接口请求相关
2.4.1 真实请求的处理
Q: 如何等待真实的请求完成?
A: waitUntil: 这个函数接受一个函数作为参数,这个函数会返回一个bool值,当bool值为true的时候,表示异步调用结束,可以开始执行后面的逻辑了
import React from 'react';
import axios from 'axios';
import waitUntil from 'async-wait-until';
export default class AsyncComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
user: {}
}
}
componentDidMount() {
this.fetchUser()
.then(res => {
if(res){
this.setState({ user: res });
}
});
}
fetchUser() {
let url = `http://localhost:3000/user?name=james`;
return axios.get(url).then(res => {
return res.data;
}).catch(err => {
console.error(err);
});
}
render() {
const { user } = this.state;
return (
<div style={{margin: '30px', fontSize: '16px'}}>
<p className="name">{user.name}</p>
<p className="age">{user.age}</p>
</div>
);
}
}
describe('test Async', () => {
it('expect component did mount will trigger re-render', async () => {
const wrapper = mount(<Async />);
await waitUntil(() => wrapper.state('user').name === 'james');
expect(wrapper.find('.name').text()).toBe('james');
expect(wrapper.find('.age').text()).toBe('30');
});
});
2.4.2 mock请求的处理
import React from 'react';
import axios from 'axios';
import waitUntil from 'async-wait-until';
const nock = require('nock')
const { shallow, mount } = Enzyme
export default class AsyncComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
user: {
name: '',
age: ''
}
}
}
componentDidMount() {
this.fetchUser()
.then(res => {
if(res){
this.setState({ user: res });
}
});
}
fetchUser() {
// 注意要保证同源,否则会出现跨域
let url = `${location.origin}/api/user`;
return axios.get(url).then(res => {
return res.data;
}).catch(err => {
// console.error(err);
});
}
render() {
const { user } = this.state;
return (
<div style={{margin: '30px', fontSize: '16px'}}>
<p className="name">{user.name}</p>
<p className="age">{user.age}</p>
</div>
);
}
}
describe('test Async', () => {
beforeAll(() => {
// mock了一个成功的get请求
nock('http://localhost:3000')
.get('/api/user')
.reply(200, {
"name": "james22",
"age": '30'
});
});
afterAll(() => {
// 用于对之前mock的接口进行清理
nock.cleanAll();
});
it('expect component did mount will trigger re-render', async () => {
const wrapper = mount(<Async />);
await waitUntil(() => wrapper.state('user').name === 'james22');
expect(wrapper.find('.name').text()).toBe('james22');
expect(wrapper.find('.age').text()).toBe('30');
});
});
describe('test Async', () => {
let resolve = false;
beforeAll(() => {
// mock了一个失败get请求
nock('http://localhost:3000')
.get('/api/user')
.reply(400, () => {
resolve = true;
});
});
afterAll(() => {
// 用于对之前mock的接口进行清理
nock.cleanAll();
});
it('expect component fetch error', async () => {
const wrapper = mount(<Async />);
await waitUntil(() => resolve);
expect(wrapper.find('.name').text()).toBe('');
expect(wrapper.find('.age').text()).toBe('');
});
});
三. React16 函数组件 + Hooks测试
需要使用到的库是 @testing-library/react-hooks
import { useState, useCallback } from 'react'
import React from 'react'
import { renderHook, act } from '@testing-library/react-hooks'
import Enzyme from '@/test/demo/Enzyme16/enzyme'
const { shallow, render, mount } = Enzyme
const useCounter = () => {
const [count, setCount] = useState(0);
const inc = useCallback(() => setCount(x => x + 1), [])
const dec = useCallback(() => setCount(x => x - 1), [])
return {
count,
inc,
dec
}
}
function Counter() {
const {count, inc, dec} = useCounter();
return (
<div>
<button className="dec" onClick={dec}>-</button>
<p>{count}</p>
<button className="inc" onClick={inc}>+</button>
</div>
);
}
test('Counter test', () => {
const wrapper = shallow(<Counter />);
const { result } = renderHook(() => useCounter());
act(() => {
result.current.inc();
wrapper.find('.inc').simulate('click');
});
expect(result.current.count).toBe(1);
expect(wrapper.find('p').text()).toBe('1');
act(() => {
result.current.dec();
wrapper.find('.dec').simulate('click');
});
expect(result.current.count).toBe(0);
expect(wrapper.find('p').text()).toBe('0');
});
四. React17 测试
React17 需要使用到的adapter是@wojtekmaj/enzyme-adapter-react-17,使用跟React16系列没有区别
import Enzyme from 'enzyme';
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
Enzyme.configure({ adapter: new Adapter() });
export default Enzyme;