Skip to content

第48课:JUnit

🎯 学习目标

  • 理解单元测试的价值与 TDD 思想
  • 掌握 JUnit 5 的断言、生命周期注解
  • 掌握参数化测试、@Disabled、@Nested
  • 了解 Mockito 模拟依赖、AssertJ 流式断言
  • 能写出可维护的单元测试

📖 一、概念讲解:为什么要写单元测试

1. 没有测试的痛

改了一处代码,不知有没有改坏别处 → 手动回归测试(慢、易漏)→ 上线出 bug。代码越改越怕,不敢重构。

2. 单元测试是什么

单元测试:对代码的"最小单元"(一个方法)自动验证行为正确。每次改代码跑一遍测试,几分钟内知道有没有改坏。

写一个 add(a,b) 方法 → 写 testAdd() 验证 add(2,3)==5 → 改代码后跑测试,绿灯=没改坏

3. 测试金字塔

       /  E2E  \      少(慢,整个系统)
      / 集成测试 \    中(调真实依赖如DB)
     /  单元测试  \   多(快,mock 依赖)

单元测试最多(快、隔离),集成测试次之,E2E 最少(慢、贵)。

类比:单元测试像"安全网"——你改代码时它在下面接着,改坏了立刻亮红灯。没有安全网,改代码如走钢丝。

4. TDD(测试驱动开发)

红:先写测试(方法还没实现,测试失败)
绿:写最少代码让测试通过
重构:优化代码(测试保证不破坏)

TDD 让测试先行,倒逼设计可测试的代码。


📖 二、JUnit 5 基础

java
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

class CalculatorTest {

    private Calculator calc;

    @BeforeEach      // 每个测试方法前执行(初始化)
    void setUp() {
        calc = new Calculator();
    }

    @Test
    void add_两个正数_返回和() {
        assertEquals(5, calc.add(2, 3));   // 断言:期望5 == 实际add(2,3)
    }

    @Test
    void divide_除零_抛异常() {
        assertThrows(ArithmeticException.class, () -> calc.divide(10, 0));
    }
}

命名约定

  • 测试类:被测类名Test(如 CalculatorTest)
  • 测试方法:方法_场景_预期(如 add_两个正数_返回和),清楚表达意图

📖 三、断言(Assertions)

java
assertEquals(5, result);                    // 期望 == 实际
assertNotEquals(0, result);                // 不等
assertTrue(result > 0);                    // 为真
assertFalse(list.isEmpty());               // 为假
assertNull(obj);                           // 为 null
assertNotNull(obj);                        // 非 null
assertThrows(Exception.class, () -> {...}); // 执行抛指定异常
assertDoesNotThrow(() -> {...});           // 不抛异常
assertIterableEquals(list1, list2);        // 列表逐元素相等

断言是测试的核心——表达"我期望这个结果"。失败时 JUnit 报告哪个断言失败、期望值和实际值。


📖 四、生命周期注解

注解时机用途
@BeforeAll类加载后一次(static)重资源初始化(连接池)
@BeforeEach每个测试方法前初始化被测对象
@AfterEach每个测试方法后清理(重置状态)
@AfterAll所有测试后一次(static)释放资源
@Disabled跳过该测试临时禁用
@DisplayName测试显示名友好名称
java
@BeforeAll
static void initAll() { /* 启动数据库(一次) */ }

@BeforeEach
void init() { /* 每个测试前创建新 calc */ }

@AfterEach
void tearDown() { /* 清理 */ }

@AfterAll
static void cleanupAll() { /* 关闭数据库(一次) */ }

关键:每个 @Test 方法独立(@BeforeEach 保证互不影响),测试不应有顺序依赖。


📖 五、参数化测试

同一个逻辑测多组数据,避免写一堆重复测试:

java
@ParameterizedTest
@CsvSource({
    "2, 3, 5",
    "0, 0, 0",
    "-1, 1, 0",
    "100, 200, 300"
})
void add_多组数据_返回正确和(int a, int b, int expected) {
    assertEquals(expected, calc.add(a, b));
}

