Hey guys! 最近找工作面试的时候,有位面试官问到单元测试,真是问到一个我完全未知的领域。今天就来仔细地扒一扒什么是单元测试。

概念和使用场景

单元测试是指软件中最小可测试单元进行检查验证, 也称为模块测试。在node.js 中通过是对某个函数, 模块 , API进行正确验证, 以保证代码的可用性。

使用场景

  • 分支条件很多,边界条件很多
  • 组件库之类的核心/关键模块
  • 工具函数,API之流不依赖外部环境的函数
  • CI/CD的一环质量红线

BDD和TDD

BDD vs TDD(区别)

对比项 TDD(测试驱动开发) BDD(行为驱动开发)
关注点 功能是否正确实现 行为是否符合用户期望
测试描述方式 技术导向(代码逻辑) 自然语言(行为描述)
测试命名 testAddFunction() should add two numbers correctly
编写者 开发者 开发者 + QA + 产品
常见框架 JUnit, unittest Mocha, Jasmine, Jest, Cucumber

TDD 风格

1
2
3
4
5
6
7
8
const assert = require('assert');
const add = (a, b) => a + b;

describe('add()', function() {
it('returns 5 when 2 + 3', function() {
assert.strictEqual(add(2, 3), 5);
});
});

BDD 风格

1
2
3
4
5
6
7
8
9
import { expect } from 'chai';

describe('Calculator', () => {
describe('add()', () => {
it('should return 5 when adding 2 and 3', () => {
expect(2 + 3).to.equal(5);
});
});
});

BDD 的好处有:

  1. 测试代码更接近自然语言,非技术人员也能理解
  2. 更容易维护与文档同步(每个 describe/it 都像行为文档)
  3. 鼓励先写“行为”,再实现功能
  4. 结合工具(如 Cucumber)可以直接从“用户故事”自动生成测试

断言(Assert)的概念

assert 模块提供了断言测试的函数,用于测试不变式。它是Node.js里自带的核心模块。有如下的一些常用方法:

  1. assert.strictEqual(actual, expected[, message]) 判断全等 (===)
1
2
3
4
const assert = require('assert');

assert.strictEqual(2 + 2, 4); // ✅ 通过
assert.strictEqual(2 + 2, '4'); // ❌ 抛错,因为类型不同
  1. assert.notStrictEqual(actual, expected[, message]) 判断不全等 (!==)
1
assert.notStrictEqual(2 + 2, '4'); // ✅
  1. assert.deepStrictEqual(actual, expected[, message]) 深度比较两个对象/数组的内容(要求类型也相同)。
1
2
3
assert.deepStrictEqual({a: 1}, {a: 1});   // ✅
assert.deepStrictEqual([1,2], [1,2]); // ✅
assert.deepStrictEqual({a: 1}, {a: '1'}); // ❌ 类型不一致
  1. assert.ok(value[, message]) 判断值是否为 真值。

  2. assert.fail([message]) 直接让测试失败。

覆盖率(Coverage)

覆盖率(Coverage) 是衡量测试用例“覆盖了多少代码”的指标。如我们需要检查分支是否被单元测试全部涉及,所有函数是否被涉及……

还是以刚刚的math模块方法为例,假设我们增加一个绝对值方法abs()

1
2
3
4
5
6
7
8
// math.js
export function abs(x) {
if (x >= 0) {
return x;
} else {
return -x;
}
}

为了检测单元测试代码是否覆盖了所有,我们需要在测试脚本里加上依赖或者参数配置。比如mocha框架是需要安装nyc依赖,而jest框架则自带覆盖率统计。

以jest为例,在package.json中加上测试命令:

1
2
3
4
5
{
"scripts": {
"test": "jest --coverage"
}
}

效果如:

单元测试工具

测试用例的基本语句

node.js测试用例有4个基本语句:

  • describe : 定义一个测试套件
  • it: 定义一个测试用例
  • expect:断言的判断条件
  • toEqual: 断言的比较结果

测试框架

mocha

特点:

  • 功能非常丰富, 支持 BDD, TDD
  • 支持运行在 node.js 和浏览器中
  • 对异步测试支持非常友好
  • 支持 4种hook, 包括 before/after/beforeEach/afterEach

使用mocha,你需要事先安装mocha:

1
npm i mocha

然后在你的package.json里配置脚本执行mocha:

1
2
3
4
"scripts": {
"start": "node --watch index.js",
"test": "mocha"
},

如此,之后在命令行里可以通过npm test来执行你的单元测试脚本,它会默认在项目中寻找 test/ 目录或 *.test.js 文件,然后执行其中用 describeit 等语法定义的测试。

运行mocha test

jest

相比mocha,jest内容更加全面,集成了断言,jsdom模拟浏览器DOM环境。

动手写一个单元测试

函数测试

首先我们先熟悉一下测试部分的几个核心组成部分:

  1. 顶层 describe()
1
describe('MyFunction 单元测试', () => { ... })

describe 用于给一组相关的测试命名。一个文件里可以有多个 describe,用来分模块组织测试。

  1. 测试用例 it/test
1
it('确认函数执行结果正确', () => { ... })

it(或 test)表示一个独立的测试用例。it彼此之间应当是高内聚低耦合的。

1
2
3
it('should return the correct sum', () => { 
expect(sum(1, 2)).toBe(3);
});

这里贴一个可以跑通看效果的case:

math.js:

1
2
3
4
5
6
7
export function add(a, b) {
return a + b;
}

export function minus(a, b) {
return a - b;
}

test/math.test.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { add, minus } from "../math.js";
import assert from "assert";

describe("测试math.js", function () {
describe("测试add方法", function () {
it("2+3", function () {
assert.equal(add(2, 3), 5);
});
it("4+3", function () {
assert.equal(add(4, 3), 7);
});
});
describe("测试minus方法", function () {
it("5-3", function () {
assert.equal(minus(5, 3), 2);
});
});
});

组件测试

测试文件代码结构

常见的测试文件的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ① 导入依赖
import { mount } from '@vue/test-utils';
import MyComponent from '../src/MyComponent.vue';

// ② 顶层描述块(describe)
describe('MyComponent 单元测试', () => {

// ③ 可选的全局变量
let wrapper;

// ④ 生命周期钩子
beforeAll(() => { /* 所有测试前执行一次,如全局mock */ });
beforeEach(() => { /* 每个测试前执行,如初始化wrapper */ });
afterEach(() => { /* 每个测试后执行,如销毁wrapper */ });
afterAll(() => { /* 所有测试结束后执行,如恢复mock */ });

// ⑤ 各个测试用例(it/test)
it('场景1:默认状态渲染', () => { ... });
it('场景2:点击触发事件', () => { ... });
it('场景3:传入props变化', async () => { ... });

// ⑥ 可选的嵌套 describe(分组测试)
describe('交互行为分组', () => {
it('点击按钮1', () => { ... });
it('点击按钮2', () => { ... });
});
});

组件测试的it执行用例会比函数测试更复杂一些:

1
2
3
4
5
6
7
8
9
10
it('xxx 功能描述', async () => {
//挂载组件
wrapper = mount(MyComponent, { propsData: { visible: true } });

//触发操作
await wrapper.find('.close-btn').trigger('click');

//验证结果
expect(wrapper.emitted().close).toBeTruthy();
});

这里需要记住几个较为核心的生命周期钩子用法:

钩子 执行时机 用途
beforeAll 所有测试前执行一次 设置全局 mock、全局配置
beforeEach 每个 it 前执行 初始化 wrapper、重置数据
afterEach 每个 it 后执行 销毁 wrapper、清理定时器
afterAll 所有测试结束后执行一次 清理全局资源

以一个弹窗组件的单元测试为例

举一个例子,我们对一个elementUI风格的modal(弹窗)组件进行单元测试。假设我们有一个这样的modal组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!-- Modal.vue -->
<template>
<div v-if="visible" class="modal">
<div class="modal-content">
<slot></slot>
<button class="close-btn" @click="close">关闭</button>
</div>
</div>
</template>

<script>
export default {
name: 'Modal',
props: {
visible: { type: Boolean, default: false },
},
methods: {
close() {
this.$emit('close');
},
},
};
</script>

我们使用jest测试框架,对这个vue组件进行测试,我们可以列出一些组件的测试目标:

测试点 要验证的内容
1. 默认不显示 visible=false 时,DOM 中不应有 .modal
2. 显示弹窗 传入 visible=true 时,出现 .modal
3. 点击关闭按钮 点击 .close-btn 时,触发 close 事件
4. 支持 slot 内容 传入 slot 内容应正确渲染
5. 清理挂载节点 每次测试后清理 DOM(afterEach)

然后再将上述思路整理为测试文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// Modal.test.js
import { mount } from '@vue/test-utils';
import Modal from '../components/Modal.vue';

describe('Modal 组件测试', () => {
let wrapper;

// 每次测试后清理,防止残留 DOM
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});

it('默认不显示', () => {
wrapper = mount(Modal, {
propsData: { visible: false },
});
expect(wrapper.find('.modal').exists()).toBe(false);
});

it('显示弹窗', () => {
wrapper = mount(Modal, {
propsData: { visible: true },
});
expect(wrapper.find('.modal').exists()).toBe(true);
});

it('点击关闭按钮触发 close 事件', async () => {
wrapper = mount(Modal, {
propsData: { visible: true },
});
await wrapper.find('.close-btn').trigger('click');
expect(wrapper.emitted().close).toBeTruthy();
});

it('支持 slot 内容渲染', () => {
wrapper = mount(Modal, {
propsData: { visible: true },
slots: {
default: '<p class="slot-text">这是slot内容</p>',
},
});
expect(wrapper.find('.slot-text').text()).toBe('这是slot内容');
});
});

配置CI/CD流水线自动执行

为了能够更好的阐释,我会以本博客的部署Github Action为例来实践单测的流水线接入,且听下回分解。

codesandbox尝试

为了实践一下,建了一个nodejs的沙箱环境,安装如下依赖:

1
npm install vue@2 @vue/test-utils jest vue-jest babel-jest babel-core@^7.0.0-bridge.0 --save-dev

sandbox项目结构如下:

1
2
3
4
5
6
7
8
9
sandbox/
├── package.json # 管理依赖和脚本
├── jest.config.js # Jest 配置文件
├── babel.config.js # Babel 转译配置
└── src/
├── components/
│ └── Modal.vue
└── __tests__/
└── modal.test.js # 组件测试

我这里使用的是vue2的框架做组件测试,jest测试框架是在Node环境下运行测试,而Vue组件需要依赖浏览器环境语法,所以需要一些转译方法将它们转成node可执行的JS,尤其需要注意的是CommonJS与ESM语法的不同,不要混用两者。

  • CommonJS(CJS)require()module.exports
  • ES Module(ESM)import ... fromexport default
  • Node.js 默认识别 .cjs 为 CommonJS,.mjs"type": "module" 为 ESM。

这里还有一个可能遇到的问题就是:

Jest encountered an unexpected token
Unexpected token ‘export’

这是一个可能遇到的转译问题,一个解决方案是使用Babel,来理解ES6的导入导出语法。

babel.config.js:

1
2
3
module.exports = {
presets: ["@babel/preset-env"]
}

比较菜的前端同学(比如我),可能不是特别清楚babel到底是啥,补一个好记的理解:

Babel 是一个 JavaScript 编译器,用来把新版本的 JS 语法转换成旧版本浏览器或 Node.js 能理解的代码。

jest.config.js:

1
2
3
4
5
6
7
8
9
module.exports = {
moduleFileExtensions: ["js", "json", "vue"],
transform: {
"^.+\\.js$": "babel-jest",
".*\\.(vue)$": "@vue/vue2-jest",
},
moduleFileExtensions: ["js", "json", "vue"],
testEnvironment: "jsdom",
};

完成配置之后,可以用npm test执行测试:

执行单元测试成功效果如下:

附上沙箱的链接,里面会有可用的依赖配置:
unit test📦

结语

以上阐释均为入门级别的测试框架使用和单元测试概念了解,煮啵写这篇博客的时候翻阅资料,了解了许多测试框架的用途,实际使用的时候还是需要结合文档多尝试,在实验中去完善认知。

参考文献

mocha是一个功能丰富的javascript测试框架

Node.js单元测试 – 测试工具篇

小书介绍 | Jest 实践指南

Polyfill 和转译器