JUnit 单元测试实战
本文介绍 JUnit 单元测试在 Java 项目中的实战用法,覆盖测试入门、断言、常用注解、测试命名规范、AAA 结构、AI 辅助生成测试和 Maven 测试依赖范围等内容。文章帮助读者建立可维护的测试习惯,让业务代码在修改、重构和扩展时更可靠。
第一章 单元测试概述
1.1 什么是单元测试
单元测试就是针对程序中最小可测试单元编写的测试代码。在 Java 中,最常见的测试单元是一个类中的一个方法。
比如有一个加法方法,就写测试验证:
- 正数相加是否正确。
- 负数相加是否正确。
- 零参与计算是否正确。
- 异常输入是否按预期处理。
1.2 为什么要写单元测试
单元测试的价值主要有四点:
| 价值 | 说明 |
|---|---|
| 及时发现问题 | 修改代码后能快速知道哪里坏了 |
| 保护重构 | 重构前后测试都通过,说明行为大概率没变 |
| 沉淀业务规则 | 测试用例能表达方法应该如何工作 |
| 提升协作效率 | 别人改代码时能用测试验证影响范围 |
单元测试不是为了“多写点代码”,而是为了让项目在持续变化中仍然可靠。
1.3 JUnit 版本说明
当前 Java 项目常用 JUnit 5。它的常见包名是:
org.junit.jupiter.api.Test
org.junit.jupiter.api.Assertions
如果看到 org.junit.Test,通常是 JUnit 4 的写法。
第二章 单元测试入门
2.1 添加测试依赖
普通 Maven 项目可以引入 JUnit 5:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
Spring Boot 项目通常使用:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
它通常包含 JUnit、AssertJ、Mockito、Spring Test 等测试常用能力。
2.2 被测试类
public class Calculator {
/**
* 计算两个整数之和。
*
* @param a 第一个整数
* @param b 第二个整数
* @return 两个整数的和
*/
public int add(int a, int b) {
return a + b;
}
/**
* 计算两个整数相除的结果。
*
* @param a 被除数
* @param b 除数
* @return 相除结果
*/
public int divide(int a, int b) {
return a / b;
}
}
2.3 测试类
测试类放在 src/test/java 下:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
public class CalculatorTest {
@Test
public void shouldReturnSumWhenAddTwoNumbers() {
Calculator calculator = new Calculator();
assertEquals(5, calculator.add(2, 3));
}
@Test
public void shouldThrowExceptionWhenDivideByZero() {
Calculator calculator = new Calculator();
assertThrows(ArithmeticException.class, () -> calculator.divide(1, 0));
}
}
测试方法名建议表达清楚“什么场景,期望什么结果”。这样测试失败时,只看方法名就能大概知道业务哪里出问题。
2.4 运行测试
可以在 IDEA 中点击测试方法左侧的运行按钮,也可以使用 Maven 命令:
mvn test
如果测试通过,说明当前代码行为符合测试预期。如果测试失败,要先看失败信息,而不是急着改测试。
第三章 单元测试断言
3.1 断言是什么
断言就是“我期望结果应该是什么”。如果实际结果和期望不一致,测试就失败。
常用断言:
| 断言 | 作用 |
|---|---|
assertEquals | 判断两个值相等 |
assertNotEquals | 判断两个值不相等 |
assertTrue | 判断条件为真 |
assertFalse | 判断条件为假 |
assertNull | 判断对象为空 |
assertNotNull | 判断对象不为空 |
assertThrows | 判断会抛出指定异常 |
assertAll | 一组断言全部执行 |
3.2 断言示例
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class AssertionDemoTest {
@Test
public void shouldVerifyCommonAssertions() {
String username = "admin";
Integer age = 18;
assertNotNull(username);
assertEquals("admin", username);
assertTrue(age >= 18);
assertThrows(NumberFormatException.class, () -> Integer.parseInt("abc"));
}
}
3.3 多断言 assertAll
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class UserProfileTest {
@Test
public void shouldVerifyUserProfile() {
UserProfile profile = new UserProfile("zhangsan", 20);
assertAll(
() -> assertEquals("zhangsan", profile.getUsername()),
() -> assertEquals(20, profile.getAge())
);
}
}
assertAll 的好处是多个断言会一起执行,能一次看到多个失败点。
第四章 单元测试常见注解
4.1 常用注解表
| 注解 | 作用 |
|---|---|
@Test | 标记测试方法 |
@BeforeEach | 每个测试方法执行前运行 |
@AfterEach | 每个测试方法执行后运行 |
@BeforeAll | 所有测试方法执行前运行一次 |
@AfterAll | 所有测试方法执行后运行一次 |
@DisplayName | 给测试类或方法设置展示名称 |
@Disabled | 临时禁用测试 |
4.2 注解执行顺序
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
public class UserServiceTest {
private UserService userService;
@BeforeEach
public void setUp() {
userService = new UserService();
}
@AfterEach
public void tearDown() {
userService = null;
}
@Test
public void shouldCreateUserSuccessfully() {
boolean result = userService.createUser("lisi");
assert result;
}
}
@BeforeEach 适合准备测试对象、初始化测试数据;@AfterEach 适合清理资源。
4.3 BeforeAll 和 AfterAll
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
public class DatabaseConfigTest {
@BeforeAll
public static void initOnce() {
System.out.println("所有测试开始前执行一次");
}
@AfterAll
public static void closeOnce() {
System.out.println("所有测试结束后执行一次");
}
@Test
public void shouldLoadConfig() {
System.out.println("执行测试");
}
}
默认情况下,@BeforeAll 和 @AfterAll 方法需要是 static。
4.4 DisplayName 和 Disabled
@DisplayName 可以让测试报告更易读,@Disabled 可以临时禁用测试。
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
public class OrderServiceTest {
@Test
@DisplayName("库存充足时应创建订单成功")
public void shouldCreateOrderWhenStockEnough() {
}
@Test
@Disabled("等待优惠券规则确认后再补充")
public void shouldCalculateCouponDiscount() {
}
}
被禁用的测试要写清楚原因,避免长期遗忘。
第五章 企业开发单元测试规范
5.1 测试类命名
常见命名规范:
| 类型 | 示例 |
|---|---|
| 被测试类 | UserService |
| 测试类 | UserServiceTest |
| 测试目录 | src/test/java |
测试类包名通常和被测试类保持一致,方便快速定位。
5.2 测试方法命名
推荐方法名表达清楚业务语义:
@Test
public void shouldReturnUserWhenUserExists() {
}
@Test
public void shouldThrowExceptionWhenUsernameIsBlank() {
}
不要只写 test1、testAdd 这种信息量很少的名字。项目越大,清晰命名越重要。
5.3 AAA 测试结构
企业开发中常用 AAA 结构:
| 阶段 | 说明 |
|---|---|
| Arrange | 准备数据和对象 |
| Act | 调用被测试方法 |
| Assert | 验证结果 |
示例:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class PriceServiceTest {
@Test
public void shouldCalculateDiscountPrice() {
PriceService priceService = new PriceService();
int originalPrice = 100;
int actualPrice = priceService.discount(originalPrice, 20);
assertEquals(80, actualPrice);
}
}
5.4 单元测试应该测什么
重点测试:
- 有分支判断的方法。
- 有边界值的方法。
- 有金额、库存、状态流转等关键业务的方法。
- 曾经出现过 bug 的方法。
- 公共工具类和复杂转换逻辑。
不建议为了覆盖率机械测试 getter、setter,也不建议把单元测试写成依赖真实数据库、真实网络接口的大集成测试。
第六章 基于 AI 辅助生成单元测试
6.1 AI 生成测试的正确姿势
AI 可以帮助我们更快生成测试用例,但不能完全替代开发者判断。你应该把被测试方法、业务规则、边界条件告诉 AI,然后检查生成结果是否符合真实业务。
适合让 AI 辅助生成:
- 简单工具类测试。
- 参数校验测试。
- 边界值测试。
- 异常场景测试。
- 重复结构较多的测试。
6.2 示例:给业务方法生成测试思路
被测试方法:
public class PasswordService {
/**
* 判断密码是否符合基础规则。
*
* @param password 密码
* @return 符合规则返回 true,否则返回 false
*/
public boolean isValid(String password) {
if (password == null || password.length() < 8) {
return false;
}
// 关键业务逻辑:密码必须同时包含字母和数字,避免过于简单的纯数字或纯字母密码。
return password.matches(".*[A-Za-z].*") && password.matches(".*\\d.*");
}
}
可以要求 AI 生成这些测试场景:
| 场景 | 期望 |
|---|---|
null | false |
| 长度小于 8 | false |
| 只有字母 | false |
| 只有数字 | false |
| 同时包含字母和数字 | true |
生成后要人工检查:测试是否真的覆盖业务规则,断言是否写反,方法名是否清晰。
第七章 单元测试和 Maven 依赖范围
7.1 为什么测试依赖要用 test
测试框架只在测试阶段使用,不应该进入正式运行环境。所以 JUnit、Mockito 等依赖应该使用 test 范围。
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
这样做的好处:
- 正式包更干净。
- 减少无关依赖。
- 避免运行环境出现测试框架类。
7.2 Maven 生命周期中的测试阶段
Maven 执行 test、package、install 时都会涉及测试阶段。常用命令:
mvn test
mvn package
mvn install
如果测试失败,后续打包通常会中断。这是 Maven 在保护构建质量。
7.3 临时跳过测试
如果临时只想打包,不运行测试:
mvn package -DskipTests
如果连测试代码也不编译:
mvn package -Dmaven.test.skip=true
长期建议还是修复测试。测试失败不是 Maven 的问题,而是构建过程提醒你代码行为不符合预期。
第八章 常见问题
8.1 测试方法没有运行
常见原因:
- 没有添加
@Test。 - 导入了错误的 JUnit 包。
- 测试类没有放在
src/test/java。 - Maven 测试插件版本过旧。
JUnit 5 常用导入:
import org.junit.jupiter.api.Test;
8.2 断言写反
assertEquals(expected, actual) 中,通常第一个参数写期望值,第二个参数写实际结果。
assertEquals(100, actualPrice);
虽然写反也能比较,但失败信息会变得不好读。
8.3 单元测试依赖真实环境
单元测试应尽量少依赖真实数据库、真实网络、真实文件系统。依赖太多时,测试会变慢、变脆弱,也更难定位问题。
如果必须测试数据库或接口,通常更接近集成测试,需要单独规划。
总结
单元测试是保护业务代码的一层安全网。它最核心的价值不是追求覆盖率数字,而是把关键业务规则用代码固定下来。
你需要重点掌握:
- JUnit 5 的基本使用方式。
@Test、@BeforeEach、@AfterEach、@BeforeAll、@AfterAll等常见注解。assertEquals、assertTrue、assertThrows、assertAll等常见断言。- 测试类和测试方法的命名规范。
- AAA 测试结构。
- Maven 中测试依赖的
test范围。
当你能为关键业务方法稳定写出单元测试时,后续重构、改需求、排查 bug 都会轻松很多。