React基于jest+enzyme测试工具的测试技巧

技术选型

  • jest: 支持断言、Mock、Snapchat、Async测试、测试覆盖率等
  • enzyme:模拟了jQuery的APi,比较直观,学习使用都比较简单

测试的原则

  • 测试代码时,只考虑测试,不考虑内部实现
  • 数据尽量模拟现实,越靠近现实越好
  • ==对重点、复杂、核心代码,重点测试==
  • 利用AOP(beforeEach、afterEach),减少测试代码数量,避免无用功能
    测试、功能开发相结合,有利于设计和代码重构
  • 测试过程中出现 Bug 的情况

店东贷采用的是BDD的测试手法,通过代码对原有业务需求的理解,对代码质量以及业务逻辑进行的测试代码的开发,在测试的过程中,将店东贷的主要业务逻辑进行了提取,从而进行的单元测试用例编写。

测试技巧

开始测试之前,我们先来了解下每个工具。

Enzyme的三种渲染方式

首先是准备了待测组件button.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import React, { PureComponent } from 'react';
import Empty from './../../client/components/Empty';
class Button extends PureComponent {
constructor(props) {
super(props);
this.state = {
name: ""
};
}
componentDidMount () {
console.log('componentDidMount');
if (!this.state.name) {
this.setState({
name: this.props.value
});
}
}
render() {
return (
<div>
<Empty text="无数据" />
<button {...this.props} />
</div>
);
}
}
export default Button;

为了区别shallow和render的区别,增加了一个empty的子组件, dom结构如下:

1
2
3
4
<div className='empty-view-wrapper'>
<img src={EmptyImg} />
<div className='text-content'>{text}</div>
</div>

浅层渲染shallow Rendering

根据官方的说法是说,通过这种渲染方式,可以访问到React的生命周期方法。而且,shallow只能渲染当前组件,只能对当前组件做断言,不涉及到子组件的渲染。

测试用例==button.test.js==, shallow渲染生成对应的快照对比:

1
2
wrapper = shallow(<Button {...props} />);
expect(toJson(wrapper)).toMatchSnapshot();

shallow snapshot:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Button: snapshot: 1`] = `
<div>
<Empty
text="无数据"
/>
<button
type="success"
value="提交"
/>
</div>
`;

完全渲染full Rendering

会渲染当前组件以及所有子组件。

mount snapshot:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Button: snapshot: 1`] = `
<Button
type="success"
value="提交"
>
<div>
<Empty
text="无数据"
>
<div
className="empty-view-wrapper"
>
<img
src="test-file-stub"
/>
<div
className="text-content"
>
无数据
</div>
</div>
</Empty>
<button
onClick={[Function]}
type="success"
value="提交"
/>
提交
</div>
</Button>
`;

静态渲染static Rendering