一次定义,跑多组数据。数据源:@ValueSource(单值)、@CsvSource(CSV)、@MethodSource(方法生成)、@EnumSource(枚举)。


📖 六、Mockito(模拟依赖)

被测方法依赖外部(数据库/网络),单元测试要隔离——用 Mock 替换依赖:

java
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
    @Mock UserRepository repo;        // 假的仓库(不真连数据库)
    @InjectMocks UserService service; // 把 mock 注入 service

    @Test
    void getUser_仓库返回用户_返回名字() {
        when(repo.findById(1)).thenReturn(new User("Alice"));  // 设定 mock 行为
        String name = service.getUserName(1);
        assertEquals("Alice", name);
        verify(repo).findById(1);   // 验证 repo.findById(1) 被调用
    }
}

Mock 价值:不依赖真实环境(数据库/网络),测试快、稳定、隔离。设定 when().thenReturn() 控制行为,verify() 验证调用。


⚠️ 七、常见陷阱

陷阱1:测试依赖顺序

测试 A 创建数据,测试 B 依赖它 → 单独跑 B 失败。每个测试独立,@BeforeEach 重置。

陷阱2:断言不充分

只测"不抛异常"不断言结果,bug 漏网。断言要具体(assertEquals 期望值)。

陷阱3:测试逻辑复杂

测试比被测代码还复杂 → 测试自己可能有 bug。测试简单直接。

陷阱4:依赖真实环境

单元测试连真实数据库/网络 → 慢、不稳定(环境变了测试挂)。用 Mock 隔离。

陷阱5:不测边界

只测正常情况,漏边界(空值、零、负数、最大值)。边界最易出 bug。


🆚 八、JUnit vs 手写断言 / C 测试

特性手写 ifJUnitC 测试框架
断言if+printassertEquals 等assert()
运行手动调框架自动发现手动注册
报告手写自动统计简单
生命周期@BeforeEach 等setup/teardown

对 C 程序员:JUnit 对应 C 的测试框架(如 Unity/Check),用断言验证。JUnit 注解驱动(@Test 自动发现),比 C 手动注册测试方便。思想相通:断言 + 自动运行 + 报告。


💡 九、最佳实践

  1. 方法_场景_预期命名,清楚表达测试意图。
  2. 每个测试独立,@BeforeEach 重置,无顺序依赖。
  3. 断言具体,assertEquals 期望值,不只测不抛异常。
  4. 测边界:空、零、负数、边界值。
  5. Mock 隔离外部依赖,单元测试不连真实环境。
  6. 参数化测试测多组数据,少重复。
  7. ** Arrange-Act-Assert**(准备-执行-断言)三段式结构。
  8. CI 跑测试,每次提交验证不回归。

📝 练习预告

完成 练习/Ex48_JUnit.java 中的 6 道题:

  1. 断言(assertEquals/assertTrue)
  2. 生命周期(@BeforeEach)
  3. 异常断言(assertThrows)
  4. 参数化测试思路
  5. Mock 思路(注释)
  6. 综合:手写测试运行器(纯 JDK,模拟 JUnit 核心)

完成后对比 答案/Sol48.java,查看逐行讲解与多解法。


📖 十、测试结构:AAA

推荐每个测试使用 AAA 结构:

text
Arrange:准备数据和依赖。
Act:执行被测行为。
Assert:验证结果。

示例:

java
@Test
void add_两个正数_返回和() {
    // Arrange
    Calculator calculator = new Calculator();

    // Act
    int result = calculator.add(2, 3);

    // Assert
    assertEquals(5, result);
}

好测试的特征:

text
一眼能看懂场景。
只验证一个核心行为。
失败信息能定位原因。
不依赖执行顺序。
不依赖外部环境。
运行速度快。

📖 十一、参数化测试数据源

常用数据源:

java
@ParameterizedTest
@ValueSource(strings = {"", " ", "\t"})
void isBlank_空白字符串_返回true(String input) {
    assertTrue(StringUtils.isBlank(input));
}

CSV:

