4. 单元测试设计:测试用例设计原则、边界值分析、等价类划分、Mock与Stub的使用
单元测试,说白了就是给代码的最小单元做体检。我见过太多团队,代码写得很嗨,一跑测试就崩。为什么?因为测试用例设计得不够好。今天咱们就聊聊,怎么把单元测试用例设计得既全面又高效。
4.1 测试用例设计原则:别让测试变成摆设
我个人习惯,设计测试用例前先问自己三个问题:
- 这个函数到底在干什么? 输入是什么,输出是什么,副作用是什么。
- 最可能出错的点在哪? 边界、异常、空值、并发。
- 我能不能用最少的用例覆盖最多的场景? 别写重复的测试,那是浪费生命。
核心原则其实就几条:
- 原子性:一个测试用例只测一件事。我见过一个测试用例里又测加法又测除法,结果加法挂了,除法也报错,排查起来特别痛苦。
- 独立性:用例之间不能有依赖。你想想看,如果测试A依赖测试B的结果,那B挂了A也挂,你根本不知道问题在哪。
- 可重复性:同样的输入,每次跑结果必须一样。我曾经被一个随机数生成的测试坑过,跑十次有两次失败,后来发现是种子没固定。
- 自动化:能自动跑就别手动点。手动测试一次两次还行,天天点谁受得了?
避坑指南:我曾经接手过一个项目,单元测试覆盖率90%,但线上还是频繁出bug。后来发现,测试用例全是happy path,异常路径一个都没测。覆盖率数字好看,但实际价值很低。记住:覆盖率是手段,不是目标。
4.2 边界值分析:bug最喜欢藏在边界上
为什么边界值分析这么重要?因为程序员写代码时,最容易在边界条件上犯错。比如判断一个数是否在[1, 100]之间,很多人会写成 if (x >= 1 && x <= 100),但如果是 if (x > 1 && x < 100) 呢?边界值就是用来揪出这种错误的。
边界值分析的核心思想:取边界值及其附近的值进行测试。通常我们取:
- 最小值
- 略大于最小值
- 略小于最小值
- 最大值
- 略大于最大值
- 略小于最大值
- 正常值(中间值)
举个例子,假设有个函数 calculateDiscount(age),年龄范围是0-120岁:
// 边界值测试用例
// 年龄范围:0 - 120
// 边界值:0, 1, 119, 120
// 附近值:-1, 121
@Test
public void testAgeBoundary() {
// 最小值边界
assertEquals(0, calculateDiscount(0)); // 刚好0岁
assertEquals(0, calculateDiscount(1)); // 略大于0
// 最大值边界
assertEquals(0.5, calculateDiscount(119)); // 略小于120
assertEquals(0.5, calculateDiscount(120)); // 刚好120岁
// 异常边界
assertThrows(IllegalArgumentException.class, () -> calculateDiscount(-1));
assertThrows(IllegalArgumentException.class, () -> calculateDiscount(121));
}
我个人习惯,边界值分析一定要和等价类划分配合使用。边界值帮你找到最敏感的点,等价类帮你覆盖整个范围。两者结合,事半功倍。
小技巧:写边界值测试时,别只盯着数值边界。字符串长度、数组大小、时间范围、文件大小……只要是有限制的,都值得做边界值分析。我在项目中遇到过一个问题:用户昵称限制20个字符,结果19个字符正常,20个字符就报错。后来发现是数据库字段长度设成了varchar(20),但代码里没做截断处理。
4.3 等价类划分:用最少的用例覆盖最多的场景
等价类划分,说白了就是把输入数据分成几个「等价类」,每个类里的数据对程序来说行为是一样的。你只需要从每个类里选一个代表值来测试就行。
举个例子,一个登录功能,用户名要求6-20个字符,只能包含字母和数字:
| 等价类 | 描述 | 代表值 | 预期结果 |
|---|---|---|---|
| 有效等价类 | 6-20个字母数字组合 | "user123" | 通过 |
| 无效等价类-长度不足 | 小于6个字符 | "ab" | 失败 |
| 无效等价类-长度超限 | 大于20个字符 | "a".repeat(21) | 失败 |
| 无效等价类-包含特殊字符 | 包含@、#等 | "user@123" | 失败 |
| 无效等价类-包含中文 | 包含汉字 | "用户123" | 失败 |
| 无效等价类-空值 | null或空字符串 | "" | 失败 |
你看,6个测试用例就覆盖了所有场景。如果不用等价类划分,你可能要测几十个甚至上百个组合,累死也测不完。
嗯,这里要注意:等价类划分不是万能的。它假设同一类里的数据行为一致,但有些bug恰恰藏在类内部的细微差别里。所以,我建议等价类划分和边界值分析一起用,边界值专门去「骚扰」那些敏感点。
4.4 Mock与Stub的使用:隔离外部依赖
单元测试的核心是「隔离」。你测的是当前单元,不是它的依赖。如果每个测试都要连数据库、调外部API、读文件系统,那还叫单元测试吗?那叫集成测试。
这时候,Mock和Stub就派上用场了。
4.4.1 Stub:模拟依赖的返回值
Stub说白了就是「替身」。你给一个假的实现,让它返回你期望的值。比如:
// 假设有个UserService依赖UserRepository
public class UserService {
private UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public String getUserName(Long userId) {
User user = userRepository.findById(userId);
return user != null ? user.getName() : "Unknown";
}
}
// Stub实现
public class UserRepositoryStub implements UserRepository {
@Override
public User findById(Long userId) {
// 返回一个固定的User对象
return new User(1L, "张三", "zhangsan@example.com");
}
}
// 测试
@Test
public void testGetUserName() {
UserRepositoryStub stub = new UserRepositoryStub();
UserService service = new UserService(stub);
String name = service.getUserName(1L);
assertEquals("张三", name);
}
Stub的好处是简单直接,适合那些「只需要返回值」的场景。但缺点也很明显:你没法验证交互行为,比如这个方法被调用了多少次、传了什么参数。
4.4.2 Mock:验证交互行为
Mock比Stub更进一步。它不仅模拟返回值,还能验证「调用过程」。比如:
// 使用Mockito框架
@Test
public void testGetUserNameWithMock() {
// 创建Mock对象
UserRepository mockRepo = mock(UserRepository.class);
// 定义行为:当findById(1L)被调用时,返回一个User对象
when(mockRepo.findById(1L)).thenReturn(new User(1L, "张三", "zhangsan@example.com"));
// 注入Mock
UserService service = new UserService(mockRepo);
// 执行测试
String name = service.getUserName(1L);
// 验证结果
assertEquals("张三", name);
// 验证交互:findById(1L)是否被调用了一次
verify(mockRepo, times(1)).findById(1L);
}
我个人习惯,能用Mock就别用Stub。为什么?因为Mock能帮你验证「代码是否按预期的方式调用了依赖」。比如,你希望某个方法只调用一次数据库,如果代码里不小心调了两次,Mock能立刻发现。Stub就做不到。
注意:Mock不是万能的。过度Mock会导致测试和实现耦合太紧。你改一下内部实现,测试就得跟着改。我见过一个项目,测试里Mock了十几个对象,每次重构都痛苦得要命。记住:Mock外部依赖(数据库、网络、文件系统),不要Mock内部逻辑。
4.5 实战建议:怎么把这些原则用起来
说了这么多,总结一下我的实战经验:
- 先写测试,再写代码(TDD)。我刚开始也觉得麻烦,但坚持下来发现,TDD能帮你把代码设计得更清晰。因为你要先想清楚「这个函数应该怎么用」,才能写出好的测试。
- 边界值分析和等价类划分是黄金搭档。先用等价类划分确定测试范围,再用边界值分析找出敏感点。两者结合,覆盖率轻松上90%。
- Mock外部,Stub内部。外部依赖(数据库、API、文件系统)用Mock,内部依赖(工具类、辅助函数)用Stub。这样既保证了隔离性,又不会让测试太脆弱。
- 别追求100%覆盖率。80%的覆盖率,配合好的测试用例设计,比100%的垃圾测试强一百倍。把精力花在核心逻辑和边界条件上。
最后说一句:单元测试不是银弹。它只能保证「代码按预期运行」,不能保证「代码做了正确的事」。但如果你连单元测试都写不好,那后面的集成测试、系统测试就更难了。万丈高楼平地起,单元测试就是那块地基。