头图

前言

如果你写了一个js插件又或者是写了一个组件库,是否还在为如何编写单元测试而苦恼,别担心,这篇文章带你轻松入门单元测试。

ps: 学完本文,相信你也会向你写的组件库或者插件添加单元测试了。

示例

以我用typescript实现的轻量高度可配置的消息提示框插件为示例,来详细讲述添加单元测试。市面上有很多测试框架,这里我还是选择比较老牌但也很流行的测试框架jest为示例。

核心

步骤1: 添加相应的依赖

首先,我们往依赖中添加一些测试框架必须要用到的依赖。

pnpm add @types/jest identity-obj-proxy jest jest-environment-jsdom

下面我们一一介绍这几个依赖的作用:

  1. jest: 核心的测试框架,提供了丰富的api,方便我们写单元测试。
  2. @types/jest: jest测试框架的ts类型包。
  3. identity-obj-proxy: 由于我们编写的插件不需要测试样式,因此添加这个库的作用就是忽略测试样式文件,例如这里我们写的是scss,所以需要忽略scss样式处理器。
  4. jest-environment-jsdom: jest环境依赖,jest默认是在node环境中执行,但是由于我们的消息提示框是运行在dom环境中的,需要用到大量的dom api,因此需要添加这个依赖,表示在dom环境(也可以理解为就是一个模拟浏览器的环境)中执行测试用例。

步骤2: 添加jest配置

接下来我们需要在项目根目录下创建一个jest.config.ts文件,并写上如下配置:

module.exports = {
  roots: ['<rootDir>/src'],
  transform: {
    '^.+\\.ts?$': 'ts-jest',
  },
  testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.ts?$',
  transformIgnorePatterns: ['<rootDir>/node_modules/'],
  moduleFileExtensions: ['ts','js'],
  testEnvironment: 'jest-environment-jsdom',
  preset: 'ts-jest',
  moduleNameMapper: {
    '\\.(css|less|scss)$': 'identity-obj-proxy',
  },
}

以下是对配置文件中各个部分的解释:

  1. roots: 指定 Jest 应该搜索测试文件的根目录。在这个例子中,测试文件位于 <rootDir>/src 目录下。
  2. transform: 指定如何转换测试文件。在这个例子中,所有以 .ts 结尾的文件都会被 ts-jest 转换器处理。
  3. testRegex: 指定 Jest 应该运行哪些测试文件。这个正则表达式匹配 /__tests__/ 目录下的所有文件,以及任何以 .test.ts 或 .spec.ts 结尾的文件。
  4. transformIgnorePatterns: 指定哪些文件应该被忽略,不进行转换。在这个例子中,所有位于 <rootDir>/node_modules/ 目录下的文件都会被忽略。
  5. moduleFileExtensions: 指定 Jest 应该识别哪些文件扩展名。在这个例子中,Jest 会识别 .ts 和 .js 文件。
  6. testEnvironment: 指定 Jest 应该使用哪个测试环境。在这个例子中,使用了 jest-environment-jsdom,这是一个模拟浏览器环境的测试环境。
  7. preset: 指定 Jest 应该使用哪个预设配置。在这个例子中,使用了 ts-jest 预设,这是一个专门为 TypeScript 项目设计的预设。
  8. moduleNameMapper: 指定如何映射模块名称。在这个例子中,所有以 .css.less, 或 .scss 结尾的文件都会被映射到 identity-obj-proxy,这是一个用于模拟 CSS 模块的库。

然后我们在package.json加上一行命令,如下所示:

  "scripts": {
    "test": "jest",
  },

以上命令,jest会执行src目录下所有\_\_test\_\_目录下的所有单元测试用例,如果需要指定运行单独的测试文件,比如我们只需要执行core.test.ts,我们只需要在jest命令后补充具体的测试文件路径即可。如下所示:

 "scripts": {
  "test:core": "jest src/core/__tests__/core.test.ts"
 }

或者我们也可以单独创建一个配置文件,运行指定的配置文件,命令如下:

 "scripts": {
  "test:core": "jest --config jest.config.core.ts"
 }

