动手实践:组件库单测编写

大厂技术  坚持周更  精选好文

本文为来自 字节跳动-国际化电商-S 项目团队 成员的文章,已授权 ELab 发布。

单测的意义

组件作为一种被频繁复用的代码,出现线上 bug 的时候影响的是范围更广的业务,保障组件每次迭代的可靠性尤为重要。

对使用者来说,组件库的丰富的单测代表着其可靠性。

对组件开发者来说,单测的沉淀也让每次对旧代码的改动更有信心。

Jest 基础

Jest 是 React 推荐的测试框架,也是当前众多前端组件库的单测首选。

先看一个简单的 🌰

function sum (num1, num2) {
    return num1 + num2;
}

上面是一个简单的加法函数,它的功能是是输入两个数 a 和 b,期望返回它们相加的结果,它的单测写出来是这样的。

import sum from './sum.ts';

describe('util 方法测试', () => {  
  
  test("sum function test", () => {
  
      // 对函数进行调用,期望结果是 3
      expect(sum(1, 2)).toBe(3);
  })
});

上面是对 sum 方法的单元测试的例子,在该测试中,对函数进行调用,传入了 1 和 2,并对返回结果的正确性进行验证。里面出现了 describe、test、expect、toBe 方法,这些是 jest 相关的基础的东西,下面逐个介绍下。

Jest 相关概念

describe:描述一个模块

describe('buttonPromise 组件相关测试', () => {  
  // 此处编写 buttonPromise 测试用例
});

test :编写一个具体的测试用例

describe('buttonPromise 组件相关测试', () => {  
  
  test('描述测试功能:buttonpromise loading style', () => {
      // 执行具体测试
  })
});

expect:断言,期望某个结果符合什么样的预期

toBe:匹配器, 对某个结果进行判断是否与预期一致

test('two plus two is four', () => {
  expect(sum(2, 2)).toEqual(4); // 断言结果为 4,通过测试
  expect(sum(2, 2)).not.toEqual(5); // 断言结果不等于 5,通过测试
});

Jest 提供了大量的常用匹配器,针对数字、字符串、函数的调用情况、调用时的参数等

  • toBeGreaterThan(x: number); 比 x 更大

  • toBeLessThan(x: number); 比 x 更小

  • toEqual(x: number): 是否相等

  • toMatch(x: RegExp) 字符串匹配

  • toHaveBeenCalledTimes(x: number) 函数被调用 x 次

  • 更多匹配器

Jest 提供的能力

  • 测试断言、匹配器,对测试用例结果进行判断

  • 支持异步函数测试

    • // 构造一个异步函数,2s 后返回 1
      const fetchData = () => new Promise((resolve) => {
        setTimeout(() => {
          resolve(1)
        }, 2000)
      })
      
      test('支持返回一个 promise,在 resove 中进行测试', () => {
          return fetchData().then(data => {
              expect(data).toBe(1);
          });
      });
      
      test('直接使用 .resolves / .rejects 对结果进行断言', () => {
          return expect(fetchData()).resolves.toBe(1);
      });
      
      test('支持在测试用例中使用 async await', async () => {
        const data = await fetchData();
        expect(data).toBe(1);
      });
  • Mock 能力

Jest 支持对一个函数、模块的 mock,有些测试场景用到了外部依赖,而这些外部依赖并不需要关心它们的执行过程,只需要它们返回一个执行后的结果,这种场景下可以使用 jest 的 mock 功能

例如下面这段代码对 axios 模块进行 mock

import axios from 'axios'

// 对整个 axios 模块进行 mock
jest.mock('axios', () => {
  return {
    get: jest.fn(() => Promise.resolve(1))
  }
})

test('试试 mock axios', () => {
  return axios.get('').then(data => expect(data).toBe(1))
})

甚至可以对引用文件内部的其他依赖做 mock

test.tsx

import axios from 'axios';
import b from './b';

export default async () => {
  const data = await axios.get('./api/v1/user');
  return data;
}

fn.test.tsx

import testFn from './test'

 // test 文件内部使用到了 axios,在这里对其做 mock
jest.mock('axios', () => {
  return {
    get: jest.fn(() => Promise.resolve(1))
  }
})

