测试
“我改了点代码,应该没事吧?“——这句话是程序员最常被打脸的瞬间。改了 A,B 突然挂了;修了 bug,引入两个新 bug。靠”手动点一遍”测,慢、累、漏。自动化测试就是解药——把测试写成代码,每次改动都自动跑一遍,几分钟告诉你哪里坏了。
这一章我们看 Java 测试生态的核心——JUnit 5(测试框架)+ Mockito(模拟框架)+ JaCoCo(覆盖率),以及 TDD(测试驱动开发)思想。
一、为什么需要测试
不写测试的项目长这样:
- 改代码像拆炸弹 —— 不敢动,怕踩雷。
- bug 反复出现 —— 同一个 bug 修了又犯,没回归保护。
- 不敢重构 —— 代码烂了也不敢动,因为没人知道动了哪里会坏。
- 手动回归到吐 —— 上线前点一遍功能,一晚上搞不完。
写测试的好处:
- 回归保护 —— 改代码立刻知道有没有破坏旧功能。
- 设计反馈 —— 难测的代码往往设计差(耦合重),测试逼你解耦。
- 活的文档 —— 测试用例就是”这段代码该怎么用”的实例。
- 敢于重构 —— 有测试兜底,敢动烂代码。
- 减少手动测试 —— 一键跑几千个测试,省人力。
二、测试金字塔
测试金字塔(Test Pyramid)——层次从底到顶:
/\
/E2E\ 少而慢 (端到端, 启动整个应用)
/------\
/集成测试\ 中等 (多模块协作, 真实数据库)
/----------\
/ 单元测试 \ 多而快 (单方法, mock 依赖)
/--------------\
| 层次 | 范围 | 速度 | 数量 |
|---|---|---|---|
| 单元测试 | 单个类/方法 | 毫秒级 | 70%+ |
| 集成测试 | 多模块协作 | 秒级 | 20% |
| 端到端测试(E2E) | 整个系统 | 分钟级 | 10% |
塔基要厚——单元测试最多最快,每次提交都跑。塔尖要薄——E2E 慢且脆,跑关键路径即可。倒金字塔(E2E 多,单元少)是反模式——慢、难维护。
三、JUnit 5
JUnit 是 Java 的事实标准测试框架。JUnit 5(2017 年)由三个子模块组成:
- JUnit Platform —— 测试运行的基础平台。
- JUnit Jupiter —— 新的编程/扩展模型(
@Test等注解在这)。 - JUnit Vintage —— 兼容 JUnit 3/4 老测试。
3.1 Maven 集成
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
</plugin>
</plugins>
</build>
3.2 第一个测试
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
private Calculator calc;
@BeforeEach
void setUp() {
calc = new Calculator(); // 每个测试前都跑
}
@Test
@DisplayName("加法应该正确返回两数之和")
void shouldAddTwoNumbers() {
assertEquals(5, calc.add(2, 3));
assertEquals(0, calc.add(-1, 1));
assertEquals(-5, calc.add(-2, -3));
}
@Test
void shouldThrowOnDivideByZero() {
assertThrows(ArithmeticException.class, () -> calc.divide(1, 0));
}
}
class Calculator {
int add(int a, int b) { return a + b; }
int divide(int a, int b) {
if (b == 0) throw new ArithmeticException("/ by zero");
return a / b;
}
}
3.3 常用注解
| 注解 | 作用 |
|---|---|
@Test | 标记测试方法 |
@BeforeEach | 每个测试方法前执行(初始化) |
@AfterEach | 每个测试方法后执行(清理) |
@BeforeAll | 所有测试前执行一次(static,初始化重资源) |
@AfterAll | 所有测试后执行一次(static,清理重资源) |
@DisplayName | 自定义测试显示名 |
@Disabled | 跳过此测试 |
@Tag | 给测试打标签,可选择性跑 |
@Nested | 嵌套测试类(按场景分组) |
@ParameterizedTest | 参数化测试(同逻辑多组数据) |
@ValueSource | 参数化测试的数据源 |
3.4 参数化测试
同一逻辑测多组数据,避免重复代码:
@ParameterizedTest
@DisplayName("加法参数化测试")
@ValueSource(ints = {1, 5, 10, 100, -1})
void shouldAddCorrectly(int n) {
assertEquals(n + n, calc.add(n, n));
}
@ParameterizedTest(name = "{0} + {1} = {2}")
@CsvSource({
"1, 2, 3",
"0, 0, 0",
"-1, 1, 0",
"100, 200, 300"
})
void shouldAddCsv(int a, int b, int expected) {
assertEquals(expected, calc.add(a, b));
}
@ParameterizedTest 配合数据源(@ValueSource/@CsvSource/@MethodSource)能跑多组数据,名字用 {0}/{1} 占位符显示参数值。
3.5 断言
| 断言 | 用途 |
|---|---|
assertEquals(expected, actual) | 相等 |
assertNotEquals | 不等 |
assertTrue(cond) / assertFalse | 真/假 |
assertNull / assertNotNull | null/非null |
assertSame | 同一对象(==) |
assertArrayEquals | 数组相等 |
assertIterableEquals | Iterable 相等 |
assertThrows(Exception.class, () -> ...) | 抛异常 |
assertDoesNotThrow | 不抛异常 |
assertAll(...) | 批量断言,全部跑完才报错 |
assertTimeout | 超时 |
assertAll 是个亮点——普通断言遇到第一个失败就停,assertAll 把所有断言都跑完一起报告:
@Test
void shouldValidateUser() {
User u = new User("张三", 20);
assertAll("用户属性",
() -> assertEquals("张三", u.getName()),
() -> assertEquals(20, u.getAge()),
() -> assertNotNull(u.getId())
);
// 即使第一个失败, 后面也会跑, 一次看到所有问题
}
四、Mockito:模拟依赖
被测类往往依赖其他类(数据库、网络、时间)。单元测试不该真连数据库——慢、不稳定。Mockito 提供模拟对象——告诉它”调用 X 时返回 Y”,验证”调用 X 了没”。
4.1 mock 与 when
import static org.mockito.Mockito.*;
// 1. 创建 mock
UserRepository mockRepo = mock(UserRepository.class);
// 2. 桩: when X then Y
when(mockRepo.findById(1)).thenReturn(new User(1, "张三"));
when(mockRepo.findById(99)).thenThrow(new RuntimeException("不存在"));
// 3. 用
User u = mockRepo.findById(1); // 返回 张三
User u2 = mockRepo.findById(2); // 返回 null (没设置)
// 4. 验证: 确实调用了
verify(mockRepo).findById(1); // 至少一次
verify(mockRepo, times(2)).findById(anyInt()); // 调了 2 次
verify(mockRepo, never()).deleteById(anyInt()); // 从没调过
4.2 测试 Service(依赖 Repository)
class UserService {
private final UserRepository repo;
public UserService(UserRepository repo) { this.repo = repo; }
public String getUserName(int id) {
User u = repo.findById(id);
if (u == null) throw new IllegalArgumentException("用户不存在");
return u.getName();
}
}
class UserServiceTest {
@Test
void shouldReturnName() {
UserRepository mockRepo = mock(UserRepository.class);
when(mockRepo.findById(1)).thenReturn(new User(1, "张三"));
UserService service = new UserService(mockRepo);
assertEquals("张三", service.getUserName(1));
verify(mockRepo).findById(1); // 验证确实调了
}
@Test
void shouldThrowWhenUserNotFound() {
UserRepository mockRepo = mock(UserRepository.class);
when(mockRepo.findById(99)).thenReturn(null);
UserService service = new UserService(mockRepo);
assertThrows(IllegalArgumentException.class, () -> service.getUserName(99));
}
}
Mock 让你聚焦被测类——UserService 依赖 UserRepository,mock 掉 Repo,专心测 Service 的逻辑。这就是”单元测试”——只测一个单元。
五、测试覆盖率:JaCoCo
覆盖率(Coverage)——测试跑过多少行代码。JaCoCo(Java Code Coverage)是主流工具。
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<execution>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
</executions>
</plugin>
mvn test 后生成 target/site/jacoco/index.html,看每个类的覆盖率:
- 行覆盖率 —— 跑过的代码行比例。
- 分支覆盖率 —— if/else 各分支跑过的比例。
- 方法覆盖率 —— 调用的方法比例。
重要认知:高覆盖率 ≠ 高质量。100% 覆盖率也可能漏掉关键边界——比如只测了 add(2, 3) 而没测 add(Integer.MAX_VALUE, 1) 溢出。覆盖率是”下限”,不是”目标”。盲目追 100% 会让团队写无意义测试应付指标。
六、TDD:测试驱动开发
TDD(Test-Driven Development)——Kent Beck 提出的开发节奏:先写测试,再写实现。三步循环(红-绿-重构):
红: 写测试 -> 跑 -> 失败 (功能还没实现)
绿: 写最少代码让测试通过
重构: 改善代码结构, 测试仍要通过
例子——开发计算斐波那契数的方法:
// 1. 红: 先写测试
@Test
void shouldReturn1ForFib1() {
assertEquals(1, fib(1));
}
// 2. 绿: 最简实现 (硬编码)
static int fib(int n) { return 1; }
// 3. 加新测试
@Test
void shouldReturn2ForFib3() {
assertEquals(2, fib(3));
}
// 4. 修改实现
static int fib(int n) {
if (n <= 2) return 1;
return fib(n - 1) + fib(n - 2);
}
TDD 的好处:
- 小步前进 —— 每次只加一个测试,永远在”绿”的状态。
- 设计驱动 —— 先写测试逼你想”这方法该怎么用”,倒逼好 API 设计。
- 回归保护 —— 实现一开始就有测试。
但 TDD 不适合所有场景——UI、探索性原型、纯算法研究。它是个工具,不是教条。
七、实战:JUnit + Mockito 风格测试
由于 Piston 在线环境没有 JUnit 和 Mockito 依赖,下面用一个迷你测试框架模拟它们的 API——演示 @Test、断言、mock、verify 的核心思想。本地项目用真 JUnit + Mockito 时,代码风格完全一致。
观察重点:
@Test标记测试方法,@BeforeEach每个测试前跑——JUnit 的核心机制。assertEquals/assertThrows/assertAll——分别测相等、抛异常、批量断言。assertAll一次报告所有失败——比普通断言更友好。- mock 把
UserRepository替换成可控对象——Service 测试不依赖真实数据库。- 测试名表达意图——
shouldThrowOnDivByZero比test1可读得多。
八、测试最佳实践
8.1 命名
测试名要表达意图:
// 坏
@Test void test1() { ... }
@Test void testAdd() { ... }
// 好
@Test void shouldReturn5WhenAdd2And3() { ... }
@Test void shouldThrowWhenDivideByZero() { ... }
8.2 AAA 模式
每个测试三段式:
@Test
void shouldReturnSum() {
// Arrange (准备)
Calculator calc = new Calculator();
// Act (执行)
int result = calc.add(2, 3);
// Assert (断言)
assertEquals(5, result);
}
8.3 一个测试一件事
一个测试只断言一个行为——失败时一眼知道哪里坏了。多个相关断言用 assertAll。
8.4 测试要独立
测试之间不能依赖执行顺序——JUnit 不保证顺序。每个测试自己准备数据(@BeforeEach),不共享状态。
8.5 别 mock 被测对象
// 错: mock 了被测对象, 测了个寂寞
UserService mock = mock(UserService.class);
when(mock.getName(1)).thenReturn("张三");
assertEquals("张三", mock.getName(1)); // 没意义!
// 对: mock 被测对象的依赖
UserRepository mockRepo = mock(UserRepository.class);
when(mockRepo.findById(1)).thenReturn(new User("张三"));
UserService service = new UserService(mockRepo); // 被测对象用真的
九、本章小结
| 概念 | 核心要点 |
|---|---|
| 测试金字塔 | 单元多快、E2E 少慢 |
| JUnit 5 | @Test/@BeforeEach/@ParameterizedTest |
| 断言 | assertEquals/assertThrows/assertAll |
| Mockito | mock/when().thenReturn()/verify |
| 覆盖率 | 下限不是目标,JaCoCo 是主流工具 |
| TDD | 红-绿-重构,先写测试再写实现 |
| AAA | Arrange-Act-Assert 三段式 |
记忆口诀:
- 金字塔——单元多、集成中、E2E 少。
- JUnit 注解——
@Test测、@BeforeEach前、@ParameterizedTest参数化。 - 断言三剑客——
assertEquals相等、assertThrows异常、assertAll批量。 - Mockito 三步——
mock创建、when().thenReturn()桩、verify验证。 - 不 mock 被测对象——mock 它的依赖,测它本身。
- 覆盖率是底线——100% 不等于没 bug。
结语:从能跑到可测
这一章我们看了测试——从 JUnit 到 Mockito,从覆盖率到 TDD。会写代码不等于会写好代码,会测试才是工程师的分水岭。下一章看另一个工程化利器——日志框架,让你在生产环境能看清楚程序在干什么。