Skip to content

About Unit Test

pointhalo edited this page Nov 4, 2021 · 2 revisions

English Version (WIP)

中文版本

单元测试方案

  • 测试框架:Jest(提供一个可运行的环境、测试结构、结果报告、代码覆盖、断言、mocking 、snapshot)
  • 辅助测试库 enzyme:主要用于react components render
  • 辅助测试库 jsdom:在node环境提供dom渲染模拟,配合enzyme的Full Dom Reandering场景使用
  • 辅助测试库 sinon:提供spy、stub、mock来做event testing、function testing,提供测试回调函数执行次数、入参判断等能力

测试内容

  • 模块应被渲染的DOM树是否正确
  • 模块的属性传递是否正确,(属性是方法则是否被正确调用,属性是布尔值或对象则是否被正确传递且达到预期的目的)
  • 模块内的各个行为响应是否正确

覆盖范围

  • @douyinfe/semi-ui 包含的各组件的代码
    • 每个组件API都应该有对应的test case(除非某些API在jest环境下不好模拟,例如onScroll,允许跳过不写)

单元测试编写指南

每个组件的单测用例都位于各自的__test__文件夹下,以componentName.test.js命名

  • 根据组件Feature进行测试用例的编写,注意单一变量原则,每个用例只关注当前props的功能是否正常

具体步骤.

1、添加一个测试.

import Switch from '../index';
// 此处无需再单独引入 shallow、mount、render、enzyme、sinon等
// 已经在<rootDir>/test/setup.js中统一赋值给了global,在组件级别的单测用例js中可以直接共享
describe('Switch', () => {
    it('xxx should xxx', () => {
        //...
    });
    it('unit test 2', () => {});
    // ...
})

2、运行所有测试,看看新加的这个是不是失败了;如果能成功则重复步骤1.

// 只跑单元测试
npm run test:unit
// 跑所有组件的单测用例,并且输出覆盖率报告
npm run test:coverage

3、根据失败报错,有针对性的编写或改写代码。 4、再次运行测试;如果能成功则跳到步骤5,否则重复步骤3
5、重复步骤1

常用API示例参考

测试className与style

it('input with custom className & style', () => {
       const wrapper = shallow(<Input className='test' style={{color: 'red'}}/>);
       expect(wrapper.exists('.test')).toEqual(true);
       expect(wrapper.find('div.test')).toHaveStyle('color', 'red');
 });

测试props是否生效

// 一般检查是否拥有对应的dom节点,是否有对应的className,或者state对应的值是否正确
   it('input different size', () => {
        const largeInput = mount(<Input size='large'/>);
        const defaultInput = mount(<Input />);
        const smallInput = mount(<Input size='small' />);
        expect(largeInput.find('.semi-input-large')).toHaveLength(1);
        expect(smallInput.find('.semi-input-small')).toHaveLength(1);
    });

    it('input with placeholder', () => {
        let placeholderText = 'semi placeholder';
        const input = mount(<Input placeholder={placeholderText} />);
        let inputDom = input.find('input');
        expect(inputDom.props().placeholder).toEqual(placeholderText);
    })

读取组件的state或props

it('input', () => {
        const input = mount(<Input  />);
        expect(input.state().disabled).toEqual(false); // 直接读取state
        expect(input.props().disabled).toEqual(false); // 读取props
 })

修改state与props,测试组件UI状态是否进行了正确的变更