jest.mock('./b', () => {
    return {
    }
});

test('试试对引用文件内部的其他依赖做 mock', () => {
  return testFn().then(data => expect(data).toBe(1))
})

jest 更多 mock 相关的知识

测试 UI 组件额外需要的工具库

通过上面可以了解到 Jest 提供了单元测试常用的一些基础能力,对测试结果的断言、匹配,对外部依赖的 mock 等,但是对于组件库来说,这对于写 UI 组件单测还不够便捷,例如,

  • 如何去渲染一个 React 组件?将组件渲染出来是测试它的第一步

  • 渲染后希望可以检查它在某些场景下的样式,又如何去操作节点

  • UI 组件的测试伴随着用户对它的交互,这些又如何操作?

...

针对上面这些问题,业界已有了相应的工具库配合 jest 一起使用

@testing-library/react

@testing-library/react 是一个用于测试 react 组件相关的库,提供了 react 组件渲染能力、 dom 操作能力,通过它在编写 react 组件单元测试的时候可以更加关注功能测试本身,无需编写大量代码去做组件的初始化等。

@testing-library/react 提供了 render api,当开始写一个组件的单测时,可以使用它将组件渲染出来,同时其返回了一个 container 对象,是组件被渲染的容器所在,通过 container,可以进行 dom 操作,如 获取对应的节点等。

import { render } from '@testing-library/react';
import ButtonPromise from '../src';