然后jest.config.core.ts文件代码和jest.config.ts代码类似,我们只需要改一下roots配置即可。

步骤3: 编写测试用例

接下来,我们来观察我们的插件代码结构,如下图所示:

截屏2024-11-01 下午5.26.32.png

通过观察我们的插件核心代码结构,我们可以为我们的每个ts文件添加对应的测试文件,正所谓一个核心功能对应一个测试。

不过对于一些常量的定义,还有ts类型,以及配置文件,我们是不需要测试的,因此const目录我们不需要添加测试,还有icon以及styles目录也不需要添加单元测试,我们只需要给core目录下的core.ts和method.ts还有utils目录下的utils.ts以及index.ts编写单元测试即可。

1. 工具函数添加单元测试

首先我们可以给工具函数添加单元测试,我们可以在utils下创建一个\_\_tests\_\_目录,并创建utils.test.ts文件,如下图所示:

截屏2024-11-01 下午5.33.24.png

接下来我们就可以开始在这个文件中编写测试代码了。在这个文件中,我们只用到了jest的describe,test,expect,toBe以及toEqual方法。我们分别介绍一下这几个方法的含义。

describe
  • 功能: 用于将一组相关的测试用例分组在一起,通常用来描述一个模块或功能的多个测试。
  • 用法: 第一个参数是一个描述字符串,第二个参数是一个函数,其中包含一个或多个 test 或 it 语句。
test 和 it
  • 功能test 和 it 用于定义单个测试用例。它们的功能是相同的,可以互换使用。it 通常用于描述行为,而 test 更加通用。
  • 用法: 第一个参数是描述字符串,第二个参数是一个函数,包含实际的测试代码。
expect
  • 功能expect 是一个断言函数,用于创建一个期望值(assertion)。你通常会将需要测试的值传递给 expect
  • 用法expect 后面会跟随一个匹配器(matcher),如 toBe 或 toEqual,来判断实际值和期望值是否符合。
toBe
  • 功能toBe 是一个严格相等匹配器,使用 === 进行比较。它用于判断两个值是否相同,包括类型。
  • 用法: 适用于原始值(如数字、字符串、布尔值等)和对象的引用比较。
toEqual
  • 功能toEqual 是一个深度相等匹配器,用于比较对象和数组的内容。它会递归地检查对象的所有属性和值。
  • 用法: 适用于比较对象和数组的内容。

由于我们的工具函数基本上就是一个函数一个功能,因此,我们的单元测试也是一个函数一个单元测试,不过我们可以用describe方法创建一组单元测试,然后再用test创建一个单元测试。因此我们的核心结构如下:

describe('utils', () => {
  test('xxx', () => {
    // xxxx
  });
  // ...
}); 

我们以isObject工具函数为例,首先从utils.ts中导入该工具函数:

import { isObject } from "../util";

我们需要知道这个工具函数的作用就是判断一个值是否是对象,因此我们可以利用expect创建一些期待值,然后利用toBe方法判断期待值是否相同即可。如下所示:

 test('isObject', () => {
    expect(isObject({ a: 1 })).toBe(true);
    expect(isObject([])).toBe(true);
    expect(isObject(null)).toBe(false);
 });

以上我们只是创建了3个测试用例,但实际上我们还可以添加更多的测试用例,例如isObject(1),不过这三个测试用例就可以代表我们的工具函数的实现是没有问题的。

其它类似的工具函数(如isNumber,isString等)基本上就是依葫芦画瓢,接下来我们来看这个工具函数isDom。它的源码实现如下:

export const isDom = (el: unknown): el is HTMLElement | HTMLCollection | NodeList => {
  if (isObject<HTMLElement>(HTMLElement)) {
    return el instanceof HTMLElement;
  } else {
    const isHTMLElement = isObject<HTMLElement>(el) && el.nodeType === 1 && isString(el.nodeName);
    return isHTMLElement || el instanceof HTMLCollection || el instanceof NodeList;
  }
}