render snapshot:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Button: snapshot: 1`] = `
<div>
<div
class="empty-view-wrapper"
>
<img
src="test-file-stub"
/>
<div
class="text-content"
>
无数据
</div>
</div>
<button
type="success"
value="提交"
/>
</div>
`;

enzyme常用API及示例:

.find(selector) => ShallowWrapper

根据选择器查找节点,selector可以是CSS中的选择器,也可以是组件的构造函数,以及组件的display name等;

1
2
const wrapper = shallow(<Button {...props} />);
wrapper.find('button[type="success"]'); // 就能找到button这个dom节点

.props() => Object

返回根组件的所有属性;

1
expect(wrapper.find('button[type="success"]').props().value).toEqual('提交');

.prop(key) => Any

返回根组件的指定属性;

1
expect(wrapper.find('button[type="success"]').prop('value')).toEqual('提交');

.state([key]) => Any

返回根组件的状态;

1
expect(wrapper.state().name).toEqual('提交');

.setState(nextState) => ShallowWrapper

设置根组件的状态;

1
2
3
4
5
const state = {
name: '先提交',
};
wrapper.setState(state);
expect(wrapper.state().name).toEqual('先提交');

.setProps(nextProps[, callback]) => ShallowWrapper

设置根组件的props属性;

1
2
3
4
5
6
const newProps = {
type: 'success',
value: '提交'
};
wrapper.setProps(newProps);
expect(wrapper.find('button[type="success"]').props().value).toEqual('提交');

.text() => String

返回当前组件的文本内容;

1
2
const wrapper = shallow(<div><b>important</b></div>);
expect(wrapper.text()).to.equal('important');

.html() => String

返回当前组件的HTML代码形式;

1
2
const wrapper = shallow(<div><b>important</b></div>);
expect(wrapper.html()).to.equal('<div><b>important</b></div>');

.simulate(event[, …args]) => Self

用来模拟事件触发,event为事件名称,mock为一个event object;

对button组件稍微修改下:

1
2
3
4
5
6
7
constructor(props) {
super(props);
this.state = {
name: "",
count: 0 // 新增count,点击时改变count值
};
}

1
2
3
4
5
6
// 新增count变化的事件
change = () => {
this.setState({
count: 1
});
}
1
<button {...this.props} onClick={() => this.change()} />

测试用例:

1
2
3
4
5
it("simulate()的使用方法: ", () => {
expect(wrapper.state().count).toEqual(0);
wrapper.find('button').simulate('click');
expect(wrapper.state().count).toEqual(1);
});

……等Api方法

jest ==.fn()== 和 ==.spyOn()==

jest.fn(implementation) => mockFn

jest.fn()是创建Mock函数最简单的方式,如果没有定义函数内部的实现,jest.fn()会返回undefined作为返回值。

1
2
3
4
5
6
7
8
9
10
11
12
test('stub: ' , () => {
let mockFn = jest.fn();
let result = mockFn(1, 2, 3);
// 断言mockFn的执行后返回undefined
expect(result).toBeUndefined();
// 断言mockFn被调用
expect(mockFn).toBeCalled();
// 断言mockFn被调用了一次
expect(mockFn).toBeCalledTimes(1);
// 断言mockFn传入的参数为1, 2, 3
expect(mockFn).toHaveBeenCalledWith(1, 2, 3);
});

jest.fn()所创建的Mock函数还可以设置返回值,定义内部实现或返回Promise对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
test('测试jest.fn()返回固定值', () => {
let mockFn = jest.fn().mockReturnValue('default');
// 断言mockFn执行后返回值为default
expect(mockFn()).toBe('default');
})

test('测试jest.fn()内部实现', () => {
let mockFn = jest.fn((num1, num2) => {
return num1 * num2;
})
// 断言mockFn执行后返回100
expect(mockFn(10, 10)).toBe(100);
})

test('测试jest.fn()返回Promise', async () => {
let mockFn = jest.fn().mockResolvedValue('default');
let result = await mockFn();
// 断言mockFn通过await关键字执行后返回值为default
expect(result).toBe('default');
// 断言mockFn调用后返回的是Promise对象
expect(Object.prototype.toString.call(mockFn())).toBe("[object Promise]");
})

jest.spyOn(object, methodName) => mockFn

jest.spyOn()方法同样创建一个mock函数,但是该mock函数不仅能够捕获函数的调用情况,还可以正常的执行被spy的函数。实际上,jest.spyOn()jest.fn()的语法糖,它创建了一个和被spy的函数具有相同内部代码的mock函数。

声明一个方法

1
2
3
4
5
const myObj = {
doSomething() {
console.log('does something');
}
};

.fn().spyOn()的一个对比:

1
2
3
4
5
6
7
8
9
10
11
test('stub .toBeCalled()', () => {
const stub = jest.fn();
stub();
expect(stub).toBeCalled();
});
test('spyOn .toBeCalled()', () => {
const somethingSpy = jest.spyOn(myObj, 'doSomething');
myObj.doSomething();
expect(somethingSpy).toBeCalled();
somethingSpy.mockRestore(); // 由于创建 spy 时,Jest 实际上修改了 myObj 对象的 doSomething 属性,所以在断言完成后,我们还要通过 mockRestore 来恢复 myObj 对象原本的 doSomething 方法
});

.fn().spyOn()的简单理解:

  • .fn()
    • 想模拟一个函数,而实际上并不关心该函数的内部实现
    • 只是想模拟一个方法的返回值
  • .spyOn()
    • 能将对象上的现有的方法转换为spy, 重新定义原始对象的实现,并覆盖原始对象的实现,完成后,还要通过mockRestore()恢复对象原本的方法

生命周期测试

待测组件button.js, 测试case:

1
2
3
4
5
6
7
8
9
10
let props = {
type: 'success',
value: '提交'
};
let wrapper = shallow(<Button {...props} />);
const spy = jest.spyOn(Button.prototype, 'componentDidMount');

wrapper.instance().componentDidMount(); // 实例化调用下组件
expect(Button.prototype.componentDidMount.mock.calls.length).toBe(1); // expect(spy).toHaveBeenCalledTimes(1);
expect(wrapper.state().name).toEqual('提交');

异步测试

回调

例如,我们通过setTimeOut模拟一个回调异步,返回一个data对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
export const fetchData = (cb) => {
const data = {
code: 200,
msg: 'ok',
content: {
name: 'bob',
age: 20
}
};
setTimeout(function () {
return cb(data);
}, 1000)
}

默认情况下,一旦到达运行上下文底部,jest测试立即结束。这样意味着这个测试将不能按预期工作。

1
2
3
4
5
6
7
8
import { fetchData } from './fetch';
test("async test: ", () => {
const cb = (data) => {
expect(data.code).toEqual(200);
// done();
}
fetchData(cb);
});

按照上面写的测试用例,不管返回的code是不是200,都会执行成功,并不能正确按照我们的期望进行测试,问题在于一旦fetchData执行结束,此测试就在没有调用回调函数前结束。

还有另一种形式的 test,解决此问题。 使用单个参数调用 done,而不是将测试放在一个空参数的函数。 Jest会等done回调函数执行结束后,结束测试。

1
2
3
4
5
6
7
test("async test: ", (done) => {
const cb = (data) => {
expect(data.code).toEqual(200);
done();
}
fetchData(cb);
});

Promise

如果您的代码使用 Promises,还有一个更简单的方法来处理异步测试。 只需要从您的测试返回一个承诺, Jest 会等待这一承诺来解决。 如果承诺被拒绝,则测试将自动失败。

模拟一个Promise待测请求:

1
2
3
4
5
6
7
8
9
10
11
export const fetchData = () => {
const data = {
code: 200,
msg: 'ok',
content: {
name: 'bob',
age: 20
}
};
return Promise.resolve(data);
}

通过expect.assertions,表示必须执行完一次expect的断言才算结束:

1
2
3
4
5
6
test("async test: ", () => {
expect.assertions(1);
fetchData().then(data => {
expect(data.code).toEqual(200);
});
});

或者通过Async/await进行测试:

1
2
3
4
test("async test: ", async () => {
const res = await fetchData();
expect(res.code).toEqual(200);
});


【参考资料】

Jest文档

Enzyme github