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 实战建议:怎么把这些原则用起来

说了这么多,总结一下我的实战经验:

  1. 先写测试,再写代码(TDD)。我刚开始也觉得麻烦,但坚持下来发现,TDD能帮你把代码设计得更清晰。因为你要先想清楚「这个函数应该怎么用」,才能写出好的测试。
  2. 边界值分析和等价类划分是黄金搭档。先用等价类划分确定测试范围,再用边界值分析找出敏感点。两者结合,覆盖率轻松上90%。
  3. Mock外部,Stub内部。外部依赖(数据库、API、文件系统)用Mock,内部依赖(工具类、辅助函数)用Stub。这样既保证了隔离性,又不会让测试太脆弱。
  4. 别追求100%覆盖率。80%的覆盖率,配合好的测试用例设计,比100%的垃圾测试强一百倍。把精力花在核心逻辑和边界条件上。

最后说一句:单元测试不是银弹。它只能保证「代码按预期运行」,不能保证「代码做了正确的事」。但如果你连单元测试都写不好,那后面的集成测试、系统测试就更难了。万丈高楼平地起,单元测试就是那块地基。