摘要: 本文是Java单元测试框架JUnit 5的完整入门指南,从零开始讲解如何在Maven项目中引入JUnit 5、编写第一个单元测试、掌握核心注解和断言方法,到参数化测试、测试覆盖率分析和最佳实践。通过计算器和用户服务等实战示例,帮助开发者建立自动化测试思维,提升代码质量和可维护性,为后续学习日志框架等高级主题打下坚实基础。
关键词: JUnit 5, 单元测试, Java测试框架, Maven项目, 测试覆盖率
在上一篇,我们学习了 Maven——这个帮你管理依赖、标准化构建流程的工具。现在你的项目有了规范的目录结构、有了自动化的构建流程,代码也能被干净地编译和打包了。
但你有没有想过一个问题:你写的代码,真的正确吗?
你可能说:“我当然试过了,我运行了 main 方法,看到输出没问题。”但是,手动测试有几个致命的弱点:
- 你只测了一个输入组合,其他边缘情况呢?
- 当你修改了代码,你怎么知道没有破坏之前已经工作的功能?
- 如果项目有 100 个类、500 个方法,你怎么可能每次都手动跑一遍?
- 在一个多人协作的项目中,你怎么保证你提交的代码不会“坑”到队友?
这就是自动化单元测试的用武之地。它让你用代码来测试代码,用自动化的方式验证每一段逻辑的正确性,并且可以在每次构建时自动运行,第一时间捕获回归问题。
今天我们要学习的是 Java 世界中最主流的单元测试框架——JUnit 5。
1. 什么是单元测试?—— 对代码的“体检”
单元测试(Unit Test) 是对软件中的最小可测试单元(通常是一个方法)进行检查和验证。它的目的是:
- 验证代码的行为是否符合预期。
- 捕获回归错误(修改代码后,原本正常的功能被破坏)。
- 作为文档,展示方法的正确用法和预期行为。
- 自动化,让测试在构建过程中自动运行,无需人工干预。
打个比方:你造了一辆汽车(整个程序),单元测试就像是对每个零件(每个方法)单独进行质量检测——刹车片是否达标、轮胎气压是否合格、发动机是否正常。而不是等到整车组装完成后再上路试驾,发现刹车失灵了才知道哪里有问题。
好的单元测试应该具备哪些特点?
| 特点 | 说明 |
|---|---|
| 自动化 | 无需人工干预,一键运行所有测试 |
| 可重复 | 每次运行的结果应该一致(没有随机性) |
| 独立 | 每个测试用例之间互不影响,可以单独运行 |
| 快速 | 单元测试应该毫秒级完成,不能依赖外部资源(数据库、网络等) |
| 可读 | 测试代码应该清晰表达“期望什么行为” |
| 全面 | 覆盖正常路径、边界条件、异常路径 |
在 Java 生态中,JUnit 是最老牌、最成熟的单元测试框架。JUnit 5 是当前的最新版本(2017 年发布),相比 JUnit 4 带来了很多改进。
2. 在 Maven 项目中引入 JUnit 5
首先,在 pom.xml 中添加 JUnit 5 的依赖:
<dependencies>
<!-- JUnit 5 核心 API -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<!-- JUnit 5 运行时引擎(用于执行测试) -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
</dependencies>
如果你用 IDEA 创建 Maven 项目,它通常会默认添加 JUnit 依赖。你也可以统一使用 junit-jupiter 聚合依赖:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
scope设置为test,表示这个依赖只在测试阶段使用,不会被包含在最终的生产 JAR 中。
3. 编写第一个单元测试 —— 一个简单的计算器
我们来写一个简单的 Calculator 类,然后为它编写单元测试。
// src/main/java/com/example/Calculator.java
package com.example;
public class Calculator {
public int add(int a, int b) {
return a + b;
}
public int subtract(int a, int b) {
return a - b;
}
public int multiply(int a, int b) {
return a * b;
}
public int divide(int a, int b) {
if (b == 0) {
throw new IllegalArgumentException("除数不能为0");
}
return a / b;
}
}
现在,在 src/test/java 目录下创建对应的测试类:
// src/test/java/com/example/CalculatorTest.java
package com.example;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
private final Calculator calculator = new Calculator();
@Test
void testAdd() {
int result = calculator.add(3, 5);
assertEquals(8, result, "3 + 5 应该等于 8");
}
@Test
void testSubtract() {
int result = calculator.subtract(10, 3);
assertEquals(7, result, "10 - 3 应该等于 7");
}
@Test
void testMultiply() {
int result = calculator.multiply(4, 6);
assertEquals(24, result, "4 * 6 应该等于 24");
}
@Test
void testDivide() {
int result = calculator.divide(10, 2);
assertEquals(5, result, "10 / 2 应该等于 5");
}
@Test
void testDivideByZero() {
// 测试抛出异常的情况
assertThrows(IllegalArgumentException.class,
() -> calculator.divide(10, 0),
"除数为0应该抛出 IllegalArgumentException");
}
}
运行测试:
- 在 IDEA 中,右键点击测试类 → Run ‘CalculatorTest’,或者点击类名左侧的绿色三角形。
- 在命令行中,执行
mvn test。
你会看到测试全部通过(绿色)。如果某个断言失败,JUnit 会清晰地告诉你哪个测试失败了、期望值是什么、实际值是什么。
4. JUnit 5 的核心注解
JUnit 5 提供了丰富的注解来控制测试的生命周期和行为。
| 注解 | 说明 |
|---|---|
@Test | 标记一个方法为测试方法 |
@BeforeEach | 在每个测试方法执行之前运行(用于初始化) |
@AfterEach | 在每个测试方法执行之后运行(用于清理) |
@BeforeAll | 在所有测试方法执行之前运行一次(必须是 static) |
@AfterAll | 在所有测试方法执行之后运行一次(必须是 static) |
@DisplayName | 为测试类或测试方法指定一个可读的名称 |
@Disabled | 禁用某个测试方法(或整个类),跳过了不执行 |
@Nested | 在测试类中创建嵌套的测试类,用于分组 |
@Tag | 给测试打标签,用于筛选执行 |
@Timeout | 设置超时时间,超时则测试失败 |
@RepeatedTest | 重复执行同一个测试指定次数 |
4.1 @BeforeEach 和 @AfterEach 示例
class LifecycleTest {
private List<String> list;
@BeforeEach
void setUp() {
list = new ArrayList<>();
System.out.println("准备测试数据...");
}
@AfterEach
void tearDown() {
list.clear();
System.out.println("清理测试数据...");
}
@Test
void testAdd() {
list.add("A");
assertEquals(1, list.size());
}
@Test
void testAddMultiple() {
list.add("A");
list.add("B");
assertEquals(2, list.size());
}
}
每个测试方法都会独立执行 setUp() → 测试方法 → tearDown(),互不干扰。
4.2 @DisplayName —— 让测试报告更易读
@DisplayName("计算器测试")
class CalculatorTest {
@Test
@DisplayName("加法测试:验证 3 + 5 = 8")
void testAdd() {
// ...
}
}
在 IDEA 和测试报告中,会显示 "加法测试:验证 3 + 5 = 8" 而不是 testAdd,更加直观。
4.3 @Disabled —— 临时跳过测试
@Disabled("这个测试还没写完,先跳过")
@Test
void testNotReadyYet() {
// 待实现的测试
}
被 @Disabled 标记的测试不会被执行,但会显示在报告中(标记为跳过)。
4.4 @Nested —— 分组组织测试
对于复杂的测试类,可以用 @Nested 把相关的测试组织在一起:
class CalculatorTest {
private Calculator calculator = new Calculator();
@Nested
@DisplayName("加法运算测试")
class AddTests {
@Test
void testPositiveNumbers() {
assertEquals(5, calculator.add(2, 3));
}
@Test
void testNegativeNumbers() {
assertEquals(-5, calculator.add(-2, -3));
}
}
@Nested
@DisplayName("除法运算测试")
class DivideTests {
@Test
void testNormalDivision() {
assertEquals(5, calculator.divide(10, 2));
}
@Test
void testDivisionByZero() {
assertThrows(IllegalArgumentException.class,
() -> calculator.divide(10, 0));
}
}
}
这样测试报告会清晰地按功能分组展示。
5. 断言 —— 验证期望结果
JUnit 5 提供了 Assertions 类,包含丰富的断言方法,用于验证实际结果是否与预期一致。
| 断言方法 | 说明 |
|---|---|
assertEquals(expected, actual) | 验证两个值是否相等 |
assertNotEquals(expected, actual) | 验证两个值不相等 |
assertTrue(condition) | 验证条件为 true |
assertFalse(condition) | 验证条件为 false |
assertNull(obj) | 验证对象为 null |
assertNotNull(obj) | 验证对象不为 null |
assertSame(expected, actual) | 验证两个引用指向同一个对象 |
assertNotSame(expected, actual) | 验证两个引用指向不同对象 |
assertArrayEquals(expectedArray, actualArray) | 验证两个数组相等 |
assertIterableEquals(expected, actual) | 验证两个 Iterable 相等 |
assertThrows(exceptionClass, executable) | 验证代码块抛出指定异常 |
assertDoesNotThrow(executable) | 验证代码块不抛出异常 |
assertTimeout(timeout, executable) | 验证代码块在指定时间内完成 |
fail(message) | 直接失败(用于标记未完成的测试) |
5.1 常见断言示例
import static org.junit.jupiter.api.Assertions.*;
class AssertionDemo {
@Test
void testAssertions() {
// 基本相等
assertEquals(10, 5 + 5);
// 带失败信息的字符串
assertEquals(10, 5 + 4, "5 + 4 应该等于 10"); // 会失败,显示错误消息
// 布尔值
assertTrue(5 > 3);
assertFalse(3 > 5);
// null
String s = null;
assertNull(s);
// 数组
int[] expected = {1, 2, 3};
int[] actual = {1, 2, 3};
assertArrayEquals(expected, actual);
// 异常
assertThrows(IllegalArgumentException.class, () -> {
Integer.parseInt("abc");
});
// 超时验证
assertTimeout(Duration.ofSeconds(1), () -> {
// 如果这里执行超过1秒,测试失败
Thread.sleep(500);
});
}
}
5.2 分组断言(assertAll)
当你想同时验证多个条件时,可以用 assertAll 将它们分组,这样即使第一个断言失败,后面的断言仍会执行。非常适合验证一个对象的多个属性:
import static org.junit.jupiter.api.Assertions.*;
@Test
void testPerson() {
Person person = new Person("张三", 25);
// 所有断言都会执行,收集所有失败
assertAll("person",
() -> assertEquals("张三", person.getName()),
() -> assertEquals(25, person.getAge()),
() -> assertNotNull(person.getEmail())
);
}
6. 参数化测试 —— 用多组数据测试同一个逻辑
很多时候,我们需要用多组不同的输入来测试同一个方法。比如测试 add 方法,你可能想验证 (2,3)→5、(0,0)→0、(-1,1)→0 等多组数据。
JUnit 5 的参数化测试让你可以方便地做这件事。
首先需要添加额外的依赖:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
然后使用 @ParameterizedTest 和对应的数据源注解:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
class ParameterizedCalculatorTest {
private final Calculator calculator = new Calculator();
// 使用 @ValueSource 提供单个参数
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5})
void testIsPositive(int number) {
assertTrue(number > 0);
}
// 使用 @CsvSource 提供多参数
@ParameterizedTest
@CsvSource({
"1, 2, 3",
"0, 0, 0",
"-1, 1, 0",
"10, -5, 5",
"-5, -3, -8"
})
void testAdd(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b));
}
// 使用 @MethodSource 提供更复杂的参数生成
@ParameterizedTest
@MethodSource("divisionTestData")
void testDivide(int a, int b, int expected) {
assertEquals(expected, calculator.divide(a, b));
}
static Stream<Arguments> divisionTestData() {
return Stream.of(
Arguments.of(10, 2, 5),
Arguments.of(9, 3, 3),
Arguments.of(100, 10, 10)
);
}
}
参数化测试让你的测试代码变得极其精简,用几行代码就覆盖了多种输入场景。
7. 测试覆盖率 —— 你的测试覆盖了多少代码?
测试覆盖率(Test Coverage) 衡量的是:你的测试代码执行了生产代码中的多少百分比。
常用的覆盖指标包括:
- 行覆盖率:测试执行了多少行代码?
- 分支覆盖率:测试覆盖了多少个
if-else分支? - 方法覆盖率:测试调用了多少方法?
高覆盖率不能保证程序没有 bug,但低覆盖率一定意味着你还有很多代码没有测试过。
在 IDEA 中,右键点击测试类 → Run ‘CalculatorTest’ with Coverage,IDEA 会运行测试并显示代码覆盖情况——绿色表示被执行到,红色表示没有执行到。
你可以在 pom.xml 中配置 jacoco-maven-plugin 来生成覆盖率报告:
<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 clean test jacoco:report,在 target/site/jacoco/index.html 查看详细的覆盖率报告。
8. 单元测试的最佳实践
✅ 好的做法
- 测试每个 public 方法:特别是包含业务逻辑的方法。
- 覆盖多种场景:正常路径、边界条件(0、null、空集合)、异常路径。
- 测试独立性:每个测试方法之间不应该有依赖关系,执行顺序应该无关紧要。
- 使用有意义的命名:测试方法名应该描述“在什么条件下,期望什么行为”,比如
shouldReturnTrueWhenAgeIsGreaterThan18()。 - 使用
@DisplayName:让测试报告易读。 - 保持测试快速:单元测试不应该依赖数据库、网络、文件系统。如果需要,可以用 Mock 工具(如 Mockito)模拟外部依赖。
- 在每次构建时运行测试:集成到 CI/CD 流水线中,第一时间发现回归问题。
❌ 避免的做法
- 不要在测试中写复杂逻辑:测试应该简单、直接,不应该包含
if、for等控制流。 - 不要测试私有方法:私有方法是实现细节,应该通过 public 方法间接测试。
- 不要在测试之间共享状态:使用
@BeforeEach重置状态,而不是在测试之间共享变量。 - 不要忽略测试失败:失败的测试应该立即修复,而不是注释掉或忽略。
- 不要只测试快乐路径:边缘条件和异常同样重要。
9. 综合示例 —— 完整的“用户服务”测试
我们来写一个更贴近实际的小例子:一个用户服务,包含注册和登录逻辑。
// UserService.java
package com.example.demo;
import java.util.*;
import java.util.regex.Pattern;
public class UserService {
private final Map<String, User> users = new HashMap<>();
private static final Pattern EMAIL_PATTERN =
Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$");
public User register(String email, String password, String name) {
if (email == null || email.isBlank()) {
throw new IllegalArgumentException("邮箱不能为空");
}
if (!EMAIL_PATTERN.matcher(email).matches()) {
throw new IllegalArgumentException("邮箱格式不正确");
}
if (password == null || password.length() < 6) {
throw new IllegalArgumentException("密码长度至少为6位");
}
if (users.containsKey(email)) {
throw new IllegalStateException("该邮箱已被注册");
}
User user = new User(email, password, name);
users.put(email, user);
return user;
}
public User login(String email, String password) {
User user = users.get(email);
if (user == null) {
throw new IllegalArgumentException("用户不存在");
}
if (!user.getPassword().equals(password)) {
throw new IllegalArgumentException("密码错误");
}
return user;
}
public Optional<User> findByEmail(String email) {
return Optional.ofNullable(users.get(email));
}
public static class User {
private final String email;
private final String password;
private final String name;
private final long createdAt;
public User(String email, String password, String name) {
this.email = email;
this.password = password;
this.name = name;
this.createdAt = System.currentTimeMillis();
}
public String getEmail() { return email; }
public String getPassword() { return password; }
public String getName() { return name; }
public long getCreatedAt() { return createdAt; }
}
}
测试代码:
// UserServiceTest.java
package com.example.demo;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("用户服务测试")
class UserServiceTest {
private UserService userService;
@BeforeEach
void setUp() {
userService = new UserService();
}
@Nested
@DisplayName("注册功能测试")
class RegisterTests {
@Test
@DisplayName("成功注册")
void shouldRegisterSuccessfully() {
UserService.User user = userService.register("test@example.com", "password123", "测试用户");
assertAll("注册成功",
() -> assertNotNull(user),
() -> assertEquals("test@example.com", user.getEmail()),
() -> assertEquals("测试用户", user.getName()),
() -> assertTrue(userService.findByEmail("test@example.com").isPresent())
);
}
@Test
@DisplayName("邮箱为空时抛出异常")
void shouldThrowWhenEmailIsBlank() {
assertThrows(IllegalArgumentException.class,
() -> userService.register("", "password123", "测试用户"),
"邮箱不能为空"
);
}
@Test
@DisplayName("邮箱格式不正确时抛出异常")
void shouldThrowWhenEmailIsInvalid() {
assertThrows(IllegalArgumentException.class,
() -> userService.register("invalid-email", "password123", "测试用户"),
"邮箱格式不正确"
);
}
@Test
@DisplayName("密码太短时抛出异常")
void shouldThrowWhenPasswordTooShort() {
assertThrows(IllegalArgumentException.class,
() -> userService.register("test@example.com", "123", "测试用户"),
"密码长度至少为6位"
);
}
@Test
@DisplayName("重复注册时抛出异常")
void shouldThrowWhenEmailAlreadyExists() {
userService.register("test@example.com", "password123", "测试用户");
assertThrows(IllegalStateException.class,
() -> userService.register("test@example.com", "newpass", "测试用户2"),
"该邮箱已被注册"
);
}
}
@Nested
@DisplayName("登录功能测试")
class LoginTests {
@Test
@DisplayName("成功登录")
void shouldLoginSuccessfully() {
userService.register("test@example.com", "password123", "测试用户");
UserService.User user = userService.login("test@example.com", "password123");
assertNotNull(user);
assertEquals("test@example.com", user.getEmail());
}
@Test
@DisplayName("用户不存在时登录失败")
void shouldThrowWhenUserNotFound() {
assertThrows(IllegalArgumentException.class,
() -> userService.login("unknown@example.com", "password123"),
"用户不存在"
);
}
@Test
@DisplayName("密码错误时登录失败")
void shouldThrowWhenPasswordWrong() {
userService.register("test@example.com", "password123", "测试用户");
assertThrows(IllegalArgumentException.class,
() -> userService.login("test@example.com", "wrongpassword"),
"密码错误"
);
}
}
}
这个测试覆盖了:
- 正常流程(成功注册、成功登录)
- 边界条件(空邮箱、无效邮箱、密码太短)
- 异常场景(重复注册、用户不存在、密码错误)
- 使用
@Nested组织测试分组 - 使用
@DisplayName让测试报告清晰可读 - 使用
assertAll验证多个条件
10. 今天的总结
今天我们学习了 JUnit 5 单元测试:
- 什么是单元测试:用代码验证代码,确保每个方法的行为符合预期。
- JUnit 5 核心注解:
@Test、@BeforeEach、@AfterEach、@BeforeAll、@AfterAll、@DisplayName、@Disabled、@Nested。 - 断言:
assertEquals、assertTrue、assertNull、assertThrows、assertAll等。 - 参数化测试:用
@ParameterizedTest+@ValueSource、@CsvSource、@MethodSource多组数据测试同一个逻辑。 - 测试覆盖率:用 JaCoCo 生成覆盖率报告,衡量测试的充分性。
- 最佳实践:测试独立性、覆盖多种场景、快速执行、不测试私有方法等。
单元测试是保证代码质量的“安全网”。有了它,你可以放心地重构代码、添加新功能,因为一旦破坏了任何已有功能,测试会立刻告诉你。
动手试试
-
为之前写的
StringUtil工具类编写单元测试,覆盖isBlank、isEmpty、reverse、capitalize等方法。 -
在
Calculator中添加一个pow(int a, int b)方法,然后用参数化测试验证多组输入(比如(2,3)→8、(3,2)→9、(10,0)→1)。 -
写一个
BankAccount类,包含deposit、withdraw、getBalance方法。编写测试覆盖:正常存钱、正常取钱、余额不足时取钱抛出异常、取负数金额抛出异常。 -
在你的
pom.xml中添加 JaCoCo 插件,运行mvn clean test jacoco:report,查看覆盖率报告,看看你的测试覆盖了哪些代码。 -
修改一个已有的测试,让它失败,然后观察 IDEA 和 Maven 的测试输出,看看错误信息是如何帮助你定位问题的。
有了单元测试的保护,你的代码质量就有了坚实的基础。下一篇文章,我们将学习日志框架——如何在程序中优雅地记录运行信息,方便问题排查和监控。
我们下一篇见。😃
📌 获取本系列示例代码请访问 GitCode。
1357



被折叠的 条评论
为什么被折叠?