describe('Button Promise 组件测试', () => {

  test('测试 loading 功能', async () => {
  
     // 渲染组件
    const { container } = render(
      <ButtonPromise id="test-copy-icon" onClick={btnClick}>
        按钮
      </ButtonPromise>,
    );
    
    // render 函数渲染完会返回对应的组件被渲染的容器节点,通过它可查找对应的 dom
    const btn = container.querySelector('#test-copy-icon');
    
    ... 后续操作
 
  });

上面的例子展示了如何渲染一个按钮组件,组件渲染之后,需要用户去点击,这涉及到用户行为的模拟,针对这一类场景,对应的解决方案是 @testing-library/user-event

@testing-library/user-event

@testing-library/user-event 是一个模拟用户行为的库,包括对用户鼠标行为(单击、双击、鼠标 hover 等)、键盘行为的模拟。

继续上面的例子,在按钮成功渲染出来后,对其进行点击

import { render } from '@testing-library/react';
import ButtonPromise from '../src';

describe('Button Promise 组件测试', () => {

  test('测试 loading 功能', async () => {
  
      // userEvent 初始化
    const user = userEvent.setup();
    const { container } = render(
      <ButtonPromise id="test-copy-icon" onClick={btnClick}>
        按钮
      </ButtonPromise>,
    );
   
    // render 函数渲染完会返回对应的组件被渲染的容器节点,通过它可查找对应的 dom
    const btn = container.querySelector('#test-copy-icon');
    
   // 点击按钮
    user.click(btn);
  });

用户的行为通常伴随着数据的变化、界面的变化,这些变化是检查组件经过用户交互后是否正确的判断标准。数据的变化通过 jest 提供的各类匹配器(toEqual、toMatch 等)可以进行判断,界面的变化则需要通过对 dom 状态的检验。

@testing-library/jest-dom

继续上面的例子,上面的部分已经做到了对 buttonPromise 组件的渲染,点击,而该组件的功能,是期望点击后用户可以看到 loading 效果.

import { render } from '@testing-library/react';
import ButtonPromise from '../src';

describe('Button Promise 组件测试', () => {

  test('测试 loading 功能', async () => {
  
    // 根据组件功能,传入 onClick 的函数返回一个 promise
    // 在 resolve 之前,按钮会有 loading 效果
    const btnClick = () => new Promise((resolve) => {
        setTimeout(() => {
           resolve() 
        }, 2000);
    })
  
      // userEvent 初始化
    const user = userEvent.setup();
    const { container } = render(
      <ButtonPromise id="test-copy-icon" onClick={btnClick}>
        按钮
      </ButtonPromise>,
    );
   
    // render 函数渲染完会返回对应的组件被渲染的容器节点,通过它可查找对应的 dom
    const btn = container.querySelector('#test-copy-icon');
    
   // 点击按钮
    user.click(btn);
    
    await waitFor(() => {
      // 期望按钮上有 loading 样式
      expect(btn).toHaveClass('arco-btn-loading')
    });
  });

写单元测试的思路

通过上面 jest 与 test-library 相关的基础介绍,到最后完成了 button-promise 组件单元测试的编写。

单测的要点

  • 明确测试目的(测什么功能,什么结果是正确的)

  • 每个 test 只包含一个测试目的

  • 多站在用户的视角进行考虑(把自己当作在使用组件的用户,做出某些行为后应该看到什么)

  • 由于 arcoS 是对 arco 的封装,对于一些组件的基础功能可以不进行测试

    • 例如上面的 button-promise 已经进入 loading 状态,在进入 loading 后,按钮的多次点击是否还会触发函数调用,这属于 button 组件基础功能,应该由 arco 去保障。

思路

以上面组件库中 button-promise 组件为例

  1. 梳理待测试的功能

功能 a:onClick 绑定一个返回了 promise 的函数,组件将进入 loading 状态,直到 promise 进入 resolve 状态)

  1. 编写测试用例

  2. 渲染待测试组件(像使用组件的用户一样,正常地绑定一个 返回了 promise 的函数)

  3. 根据组件功能模拟用户行为(点击按钮)

  4. 断言当前组件表现符合预期(判断组件上是否存在 loading 样式)

  5. 断言 promise 进入 resolve 状态后,loading 样式是否消失(判断组件上 loading 样式是否丢失)

完整流程

通过脚手架创建完组件后,会产生对应的文件夹 __tests__,以及对应的单元测试文件 xxx.test.tsx,文件内容如下

import { render } from '@testing-library/react';
import xxx from '../src';

describe('组件名', () => {
  
  test('测试功能1', () => {
      // 在这里写单元测试
  });
  
   test('测试功能2', () => {
      // 在这里写单元测试
  });

编写完测试用例后,执行 yarn test (命令已在创建组件时被初始化),可以看到测试结果

成功的测试结果

15c0e60aec777187b7e55c0569870964.png

失败的结果,可以看到测试结果期望有 arco-btn-loading1 的类名,但是实际 received 到的类并没有

cf5192f3dcdd61daa3e892a0dc8eb190.png

🌰 Button-group

  • 确定测试功能

    • 支持传入数组渲染下拉菜单

    • 数组内支持定义每个 item 的点击事件及文字的自定义

import ButtonGroup from './src';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';

describe('Button-Group Test', () => {

   // 不同功能验证编写不同用例
  test('droplist show correct', async () => {
    const user = userEvent.setup();

     // 渲染按钮
    const { container, getByText } = render(
      <ButtonGroup
        title={'更多'}
        dropList={[{
          title: '批量导出',
        }, {
          render: () => <span>操作记录</span>,
        }]}
        />
    );
    
     // 模拟用户行为
    user.click(getByText('更多'))
    await waitFor(() => {
    
        // 期望传入的两个按钮在点击更多后正常渲染
      expect(getByText('批量导出')).toBeInTheDocument()
      expect(getByText('操作记录')).toBeInTheDocument()
    });
  })

   // 不同功能验证编写不同用例
  test('droplist click correct', async () => {
    const user = userEvent.setup({
      pointerEventsCheck: 0
    });

    const exportFn = jest.fn(() => {});

    const { getByText } = render(
      <ButtonGroup
        title={'更多'}
        dropList={[{
          title: '批量导出',
          onClick: () => exportFn()
        }]}
        />
    );
    user.click(getByText('更多'))
    await waitFor(() => {
      const exportBtn = getByText('批量导出');
      user.click(exportBtn);
      
       // 期望 exportFn 函数在点击批量导出后能够被调用一次
      expect(exportFn).toBeCalledTimes(1)
    });
  })
})

更多学习资料

jest 官方文档

React Test Library 工具库

❤️ 谢谢支持

以上便是本次分享的全部内容,希望对你有所帮助^_^

喜欢的话别忘了 分享、点赞、收藏 三连哦~。

欢迎关注公众号 ELab团队 收货大厂一手好文章~

- END -

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值