当前位置: 首页 > 工具软件 > Enzyme > 使用案例 >

Jest+Enzyme的单元测试技巧总结

左丘峰
2023-12-01

技术选型

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

测试的原则

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

当前被测项目采用的是BDD模式,通过测试case根据原有业务需求的理解,对代码的质量以及主业务逻辑进行的测试case的编写。

测试技巧

Enzyme的三种渲染方式

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

import React, { PureComponent } from 'react';
import Empty from './../../client/components/Empty';
class Button extends PureComponent {
    constructor(props) {
        super(props);
        this.state = {
            name: ""
        };
    }
    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结构如下:

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

浅层渲染shallow Rendering(shallow)

根据官方的说法是说,通过这种渲染方式,可以访问到React的生命周期方法。而且,shallow只能渲染当前组件,对当前组件做断言,不涉及到子组件的渲染。它的性能上最快的,大部分情况下,如果不深入组件内部测试,那么可以使用shallow渲染。

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

import { shallow } from 'enzyme';
import toJson from 'enzyme-to-json';

test("shallow snapshot:", () => {
	const wrapper = shallow(<Button {...props} />);
	expect(toJson(wrapper)).toMatchSnapshot();
});

shallow snapshot:

// Jest Snapshot v1, https://goo.gl/fbAQLP

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

完全渲染full Rendering(mount)

它会进行完整渲染,会渲染当前组件以及所有子组件,渲染的结果和浏览器渲染结果是一样的。
测试用例button.test.js, shallow渲染生成对应的快照对比:

const wrapper = mount(<Button {...props} />);

mount snapshot:

// 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)

render也会进行完整渲染,但不依赖DOM API,而是渲染成HTML结构,相当于只调用了组件的render方法,得到jsx并转码为html,所以组件的生命周期方法内的逻辑都测试不到,所以render常常只用来测试一些数据(结构)一致性对比的场景。

const wrapper = render(<Button {...props} />);

render snapshot:

// 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及示例:

结合button.js组件进行展示, button.test.js

let props, wrapper;

beforeEach(() => {
    // ...
    props = {
        type: 'success',
        value: '提交'
    };
    wrapper = shallow(<Button {...props} />);
});

.find(selector) => Wrapper

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

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

.props() => Object

返回根组件的所有属性;

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

.prop(key) => Any

返回根组件的指定属性;

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

.state([key]) => Any

返回根组件的状态;

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

.setState(nextState) => Wrapper

设置根组件的状态;

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

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

设置根组件的props属性;

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

.text() => String

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

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

.html() => String

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

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组件稍微修改下:

constructor(props) {
    super(props);
    this.state = {
        name: "",
        count: 0 // 新增count,点击时改变count值
    };
}
// 新增count变化的事件
change = () => {
    this.setState({
        count: 1
    });
}
<button {...this.props} onClick={() => this.change()} />

测试用例:

it("simulate(): ", () => {
    expect(wrapper.state().count).toEqual(0);
    wrapper.find('button').simulate('click');
    expect(wrapper.state().count).toEqual(1);
});

.instance() => ReactComponent

测试组件对应的 React 组件实例。

it("instance():", () => {
	const inst = wrapper.instance();
	expect(inst).to.be.instanceOf(Button);
});

…等Api方法

jest

常用测试单元

expect({a:1}).toBe({a:1}); //判断两个对象是否相等
expect(1).not.toBe(2); //判断不等
expect(n).toBeNull(); //判断是否为null
expect(n).toBeUndefined(); //判断是否为undefined
expect(n).toBeDefined(); //判断结果与toBeUndefined相反
expect(n).toBeTruthy(); //判断结果为true
expect(n).toBeFalsy(); //判断结果为false
expect(value).toBeGreaterThan(3); //大于3
expect(value).toBeGreaterThanOrEqual(3.5); //大于等于3.5
expect(value).toBeLessThan(5); //小于5
expect(value).toBeLessThanOrEqual(4.5); //小于等于4.5
expect(value).toBeCloseTo(0.3); // 浮点数判断相等
expect('Christoph').toMatch(/stop/); //正则表达式判断
expect(['one','two']).toContain('one'); // 含有某个元素

.fn().spyOn()

jest.fn(implementation) => mockFn

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

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对象。

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函数。

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

const myObj = {
  doSomething() {
    console.log('does something');
  }
};
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:

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对象:

export const fetchData = (cb) => {
    const data = {
        code: 200,
        msg: 'ok',
        content: {
            name: 'bob',
            age: 20
        }
    };
    setTimeout(function () {
        return cb(data);
    }, 1000)
}

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

import { fetchData } from './fetch';
test("async test: ", () => {
    const cb = (data) => {
        expect(data.code).toEqual(200);
    }
    fetchData(cb);
});

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

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

test("async test: ", (done) => {
    const cb = (data) => {
        expect(data.code).toEqual(200);
        done();
    }
    fetchData(cb);
});

Promise

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

模拟一个Promise待测请求:

export const fetchData = () => {
    const data = {
        code: 200,
        msg: 'ok',
        content: {
            name: 'bob',
            age: 20
        }
    };
    return Promise.resolve(data);
}

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

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

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

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

【参考资料】

Jest文档

Enzyme github

 类似资料: