单元测试实践:从零开始的学习之旅

7 阅读11分钟

单元测试实践:从零开始的学习之旅

前言

最近在做一个 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 函数的执行顺序是:

  1. 检查长度
  2. 检查字符类型(先执行)
  3. 检查是否与手机号相同
  4. 检查连续数字
  5. 检查重复字符

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. 从简单到复杂

我的建议是:

  1. 先测工具函数(纯函数,无副作用)
  2. 再测自定义 Hooks
  3. 最后测 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);
});

下一步计划

通过这次实践,我掌握了工具函数的测试。接下来我计划学习:

  1. 自定义 Hooks 的测试 - 使用 @testing-library/react-hooks
  2. React 组件的测试 - 测试渲染、交互、事件
  3. API 工具类的测试 - Mock 和 Spy 的使用
  4. 集成测试 - 测试多个模块的配合

最后的话

学习单元测试的这段时间,我最大的收获不是学会了 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

感谢阅读!如果这篇文章对你有帮助,欢迎分享给更多需要的人。