这里,由于jest环境并不能识别isObject函数,因此,我们需要调用jest.mock方法来模拟isObject的实现。不过在这之前,有必要解释一下jest.mock。

功能
  • 模块模拟jest.mock 可以用来模拟整个模块或特定的导出。
  • 依赖控制: 通过模拟,可以控制被测试模块的依赖项行为,避免测试过程中依赖外部系统或复杂逻辑。
基本用法
  1. 默认模拟: 当你调用 jest.mock('moduleName'),Jest 会自动创建一个该模块的模拟版本。

    // math.js
    export const add = (a, b) => a + b;
    // math.test.js
    import { add } from './math';
    
    jest.mock('./math'); // 自动模拟 math 模块
    
    test('add function', () => {
      add.mockReturnValue(3); // 自定义返回值
      expect(add(1, 2)).toBe(3);
    });
  2. 手动模拟: 你可以提供一个自定义实现,覆盖模块的默认行为。

    jest.mock('./math', () => ({
      add: jest.fn(() => 5), // 自定义实现
    }));
    
    test('add function', () => {
      expect(add(1, 2)).toBe(5); // 返回自定义的值
    });
其他特性
  • 清理: 在每个测试后,Jest 会自动重置模拟的状态,但你也可以使用 jest.clearAllMocks()jest.resetAllMocks() 手动清理。
  • 模块依赖: 如果一个模块依赖于另一个模块,使用 jest.mock 可以确保依赖模块的所有调用都会被模拟。
  • 命名空间: 可以在模拟时使用 jest.spyOn 结合 jest.mock,监视特定函数的调用。

这里,我们使用了手动模拟的方式,如下:

  describe('isDom', () => {
    // 手动模拟从utils.ts中导入模块,并手动定义isObject方法,我们通过jest.fn来创建一个模拟方法
    jest.mock('../util.ts', {
      // @ts-ignore
      isObject: jest.fn((v) => isObject(v))
    });
    // 然后就是具体的测试用例了
    test('should return true for HTMLElement', () => {
      const element = document.createElement('div');
      expect(isDom(element)).toBe(true);
    });

    test('should return true for HTMLCollection', () => {
      const collection = document.getElementsByTagName('div');
      expect(isDom(collection)).toBe(true);
    });

    test('should return true for NodeList', () => {
      const list = document.querySelectorAll('div');
      expect(isDom(list)).toBe(true);
    });

    test('should return false for non-HTML element', () => {
      const nonElement = {};
      expect(isDom(nonElement)).toBe(false);
    });
  });

2. 给核心Message类添加单元测试

观察我们的Message.ts代码,核心的实现就是创建了一个类,并且在类上面实现了一些方法,因此我们在测试的时候,需要模拟new Message,然后再依次测试调用类上面的方法,从而验证是否成功。首先我们需要导入这个核心类,以及相关的图标,然后我们使用describe方法创建一组测试用例。代码如下:

import { Message } from '../core';
import { closeIcon } from '../../const/icon';

describe('Message', () => {
    let message;

    const mockContainer = document.createElement('div');
    mockContainer.setAttribute('id', 'message-container');
    document.body.appendChild(mockContainer);

    beforeEach(() => {
        message = new Message({
            container: mockContainer,
            duration: 2000,
            type: 'info',
            content: 'Test message',
            showClose: true,
            showTypeIcon: true,
            closeIcon: closeIcon('ew-'),
            typeIcon: undefined,
        });
    });

    afterEach(() => {
        message.destroy();
        mockContainer.innerHTML = '';
    }); 
    
    // ...
});

我们创建了一个变量,用于接收Message实例,然后创建一个模拟的容器元素,并添加到body中,jest提供了2个钩子函数beforeEach和afterEach。在继续解读后面代码之前,我们需要先来了解一下jest的这2个钩子函数的作用和用法。

在 Jest 测试框架中,beforeEachafterEach 是用于设置和清理测试环境的钩子函数。它们可以帮助确保每个测试用例在干净的状态下运行,避免测试之间的相互干扰。