java
@ParameterizedTest
@CsvSource({
    "2, 3, 5",
    "-1, 1, 0",
    "0, 0, 0"
})
void add_多组数字_返回和(int a, int b, int expected) {
    assertEquals(expected, calculator.add(a, b));
}

MethodSource:

java
static Stream<Arguments> cases() {
    return Stream.of(
        Arguments.of("a@example.com", true),
        Arguments.of("bad-email", false)
    );
}

@ParameterizedTest
@MethodSource("cases")
void validateEmail_多组输入_返回预期(String input, boolean expected) {
    assertEquals(expected, validator.isEmail(input));
}

数据多、结构复杂时,MethodSourceCsvSource 更清晰。


📖 十二、Mockito 使用边界

Mock 适合隔离外部依赖,不适合替代所有对象。

适合 Mock:

text
数据库 Repository。
远程 HTTP/RPC 客户端。
消息队列生产者。
文件系统、时间、随机数等外部依赖。
发送邮件、短信、支付等副作用组件。

不适合 Mock:

text
普通值对象。
简单工具类。
集合。
被测对象内部的私有方法。
你真正想验证的核心逻辑。

测试应该关注行为结果,而不是过度验证内部调用。

java
verify(repository).save(user);

这种验证适合“保存动作就是结果”的场景。但如果最终状态可断言,优先断言状态。


📖 十三、单元测试与集成测试

单元测试:

text
快。
隔离。
不依赖真实数据库和网络。
主要验证业务逻辑。

集成测试:

text
较慢。
使用真实或近似真实依赖。
验证配置、SQL、序列化、事务、框架集成。

两者都需要:

text
单元测试保证逻辑正确。
集成测试保证组件能连起来。
E2E 测试保证关键用户路径可用。

不要用大量慢集成测试替代单元测试,也不要以为 Mock 全绿就代表系统能跑。


🛠 十四、脆弱测试排查

脆弱测试的表现:

text
本地过,CI 不过。
单独跑过,整套跑不过。
偶发失败。
依赖当前时间。
依赖测试顺序。
依赖外部网络。
断言过于宽泛或过于细节。

治理方式:

text
固定时间源,例如注入 Clock。
每个测试独立准备数据。
不要依赖随机数,或固定 seed。
不要依赖真实第三方服务。
清理全局状态和静态缓存。
避免 sleep 等待,使用可控同步。

时间依赖示例:

java
Clock fixedClock = Clock.fixed(
    Instant.parse("2026-06-30T00:00:00Z"),
    ZoneOffset.UTC
);

📊 十五、覆盖率与质量

覆盖率有价值,但不能迷信。

常见指标:

text
行覆盖率:执行过多少行。
分支覆盖率:if/else、switch 分支覆盖。
方法覆盖率:执行过多少方法。

高覆盖率不等于高质量:

text
没有断言也能提高覆盖率。
只测正常路径,边界仍然漏。
Mock 过度会让测试脱离真实行为。

更重要的是:

text
核心业务规则是否覆盖。
异常和边界是否覆盖。
测试失败是否能阻止回归。
测试是否易读、易维护。

🧭 十六、测试用例设计清单

写测试时考虑:

text
正常输入。
空值。
空集合。
边界值。
非法输入。
重复调用。
并发调用。
异常路径。
外部依赖失败。
权限不足。
状态转换。

命名建议:

text
方法_场景_预期。
given_when_then。
should_预期_when_场景。

团队选一种风格坚持即可,核心是让失败报告能读懂业务场景。


✅ 十七、掌握标准

学完本课后,应能做到:

text
能写 JUnit 5 基础测试。
能使用 assertEquals、assertThrows 等断言。
能使用 BeforeEach 初始化测试对象。
能写参数化测试覆盖多组输入。
能用 Mockito 隔离外部依赖。
能区分单元测试和集成测试。
能识别脆弱测试并修复。
能用 AAA 结构组织测试。
能理解覆盖率的价值和局限。

单元测试的目标不是“为了覆盖率而写测试”,而是让你敢改代码、能快速发现回归,并把业务规则固定下来。


🎓 下一步

  • 第49课:日志框架 — SLF4J、Logback、日志级别、MDC