Appearance
第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 测试
| 特性 | 手写 if | JUnit | C 测试框架 |
|---|---|---|---|
| 断言 | if+print | assertEquals 等 | assert() |
| 运行 | 手动调 | 框架自动发现 | 手动注册 |
| 报告 | 手写 | 自动统计 | 简单 |
| 生命周期 | 无 | @BeforeEach 等 | setup/teardown |
对 C 程序员:JUnit 对应 C 的测试框架(如 Unity/Check),用断言验证。JUnit 注解驱动(@Test 自动发现),比 C 手动注册测试方便。思想相通:断言 + 自动运行 + 报告。
💡 九、最佳实践
- 方法_场景_预期命名,清楚表达测试意图。
- 每个测试独立,@BeforeEach 重置,无顺序依赖。
- 断言具体,assertEquals 期望值,不只测不抛异常。
- 测边界:空、零、负数、边界值。
- Mock 隔离外部依赖,单元测试不连真实环境。
- 参数化测试测多组数据,少重复。
- ** Arrange-Act-Assert**(准备-执行-断言)三段式结构。
- CI 跑测试,每次提交验证不回归。
📝 练习预告
完成 练习/Ex48_JUnit.java 中的 6 道题:
- 断言(assertEquals/assertTrue)
- 生命周期(@BeforeEach)
- 异常断言(assertThrows)
- 参数化测试思路
- Mock 思路(注释)
- 综合:手写测试运行器(纯 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));
}数据多、结构复杂时,MethodSource 比 CsvSource 更清晰。
📖 十二、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