YYGod0120
单元测试Categories: Project   2025-02-23

来自技术分享会,自己总结的一些东西,可能不够细节或者有所差错

单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。对于单元测试中单元的含义,一般来说,要根据实际情况去判定其具体含义,如 C 语言中单元指一个函数,Java 里单元指一个类,图形化的软件中可以指一个窗口或一个菜单等。总的来说,单元就是人为规定的最小的被测功能模块。单元测试是在软件开发过程中要进行的最低级别的测试活动,软件的独立单元将在与程序的其他部分相隔离的情况下进行测试。 而针对前端来说,我们可以简单的分为逻辑单元测试和UI单元测试

逻辑单元测试

一个逻辑单元测试最重要的部分就是断言库。他能够让我们去调用匹配器对一个简单的函数进行测试,测试内容可以是简单的字符串比较,对象的深度比较甚至是判断函数的调用次数以及错误捕获。断言库的优秀与否是一个单元测试框架是否流行的重要因素。

断言风格

目前前端单测框架中,不管是内置的还是Chai等业界流行的断言库,写法风格大概分为两类: TDD(Test-driven Development)和BDD(Behavior-driven Development ) 二者算是一个递进的关系,TDD 指的是测试驱动开发,先编写单测去检验业务代码的实现。而 BDD 在此之上更具有语义化,更类似于说明文档。 就拿Chai来说,他有明显的写法风格。

1//Chai
2assert.equal(add(1,2), 3); //TDD
3//基本等同于
4expect(add(1,2)).to.equal(3); //BDD
5add(1,2).should.equal(3)
6

而Jest内置的断言库,目前只存在一种TDD的写法,利用expect和匹配器对被测函数进行测试,最终通过or抛出错误。

1// Jest
2test('two plus two is four', () => {
3  expect(2 + 2).toBe(4);
4});
5

Chai

作为业界最流行的断言库之一,相较于其他断言库,它的优势在于:

  • 支持 TDD 和 BDD 两种风格

  • 断言 api 功能强大,使用简单

  • 错误捕获隐藏内部函数堆栈,利于使用者查看报错

    内部实现

    首先,Chai 定义了一个 Assertion 对象:

    1export function Assertion (obj, msg, ssfi, lockSsfi) {
    2util.flag(this, 'ssfi', ssfi || Assertion);
    3util.flag(this, 'lockSsfi', lockSsfi);
    4util.flag(this, 'object', obj);
    5util.flag(this, 'message', msg);
    6util.flag(this, 'eql', config.deepEqual || util.eql);
    7return util.proxify(this);
    8}
    9

    这里面有两个比较重要的点,一个是 util.flag 这个工具函数,可以理解为在一个实例对象上设置和检索私有的属性(键值对),避免污染原对象上原本的属性。

    1export function flag(obj, key, value) {
    2var flags = obj.__flags || (obj.__flags = Object.create(null));
    3if (arguments.length === 3) {
    4  flags[key] = value;
    5} else {
    6  return flags[key];
    7}
    8}
    9

    另一个是 ssfi ,全称叫做"Start Stack Function Indicator",配合上lockSsfi,Chai就可以在单测函数未通过测试抛出的错误的堆栈追踪中剪去没必要的函数内容,比如 Chai 内部的实现以及一些库函数。

    更具体的 Chai 相关部分可以看> https://lucasfcosta.com/2017/02/17/JavaScript-Errors-and-Stack-Traces.html

然后 Chai 内部针对不同的写法定义了三种断言链的起点:

1export function assert(express, errmsg) {
2  var test = new Assertion(null, null, chai.assert, true);
3  test.assert(
4      express
5    , errmsg
6    , '[ negation message unavailable ]'
7  );
8} // assert('foo' !== 'bar', 'foo is not bar'); 
9//assert.equal(3, '3', '== coerces values to strings');
10

assert 对象用于TDD写法,既可以直接调用也可以跟上 chai 内部定义的各种 api

1// expect
2function expect(val, message) {
3  return new Assertion(val, message);
4}
5// should
6function shouldGetter() {
7    if (this instanceof String
8        || this instanceof Number
9        || this instanceof Boolean
10        || typeof Symbol === 'function' && this instanceof Symbol
11        || typeof BigInt === 'function' && this instanceof BigInt) {
12      return new Assertion(this.valueOf(), null, shouldGetter);
13    } //相当于做一个类型转化
14    return new Assertion(this, null, shouldGetter);
15  }
16