beforeEach
  • 作用:在每个测试用例执行之前调用。通常用于初始化共享的状态或创建对象实例。
  • 用法

    beforeEach(() => {
        // 在这里编写初始化代码
    });
  • 示例

    let counter;
    
    beforeEach(() => {
        counter = 0; // 在每个测试前重置计数器
    });
    
    test('increments counter', () => {
        counter++;
        expect(counter).toBe(1);
    });
    
    test('counter is still zero before increment', () => {
        expect(counter).toBe(0);
    });

    在这个例子中,每个测试都会在 counter 被重置为 0 后开始,从而确保测试是独立的。

afterEach
  • 作用:在每个测试用例执行之后调用。通常用于清理或释放资源,确保每个测试后状态的整洁。
  • 用法

    afterEach(() => {
        // 在这里编写清理代码
    });
  • 示例

    let data;
    
    beforeEach(() => {
        data = []; // 初始化数据
    });
    
    afterEach(() => {
        data = null; // 清理数据
    });
    
    test('adds item to data', () => {
        data.push('item');
        expect(data.length).toBe(1);
    });
    
    test('data should be null after cleanup', () => {
        expect(data).toBeNull(); // 确保每个测试后数据被清理
    });

    在这个例子中,每个测试结束后 data 会被清理,这样可以确保后续测试不受前一个测试影响。

总结
  • beforeEachafterEach 是用于管理测试环境的有用工具。
  • 通过在测试用例之前和之后执行代码,可以确保测试的独立性和可靠性,降低潜在的错误和状态污染。

了解了beforeEach和afterEach的用法之后,我们继续,在beforeEach钩子函数中,我们使用new Message创建了一个message实例,传入的参数就是我们实际设计的api参数,在afterEach钩子函数中,我们调用了message的destroy方法,用来销毁实例,并且我们清空了模拟容器元素的innerHTML。

接下来就是一组组的测试用例了,分别如下:

  1. should initialize with default options:检查 Message 实例是否使用默认选项正确初始化。它验证了 type 属性是否设置为 'info',以及 duration 属性是否设置为 2000
 test('should initialize with default options', () => {
     expect(message.options.type).toBe('info');
     expect(message.options.duration).toBe(2000);
 });
  1. should create message element:检查 Message 实例是否正确创建了消息元素。它验证了 el 属性是否不为 null,消息元素的类名是否包含 'ew-message-info',以及消息内容是否正确设置。
test('should create message element', () => {
    expect(message.el).not.toBeNull();
    expect(message.el.className).toContain('ew-message-info');
    expect(message.el.querySelector('p').textContent).toBe('Test message');
});
  1. should render close button:检查 Message 实例是否正确渲染了关闭按钮。它验证了关闭按钮元素是否存在,并且按钮的 HTML 内容是否包含 'close'
test('should render close button', () => {
    const closeBtn = message.el.querySelector('.ew-message-close');
    expect(closeBtn).not.toBeNull();
    expect(closeBtn.innerHTML).toContain('close');
});

后面的其实也都没什么可说的,只要掌握了jest的核心几个方法,我们基本都是依葫芦画瓢,来写单元测试。

当然,这只是对jest的一个简单应用,更复杂的还有如何模拟模块,模拟函数等,模拟函数中的函数这些,由于篇幅原因,这里就不一一说明了。

总结

本文通过一个简单的开源插件示例,来详细讲解了如何为一个库添加单元测试的步骤。如果觉得有用,希望大家不吝啬点赞收藏。

最后

作者是很用心将这个消息提示框打造的很完美的,用法也很明确,可以用在不用ui组件库的中小型网站中,甚至一些系统当中也是可以的。

所以,也希望,能够有更多志同道合的朋友加入进来贡献,将这个插件打造的更轻量,好用,更完美,感谢🙏。


夕水
5.3k 声望5.8k 粉丝

问之以是非而观其志,穷之以辞辩而观其变,资之以计谋而观其识,告知以祸难而观其勇,醉之以酒而观其性,临之以利而观其廉,期之以事而观其信。