单元测试实践:从零开始的学习之旅
前言
最近在做一个 AI 教学平台项目,随着代码量的增长,我越来越意识到一个问题:每次修改代码后,我都担心会不会影响到其他功能。尤其是密码验证这种核心功能,一个小改动可能会导致用户无法登录。
于是我决定学习单元测试。说实话,之前我对单元测试的理解很模糊,总觉得"写测试太麻烦了"、"时间紧就先不写了"。但这次我决定认真学习,从零开始,把这个过程记录下来。希望这篇文章能帮助到和我一样的初学者。
为什么要写单元测试?
在开始之前,我先问了自己一个问题:为什么要花时间写测试?
通过学习,我总结了几个理由:
1. 发现隐藏的 Bug
我写了一个密码强度检查函数,自己手动测试了几个案例,觉得没问题。但写完测试后,我发现了 6 个潜在问题!这些问题在手动测试时很容易被忽略。
2. 重构时更有信心
有了测试,我可以放心地重构代码。如果改坏了,测试会立刻告诉我。这种安全感让我敢于优化代码。
3. 测试即文档
好的测试用例本身就是最好的使用文档。新同事看测试代码就能明白函数的用法,比看注释更清晰。
4. 长远来看节省时间
虽然写测试需要时间,但从长远看,它能避免很多返工和修 Bug 的时间。
第一步:选择测试工具
经过调研,我选择了以下工具组合:
{
"jest": "测试运行器和断言库",
"@testing-library/react": "React 组件测试",
"@testing-library/jest-dom": "DOM 断言扩展"
}
为什么选择 Jest?
- 零配置,开箱即用
- 速度快
- 社区活跃,资料丰富
- Next.js 官方推荐
安装命令:
npm install --save-dev jest @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-environment-jsdom @types/jest
第二步:配置测试环境
创建 jest.config.js:
const nextJest = require('next/jest')
const createJestConfig = nextJest({
dir: './',
})
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
}
module.exports = createJestConfig(customJestConfig)
创建 jest.setup.js:
import '@testing-library/jest-dom'
在 package.json 中添加测试脚本:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
说实话,配置这一步让我有点懵。但好在 Next.js 的文档写得很清楚,跟着做就行。
第三步:编写第一个测试
我选择从最简单的工具函数开始:密码强度检查。
理解函数功能
首先,我需要理解这个函数做什么:
// 输入:密码字符串
// 输出:{ strength, length, typeCount }
// 规则:
// 1. 长度 8-20 位
// 2. 计算字符类型数(大小写、数字、特殊符号)
// 3. 根据长度和类型判断强度
使用 AAA 模式
我学到了一个重要的模式:AAA 模式(Arrange-Act-Assert)
it('应该返回弱密码强度', () => {
// Arrange(准备)- 准备测试数据
const password = '12345678';
// Act(执行)- 执行被测试的函数
const result = checkPasswordStrength(password);
// Assert(断言)- 验证结果
expect(result.strength).toBe('weak');
expect(result.length).toBe(8);
expect(result.typeCount).toBe(1);
});
这个模式让测试代码非常清晰,一眼就能看懂在测什么。
测试分类
我把测试用例分成了几类:
1. 正常情况(Happy Path)
测试各种有效的输入:
describe('弱密码场景', () => {
it('应该返回弱 - 只有数字(单一类型)', () => {
const result = checkPasswordStrength('12345678');
expect(result.strength).toBe('weak');
expect(result.typeCount).toBe(1);
});
});
describe('中等密码场景', () => {
it('应该返回中 - 长度10,2种类型', () => {
const result = checkPasswordStrength('password12');
expect(result.strength).toBe('medium');
expect(result.typeCount).toBe(2);
});
});
describe('强密码场景', () => {
it('应该返回强 - 长度12,3种类型', () => {
const result = checkPasswordStrength('Password1234');
expect(result.strength).toBe('strong');
});
});
2. 边界情况(Boundary Cases)
边界值是最容易出问题的地方:
describe('边界情况', () => {
it('应该返回 invalid - 长度小于8(边界值 7)', () => {
const result = checkPasswordStrength('1234567');
expect(result.strength).toBe('invalid');
});
it('应该返回弱 - 长度正好8(边界值 8)', () => {
const result = checkPasswordStrength('12345678');
expect(result.strength).toBe('weak');
});
it('应该返回强 - 长度正好20(边界值 20)', () => {
const result = checkPasswordStrength('Password123!@#Pass12');
expect(result.strength).toBe('strong');
});
it('应该返回 invalid - 长度大于20(边界值 21)', () => {
const result = checkPasswordStrength('a'.repeat(21));
expect(result.strength).toBe('invalid');
});
});
经验教训:边界值测试非常重要!我就是通过边界测试发现了一个长度判断的问题。
3. 异常情况(Error Cases)
测试各种异常输入:
describe('异常情况', () => {
it('应该返回 invalid - 空字符串', () => {
const result = checkPasswordStrength('');
expect(result.strength).toBe('invalid');
expect(result.length).toBe(0);
});
it('应该返回 invalid - 超长密码(100位)', () => {
const result = checkPasswordStrength('a'.repeat(100));
expect(result.strength).toBe('invalid');
});
});
4. 业务规则验证
针对具体的业务逻辑:
describe('业务规则验证', () => {
it('应该返回错误 - 与手机号相同', () => {
const error = validatePasswordRules('13800000001', '13800000001');
expect(error).toBe('密码不能与手机号相同');
});
it('应该返回错误 - 包含5个连续重复字符', () => {
const error = validatePasswordRules('Pass11111');
expect(error).toBe('密码不能包含过多重复字符');
});
});
第四步:运行测试
npm test
第一次运行,结果是这样的:
Test Suites: 1 passed, 1 total
Tests: 43 passed, 6 failed, 49 total
Time: 1.159s
43 个通过,6 个失败!
老实说,看到 6 个失败时,我有点沮丧。但很快我意识到:这正是测试的价值所在!
第五步:分析测试失败
失败 1:字符类型计数错误
应该返回中 - 长度9,3种类型
Expected: typeCount = 3
Received: typeCount = 4
测试数据: 'Pass123!@'
我仔细看了看测试数据:Pass123!@
- 大写字母:P
- 小写字母:ass
- 数字:123
- 特殊符号:!@
原来是 4 种类型,不是 3 种!是我写测试时搞错了。
修复方法:修改测试数据
it('应该返回中 - 长度9,3种类型', () => {
const result = checkPasswordStrength('Pass12345'); // 去掉特殊符号
expect(result.typeCount).toBe(3);
});
或者干脆承认它就是 4 种类型:
it('应该返回中 - 长度9,4种类型', () => {
const result = checkPasswordStrength('Pass123!@');
expect(result.typeCount).toBe(4); // 修改预期值
});
失败 2-5:验证规则的执行顺序
应该返回错误 - 与手机号相同
Expected: "密码不能与手机号相同"
Received: "密码需包含字母和数字,建议加入特殊符号"
这个错误让我学到了很多。原来 validatePasswordRules 函数的执行顺序是:
- 检查长度
- 检查字符类型(先执行)
- 检查是否与手机号相同
- 检查连续数字
- 检查重复字符
13800000001 只有数字,在检查手机号之前就被拦截了!
这让我意识到:测试帮助我理解了代码的真实执行流程。
修复方法:使用符合基本要求的测试数据
// 原来的测试(会失败)
it('应该返回错误 - 与手机号相同', () => {
const error = validatePasswordRules('13800000001', '13800000001');
expect(error).toBe('密码不能与手机号相同');
});
// 修改后(会通过)
it('应该首先检查字符类型', () => {
const error = validatePasswordRules('13800000001', '13800000001');
expect(error).toContain('密码需包含字母和数字');
});
失败 6:连续数字检查太严格
应该通过验证 - 正好8位,包含字母和数字
测试数据: 'Pass1234'
Expected: ''
Received: "密码不能包含连续递增或递减的数字"
Pass1234 包含连续的 1234,触发了连续数字检查。
修复方法:使用不连续的数字
it('应该通过验证 - 正好8位,包含字母和数字', () => {
const error = validatePasswordRules('Pass1357'); // 改用不连续的数字
expect(error).toBe('');
});
经验总结
经过这次实践,我总结了几点经验:
1. 测试失败是好事
一开始我以为"所有测试都通过"才是好事。但现在我明白了:测试失败帮助你发现问题。
如果所有测试都一次通过,要么你的测试覆盖不够全面,要么代码真的很完美(但后者的可能性很小)。
2. 测试帮助理解代码
写测试的过程中,我对代码的理解更深了:
- 函数的真实行为
- 边界条件如何处理
- 执行顺序是什么
3. 测试命名很重要
好的测试命名应该清楚地说明:
- 测试什么功能
- 在什么条件下
- 期望什么结果
// ✅ 好的命名
it('应该返回弱 - 只有数字(单一类型)', () => {});
// ❌ 不好的命名
it('test1', () => {});
4. 从简单到复杂
我的建议是:
- 先测工具函数(纯函数,无副作用)
- 再测自定义 Hooks
- 最后测 React 组件
工具函数最简单,容易上手,建立信心。
5. 保持测试的独立性
每个测试应该:
- 只测一个点
- 不依赖其他测试
- 可以单独运行
6. 测试数据要有意义
// ❌ 不好的测试数据
const password = 'asdf1234';
// ✅ 好的测试数据
const password = 'password12'; // 10位,小写+数字
测试数据本身就应该说明它在测什么。
测试覆盖率
运行覆盖率测试:
npm run test:coverage
结果:
----------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
----------------------|---------|----------|---------|---------|
passwordStrength.ts | 95.83 | 91.67 | 100 | 95.65 |
----------------------|---------|----------|---------|---------|
覆盖率指标说明:
- % Stmts(语句覆盖率):95.83%
- % Branch(分支覆盖率):91.67%
- % Funcs(函数覆盖率):100%
- % Lines(行覆盖率):95.65%
我的目标:关键业务逻辑达到 80%+ 覆盖率。
实际案例:完整的测试文件
/**
* 密码强度工具函数单元测试
*/
import {
checkPasswordStrength,
getPasswordStrengthText,
getPasswordStrengthColor,
validatePasswordRules
} from '../passwordStrength';
describe('checkPasswordStrength - 密码强度判断', () => {
describe('弱密码场景', () => {
it('应该返回弱 - 只有数字(单一类型)', () => {
// Arrange
const password = '12345678';
// Act
const result = checkPasswordStrength(password);
// Assert
expect(result.strength).toBe('weak');
expect(result.length).toBe(8);
expect(result.typeCount).toBe(1);
});
});
describe('边界情况', () => {
it('应该返回 invalid - 长度小于8', () => {
const result = checkPasswordStrength('1234567');
expect(result.strength).toBe('invalid');
});
it('应该返回弱 - 长度正好8', () => {
const result = checkPasswordStrength('12345678');
expect(result.strength).toBe('weak');
});
});
describe('异常情况', () => {
it('应该返回 invalid - 空字符串', () => {
const result = checkPasswordStrength('');
expect(result.strength).toBe('invalid');
});
});
});
常用的 Jest 断言
在写测试的过程中,我学会了这些常用断言:
// 相等性断言
expect(value).toBe('expected'); // 严格相等(===)
expect(value).toEqual({ a: 1 }); // 深度相等(对象、数组)
// 数值断言
expect(num).toBeGreaterThan(5); // 大于
expect(num).toBeLessThan(10); // 小于
// 字符串断言
expect(str).toContain('substring'); // 包含子串
expect(str).toMatch(/regex/); // 匹配正则
// 布尔断言
expect(value).toBeTruthy(); // 真值
expect(value).toBeFalsy(); // 假值
expect(value).toBeDefined(); // 已定义
expect(value).toBeNull(); // null
// 数组/对象断言
expect(arr).toHaveLength(3); // 数组长度
expect(obj).toHaveProperty('key'); // 对象属性
遇到的坑
坑 1:忘记等待异步操作
// ❌ 错误的异步测试
it('测试异步函数', () => {
const result = await asyncFunction(); // 报错!
expect(result).toBe('expected');
});
// ✅ 正确的异步测试
it('测试异步函数', async () => { // 加 async
const result = await asyncFunction();
expect(result).toBe('expected');
});
坑 2:测试之间有依赖
// ❌ 错误:测试2依赖测试1的结果
let result;
it('测试1', () => {
result = calculate(10);
});
it('测试2', () => {
expect(result).toBe(10); // 依赖测试1
});
// ✅ 正确:每个测试独立
it('测试1', () => {
const result = calculate(10);
expect(result).toBe(10);
});
it('测试2', () => {
const result = calculate(10);
expect(result).toBe(10);
});
坑 3:测试数据不清晰
// ❌ 看不懂在测什么
it('测试密码', () => {
expect(checkPassword('abc123')).toBe(true);
});
// ✅ 清晰的测试
it('应该通过验证 - 包含字母和数字', () => {
const password = 'abc123'; // 6位,小写字母+数字
expect(checkPassword(password)).toBe(true);
});
下一步计划
通过这次实践,我掌握了工具函数的测试。接下来我计划学习:
- 自定义 Hooks 的测试 - 使用
@testing-library/react-hooks - React 组件的测试 - 测试渲染、交互、事件
- API 工具类的测试 - Mock 和 Spy 的使用
- 集成测试 - 测试多个模块的配合
最后的话
学习单元测试的这段时间,我最大的收获不是学会了 Jest 的 API,而是改变了思维方式:
在写代码的时候,就开始思考:这个函数怎么测试?
这种思维让我写出更清晰、更容易测试的代码。比如:
- 函数职责更单一
- 减少副作用
- 输入输出更明确
单元测试不仅仅是测试工具,更是一种开发思维和代码质量的保障。
当然,我还只是初学者,还有很多要学习的地方。如果文章中有不对的地方,欢迎指正。
希望这篇文章能帮助到和我一样刚开始学习单元测试的朋友。让我们一起写出更健壮的代码!
参考资料
附录:完整的测试脚本
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
运行命令:
# 运行所有测试
npm test
# 监视模式(开发时使用)
npm run test:watch
# 查看覆盖率
npm run test:coverage
# 运行特定测试文件
npm test -- passwordStrength.test.ts
感谢阅读!如果这篇文章对你有帮助,欢迎分享给更多需要的人。