foo.should.be.a('string'),foo.should.equal('bar') expect(answer).to.equal(42),expect(answer, 'topic [answer]').to.equal(42);

should和expect的写法更加的语义化,更符合 BDD 风格。自然写法也比assert来的繁琐一些。 接下来就是很重要的 API 部分的内容,也是区分 BDD 和 TDD 的一大特点。

1在此讲各个 api 之前还有一个函数需要介绍一下。
2Assertion.addProperty = function (name, fn) {
3  util.addProperty(this.prototype, name, fn);
4};
5export function addProperty(ctx, name, getter) {
6  getter = getter === undefined ? function () {} : getter;
7  Object.defineProperty(ctx, name,
8    { get: function propertyGetter() {
9        if (!isProxyEnabled() && !flag(this, 'lockSsfi')) {
10          flag(this, 'ssfi', propertyGetter);
11        }
12        var result = getter.call(this);
13        if (result !== undefined)
14          return result;
15        var newAssertion = new Assertion();
16        transferFlags(this, newAssertion);
17        return newAssertion;
18      }
19    , configurable: true
20  });
21}
22

addProperty 这个函数主要有两点作用,一个是定义目标访问时根据 lockSsfi 更新 ssfi,利于用户报错时的堆栈追踪。 另外一个就是返回新的 Assertion 对象,实现断言的链式调用。

在 BDD 风格下,大概可以把相关的 api 分为三种:语义类型API,辅助类型API以及判断类型API。 语义化API顾名思义就是没有什么效果,只是为了你的断言写起来更好理解,更语义化。

1//api相关实现
2[ 'to', 'be', 'been', 'is'
3, 'and', 'has', 'have', 'with'
4, 'that', 'which', 'at', 'of'
5, 'same', 'but', 'does', 'still', "also" ].forEach(function (chain) {
6  Assertion.addProperty(chain);
7});
8

辅助类型API一般都是配合逻辑判断来使用,例如deep,not等等。具有一定作用但是不作为逻辑判断,一般需要配合判断类型API”食用“。

1Assertion.addProperty('not', function () {
2  flag(this, 'negate', true);
3});
4Assertion.addProperty('deep', function () {
5  flag(this, 'deep', true);
6});
7

逻辑判断类型API是断言库API中内容最多也是最重要的一个部分,配合辅助类型API进行逻辑判断是否符合预期。

1function assertEqual (val, msg) {
2  if (msg) flag(this, 'message', msg);
3  var obj = flag(this, 'object');
4  if (flag(this, 'deep')) {
5    var prevLockSsfi = flag(this, 'lockSsfi');
6    flag(this, 'lockSsfi', true);
7    this.eql(val);
8    flag(this, 'lockSsfi', prevLockSsfi);
9  } else {
10    this.assert(
11        val === obj
12      , 'expected #{this} to equal #{exp}'
13      , 'expected #{this} to not equal #{exp}'
14      , val
15      , this._obj
16      , true
17    );
18  }
19}
20

简单来说BDD写法的单测就是通过语义以及逻辑串起来的测试,好处是一眼就知道这个单测的内容关于什么,也有助于非开发人员来了解被测函数的作用。坏处就是写起来稍微麻烦。 而TDD的实现在 Chai 中就比较简单,将语义化内容打包写成一个api,类似于再封装一层。

1// TDD api
2assert.isNotOk = function (val, msg) {
3  new Assertion(val, msg, assert.isNotOk, true).is.not.ok;
4};
5assert.isAtLeast = function (val, atlst, msg) {
6  new Assertion(val, msg, assert.isAtLeast, true).to.be.least(atlst);
7};
8

断言库

UI组件测试

React/Vue传统Web组件

一个前端项目,除了相关的JS逻辑,还存在UI的渲染,而UI渲染有的时候是由一块一块的组件组合渲染而成,比如某个按钮,某个输入框。针对这些组件的渲染,仅靠简单的断言库是没法实现的。 首先,渲染一般都跑在浏览器上,node环境是不支持dom等的操作的,所以我们需要模拟浏览器环境 目前市面上用于模拟浏览器环境的node包还是有不少的,比如puppeteer,JSDOM等等,而JSDOM算是一个最常见的模拟浏览器进行单元测试的工具。