// 可以通过setState和setProps接口模拟组件的外部状态变化,测试组件状态动态改变时,UI是否做出了正确的响应 
it('change props & state', () => {
    const input = mount(<Input />);
    input.setProps({ disabled: true }) ;
    input.setState({ value: 1 })
    input.update(); // !!!注意,需执行update()
    // expect ....
}

测试reactNode的slot是否如预期被渲染

     it('Collapse with custom expandIcon / collapseIcon', () => {
        let plusIcon = <Icon type="plus" />;
        let minIcon = <Icon type="minus" />;
        let props = {
            expandIcon: plusIcon,
            collapseIcon: minIcon
        };
        let collapse = mount(<Collapse {...props} />);
        expect(collapse.props().expandIcon).toEqual(plusIcon);
        expect(collapse.props().collapseIcon).toEqual(minIcon);
        expect(collapse.contains(plusIcon)).toEqual(true);
        expect(collapse.contains(minIcon)).toEqual(false);
   });

测试组件的事件回调是否被调用

   it('input should call onChange when value change', () => {
        let inputValue = 'semi';
        let event = { target: { value: inputValue } };
        let onChange = () => {};
        // 使用sinon.spy封装回调函数,spy后可收集函数的调用信息
        let spyOnChange = sinon.spy(onChange); 
        const input = mount(<Input onChange={spyOnChange} />);
        // 找到原生input元素,触发模拟事件,模拟input的value值变化
        input.find('.semi-input').simulate('change', event);
        expect(spyOnChange.calledOnce).toBe(true); // onChange回调被执行一次
    })

测试组件的事件回调数据是否正确

    it('test callback value', () => {
        //  ... 主体代码同上
        // 单个入参可以用calledWithMatch
        expect(spyOnChange.calledWithMatch('semi')).toBe(true);
        // 多个入参可以用 
    })
    // 其他callXXX 型API的用法可以参考sinon的官方文档 https://sinonjs.org/releases/v7.5.0/spies/

测试回调触发的次数 / 每次回调触发的入参是否正确

    it('test callback count / specific callback arguments', () => {
        //  ... 主体代码同上
        // 获取函数的总调用次数
        expect(spyOnChange.callCount).toBe(2);
        // 获取该函数的第1次调用
        let firstCall = spyOnChange.getCall(0);
        // 第N次调用的入参
        let arguments = firstCall.argus; // Array like object
    })

测试Dom节点上的attribute是否符合预期

it('collapse defaultActiveKey', () => {
  const collapse = mount(<Collaplse defaultActiveKey='1'> 
        {...}// 中间省略  
  </Collapse>);
  let domNode = collapse.find([tabIndex="1"]).getDOMNode();
  // 使用getDOMNode获取ReactWrapper的真实DOM
  // 注意某些属性的大小写,例如在dom中渲染是tabindex,但实际上获取属性时需要用tabIndex
  expect(domNode.getAttribute("aria-expand")).toEqual(true);
})
it('show different direction popup layer', ()=> {
   let props = {
            position: 'top',
            data: stringData,
            defaultOpen: true,
            ...commonProps
   };
        let ac = mount(<AutoComplete {...props} />, attachTo: {...} );
        expect(ac.find('.semi-popover-wrapper').instance().getAttribute('x-placement')).toEqual('top');
        ac.setProps({ props: 'right' });
        ac.update();
        expect(ac.find('.semi-popover-wrapper').instance().getAttribute('x-placement')).toEqual('right');
})

获取列表中的第N个DOM实例

it('optionList should show when defaultOpen is true & data not empty', () => {
     // ...
    // 使用find定位,children获取子节点列表,at获取第N个,getDOMNode获取DOM节点实例
    let ac = mount(<AutoComplete {...props} />, { attachTo: document.getElementById('container') });
    let list = ac.find('.semi-autocomplete-option-list').children();
    expect(list.length).toEqual(4);
    expect(list.at(0).getDOMNode().textContent).toEqual('semi');
    expect(list.at(1).getDOMNode().textContent).toEqual('ies');
 });

易掉坑的点

  • enzyme.smiluate 并非真正的模拟事件 https://github.com/airbnb/enzyme/issues/1606.
    事件模拟不会像在真实环境中通常所期望的那样传播(即没有冒泡等机制)。因此,必须在具有事件处理程序集的实际节点上调用.simulate().
    .simulate()实际上会根据你给它的事件来定位组件的prop。例如,.simulate('click')实际上会获得onClick prop并调用它.

  • 断言库的差异. enzyme的官方文档示例,使用的是mocha + chai。semi直接用的jest + jest自带的expect风格的断言库, 所以语法上会有差异。 如 to.have.lengthOf(1) => toHaveLength(1)

  • 最外层使用了Context.Consumer的组件,不适合用shallow. Semi有些组件,需要做i18n适配,组件结构如下,如果直接用shallow渲染,只能渲染出localeConsumer这层。这种情况需要直接用mount

<LocaleConsumer>
  {
    (locale, localeCode) => (
         <div>{...}</div>    
    )}
</LocaleConsumer>
  • mount只将组件渲染到div元素,而不将其附加到DOM树
    • 即mount默认并不会将组件挂载到document上
    • 涉及弹出层的组件,例如Modal、Tooltip、Select等,需要在mount(component, { attachTo: DOM }),详情参考AutoComplete

单元测试指标解读

jest 内置的 istanbul 输出的覆盖率结果, 表格中的第2列至第5列,分别对应四个衡量维度:

  • 语句覆盖率(statement coverage):是否每个语句都执行了
  • 分支覆盖率(branch coverage):是否每个if代码块都执行了
  • 函数覆盖率(function coverage):是否每个函数都调用了
  • 行覆盖率(line coverage):是否每一行都执行了

如何提升单测覆盖率

当我们执行jest 命令时,加上 --coverage后,会输出覆盖率报告
// 跑某个组件的单元测试. type=unit jest packages/semi-ui/xxx --silent --coverage. 跑完上述命令后,jest会输出对应的html(根目录/test/converage/xxx/index.html)
点击对应组件的html,没有被执行过的行会标红高亮展示(键盘按N可以直接跳至对应行)。因此注意在你的单侧case中覆盖到这些场景后,相应的单测行覆盖度就会得到提升.