jsdom不同于puppeteer,他不会启动一个headless浏览器实现模拟浏览器环境,而是通过js模拟一些api,比如document和window,以及一些简单的事件比如click,通过parse5解析传递的html字符串。 因为只是轻量级的模拟所以存在一些缺陷:没法完全模拟比如键盘鼠标事件,fetch/xhr等网络请求需要polyfill,无法计算css样式等等。。

我们可以通过Jest配合Jsdom可以让我们创建一个UI的单元测试。以下是一个简单的事例:

1/**
2 * @jest-environment jsdom
3 */
4test('use jsdom in this test file', () => {
5  const element = document.createElement('div');
6  expect(element).not.toBeNull();
7});
8

jest 默认是node环境的单元测试,你可以在config文件下指定也可以直接写在单测文件开头

其次目前绝大多数的Web项目,基本都要由框架如react和vue来编写,而这些框架编写的组件本质上还是js,依旧做不到利用dom去操作,所以我们可以借助一些工具来进一步的操作,比如,等等。 这是一个简单的jest配合testing-library的单测例子:

1// hidden-message.js
2import * as React from 'react'
3
4// NOTE: React Testing Library works well with React Hooks and classes.
5// Your tests will be the same regardless of how you write your components.
6function HiddenMessage({children}) {
7  const [showMessage, setShowMessage] = React.useState(false)
8  return (
9    <div>
10      <label htmlFor="toggle">Show Message</label>
11      <input
12        id="toggle"
13        type="checkbox"
14        onChange={e => setShowMessage(e.target.checked)}
15        checked={showMessage}
16      />
17      {showMessage ? children : null}
18    </div>
19  )
20}
21
22export default HiddenMessage
23
24import '@testing-library/jest-dom'
25// NOTE: jest-dom adds handy assertions to Jest and is recommended, but not required
26/**
27 * @jest-environment jsdom
28 */
29
30import * as React from 'react'
31import {render, fireEvent, screen} from '@testing-library/react'
32import HiddenMessage from '../hidden-message'
33
34test('shows the children when the checkbox is checked', () => {
35  const testMessage = 'Test Message'
36  render(<HiddenMessage>{testMessage}</HiddenMessage>)
37  expect(screen.queryByText(testMessage)).toBeNull()
38  fireEvent.click(screen.getByLabelText(/show/i))
39  expect(screen.getByText(testMessage)).toBeInTheDocument()
40})
41

test-library做了些什么呢,最核心的部分就是render函数,他模拟了react的挂载流程,借助jsdom实现的浏览器环境,test-library直接将你的组件挂载到document(你也可以自定义挂载对象)上,借此你就可以像操作html一样利用他提供的一些query函数来进行操作从而进行单元测试。

小程序组件

如果是小程序组件呢? 以微信小程序为例子,它提供了一个和来处理小程序组件的单测问题。 这里先看一个微信小程序官方文档的组件单测例子:

1// /test/components/index.test.js
2/**
3 * @jest-environment jsdom
4 */
5
6const simulate = require('miniprogram-simulate')
7
8test('components/index', () => {
9    const id = simulate.load('/components/index') 
10    const comp = simulate.render(id) // 渲染成自定义组件树实例
11    const parent = document.createElement('parent-wrapper') // 创建父亲节点
12    comp.attach(parent) 
13    const view = comp.querySelector('.index') // 获取子组件 view
14    expect(view.dom.innerHTML).toBe('index.properties') // 测试渲染结果
15    expect(window.getComputedStyle(view.dom).color).toBe('green') // 测试渲染结果
16})
17

从例子上看出其实本质上和React等框架的组件单测实现思路上是一致的,都是将一个组件转化为一个自定义组件实例,再通过工具库内部实现的一些方法来模拟操作。 但是因为小程序组件是基于多个文件的(wxml,wxss,js,json)而非一个函数/一个对象。所以我们需要额外的一个load操作-通过传递的路径获取wxss,wxml文件内容进行编译,然后执行j-components的注册一个组件函数,返回一个组件id。 然后通过render函数向全局注入样式以及创建一个自定义组件树实例,再挂载到父节点上就可以进行dom操作进行单元测试。

UI测试

缺陷

因为组件测试的运行环境不存在wx对象,所以一些wx的api是没法用的,需要用户自己去实现。 一些wx内置的组件也未实现功能,仅做渲染。

© 2023 - 2025
githubYYGod0120