一、JUnit 简介:什么是 JUnit?为什么要用它?
1.1 核心定义
JUnit 是一个开源的、基于 Java 语言的单元测试框架,最初由 Erich Gamma (GoF 设计模式作者之一) 和 Kent Beck (极限编程创始人) 在 1997 年共同开发。作为 xUnit 测试框架家族中最重要的成员,JUnit 目前最新稳定版本为 JUnit 5(代号 Jupiter),于 2017 年发布。
JUnit 的核心作用是帮助开发者:
- 编写结构化、可维护的单元测试代码
- 自动化执行测试用例
- 生成详细的测试报告
- 通过断言机制验证代码行为是否符合预期
典型测试场景示例:
@Test
void testAddition() {
Calculator calc = new Calculator();
assertEquals(5, calc.add(2, 3)); // 验证2+3是否等于5
}
1.2 为什么选择 JUnit?
简单易用:
- 采用注解驱动(如 @Test、@BeforeEach)
- 提供丰富的断言方法(assertEquals、assertTrue 等)
- 基本测试用例仅需5行代码即可完成
IDE 无缝集成:
- IntelliJ IDEA:内置支持,可一键运行测试并显示彩色结果
- Eclipse:自带 JUnit 视图,支持测试覆盖率分析
- VS Code:通过插件提供完整测试支持
生态完善:
- 构建工具:
- Maven:通过 surefire 插件执行测试
- Gradle:内置 test 任务支持
- 框架整合:
- Spring Boot 提供 @SpringBootTest 注解
- Mockito 等模拟框架完美兼容
- 构建工具:
进阶功能:
- 参数化测试(@ParameterizedTest):
@ParameterizedTest @ValueSource(ints = {1, 3, 5}) void testOddNumbers(int number) { assertTrue(number % 2 != 0); }
- 测试套件(@Suite)
- 动态测试(@TestFactory)
- 条件测试(@EnabledOnOs)
- 参数化测试(@ParameterizedTest):
二、JUnit 5 环境搭建:从依赖引入到第一个测试用例
1. JUnit 5 架构组成
JUnit 5 采用了模块化设计,由三个核心模块组成:
JUnit Jupiter
- 包含 JUnit 5 的核心 API,如测试注解(
@Test
,@BeforeEach
等)和断言方法(assertEquals()
,assertTrue()
等) - 引入了新的编程模型和扩展模型
- 示例:
@ParameterizedTest
支持参数化测试,能更灵活地编写测试用例
- 包含 JUnit 5 的核心 API,如测试注解(
JUnit Vintage
- 提供向后兼容支持,允许运行 JUnit 3 和 JUnit 4 编写的测试用例
- 在迁移项目中尤其有用,可以逐步将旧测试迁移到 JUnit 5
- 需要额外依赖
junit-vintage-engine
JUnit Platform
- 提供统一的测试运行平台,作为测试执行的基础
- 支持在 IDE(如 IntelliJ IDEA, Eclipse)、构建工具(Maven, Gradle)中执行测试
- 允许通过命令行启动测试
- 提供测试发现和执行的API
2. 项目配置
2.1 Maven 依赖配置
完整的 Maven 配置示例如下:
<dependencies>
<!-- JUnit 5核心API -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.9.2</version>
<scope>test</scope>
</dependency>
<!-- JUnit 5测试引擎(运行时必需) -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.9.2</version>
<scope>test</scope>
</dependency>
<!-- 可选:参数化测试支持 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>5.9.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- 配置Maven Surefire插件以支持JUnit 5 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
<configuration>
<includes>
<include>**/*Test.java</include>
</includes>
</configuration>
</plugin>
</plugins>
</build>
3. 编写测试用例
3.1 业务类实现
/**
* 计算器工具类
* 提供基本的加减运算功能
*/
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 subtract(int a, int b) {
return a - b;
}
/**
* 除法运算
* @param dividend 被除数
* @param divisor 除数
* @return 除法结果
* @throws ArithmeticException 当除数为0时抛出
*/
public double divide(int dividend, int divisor) {
if (divisor == 0) {
throw new ArithmeticException("除数不能为0");
}
return (double) dividend / divisor;
}
}
3.2 测试类实现
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.*;
/**
* Calculator类的单元测试
*/
@DisplayName("计算器功能测试")
class CalculatorTest {
private Calculator calculator;
/**
* 在每个测试方法执行前初始化Calculator实例
*/
@BeforeEach
void setUp() {
calculator = new Calculator();
}
@Test
@DisplayName("加法功能测试 - 正常情况")
void testAdd() {
assertEquals(5, calculator.add(2, 3), "2+3应该等于5");
assertEquals(0, calculator.add(-1, 1), "-1+1应该等于0");
}
@Test
@DisplayName("减法功能测试")
void testSubtract() {
assertEquals(1, calculator.subtract(3, 2), "3-2应该等于1");
assertEquals(-5, calculator.subtract(0, 5), "0-5应该等于-5");
}
@ParameterizedTest
@CsvSource({
"6, 2, 3",
"10, 5, 2",
"-4, -8, 2"
})
@DisplayName("除法功能参数化测试")
void testDivide(int dividend, int divisor, double expected) {
assertEquals(expected, calculator.divide(dividend, divisor),
() -> dividend + "除以" + divisor + "应该等于" + expected);
}
@Test
@DisplayName("除法异常测试 - 除数为0")
void testDivideByZero() {
ArithmeticException exception = assertThrows(
ArithmeticException.class,
() -> calculator.divide(1, 0),
"除数为0时应抛出ArithmeticException"
);
assertEquals("除数不能为0", exception.getMessage());
}
}
4. 测试执行与报告
4.1 执行方式
IDE 执行:
- IntelliJ IDEA:右键测试类 → "Run 'CalculatorTest'"
- Eclipse:右键测试类 → "Run As" → "JUnit Test"
- 可以执行单个测试方法、整个测试类或整个测试包
Maven 命令行执行:
mvn test # 执行所有测试 mvn -Dtest=CalculatorTest test # 执行特定测试类 mvn -Dtest=CalculatorTest#testAdd test # 执行特定测试方法
Gradle 执行:
gradle test # 执行所有测试 gradle test --tests CalculatorTest # 执行特定测试类
4.2 测试结果分析
测试通过:
- IDE 中显示绿色标记
- 控制台输出类似:
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS
测试失败:
- IDE 中显示红色标记
- 控制台输出详细错误信息,包括:
- 失败的方法名
- 预期值和实际值差异
- 失败位置(代码行号)
- 自定义错误信息(如果有)
[ERROR] testAdd(CalculatorTest) Time elapsed: 0.012 s <<< FAILURE! org.opentest4j.AssertionFailedError: 2+3应该等于5 ==> Expected :5 Actual :6
4.3 高级功能
生命周期钩子:
@BeforeAll // 在测试类执行前运行一次 static void initAll() { /* 初始化代码 */ } @AfterEach // 在每个测试方法执行后运行 void tearDown() { /* 清理代码 */ } @AfterAll // 在测试类执行后运行一次 static void tearDownAll() { /* 最终清理 */ }
断言增强:
// 多条件断言 assertAll("多条件验证", () -> assertEquals(5, result), () -> assertTrue(result > 0) ); // 超时断言 assertTimeout(Duration.ofMillis(100), () -> { // 应在100ms内完成的操作 });
标签和过滤:
@Tag("fast") @Test void fastTest() { /* ... */ } @Tag("slow") @Test void slowTest() { /* ... */ }
三、JUnit 5 核心注解:掌握测试流程控制
JUnit 5 提供了一系列注解用于标记测试方法和控制测试生命周期。这些注解可以帮助开发者更有效地组织和执行测试用例。
核心生命周期注解
注解 | 作用 | 重要说明 |
---|---|---|
@Test |
标记一个方法为测试方法 | 方法必须为void 返回类型且无参数 |
@BeforeEach |
每个测试方法执行前运行 | 常用于初始化测试对象(如创建待测试类实例) |
@AfterEach |
每个测试方法执行后运行 | 常用于释放资源(如关闭文件句柄、数据库连接) |
@BeforeAll |
所有测试方法执行前运行一次 | 必须是静态方法,常用于加载全局配置(如数据库连接池初始化) |
@AfterAll |
所有测试方法执行后运行一次 | 必须是静态方法,常用于清理全局资源(如关闭数据库连接池) |
测试控制注解
注解 | 作用 | 使用场景示例 |
---|---|---|
@Disabled |
标记测试方法/类为"禁用",不参与测试执行 | 方法未完成时临时跳过测试;某些环境不支持的测试用例 |
@DisplayName |
为测试方法/类设置自定义显示名称 | 使用中文描述测试目的(如@DisplayName("用户登录失败场景测试") ) |
@Timeout |
设置测试方法超时时间 | 性能测试(如@Timeout(500) 表示500毫秒内未完成则测试失败) |
进阶用法示例
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("计算器功能测试套件")
class AdvancedCalculatorTest {
// 共享测试资源
private static DatabaseConnection dbConnection;
private Calculator calculator;
@BeforeAll
static void initAll() throws Exception {
dbConnection = new DatabaseConnection("jdbc:mysql://localhost/test");
dbConnection.connect();
System.out.println("数据库连接建立完成");
}
@BeforeEach
void init() {
calculator = new ScientificCalculator(dbConnection);
System.out.println("初始化科学计算器实例");
}
@Test
@DisplayName("复杂公式计算:(2^3 + √16) × 5")
@Timeout(1000)
void testComplexCalculation() {
double result = calculator.calculate("(pow(2,3)+sqrt(16))*5");
assertEquals(60.0, result, 0.001);
}
@Test
@Disabled("等待数据库函数修复")
void testDatabaseFunction() {
// 测试使用数据库函数的计算
}
@AfterEach
void cleanup() {
calculator.reset();
System.out.println("清理计算器状态");
}
@AfterAll
static void tearDownAll() {
dbConnection.close();
System.out.println("数据库连接已关闭");
}
}
测试执行顺序说明
- 首先执行
@BeforeAll
标记的方法(仅一次) - 对每个测试方法:
- 执行
@BeforeEach
方法 - 执行
@Test
方法 - 执行
@AfterEach
方法
- 执行
- 最后执行
@AfterAll
标记的方法(仅一次)
典型应用场景
数据库测试:
@BeforeAll
建立连接池@BeforeEach
开始事务@AfterEach
回滚事务@AfterAll
关闭连接池
性能测试:
@Test @Timeout(value = 100, unit = TimeUnit.MILLISECONDS) void shouldRespondIn100Milliseconds() { // 测试响应时间 }
条件测试:
@Test @EnabledOnOs(OS.LINUX) void linuxOnlyTest() { // 仅在Linux系统执行的测试 }
四、JUnit 5 断言方法:验证测试结果的核心
断言是单元测试的核心组成部分,用于判断 "实际结果" 是否与 "预期结果" 一致。JUnit 5 的 org.junit.jupiter.api.Assertions
类提供了丰富的断言方法,这些方法可以帮助开发者编写清晰、可读性强的测试代码。
4.1 基本断言(数值、字符串、布尔值)
基本断言是最常用的断言类型,用于验证基本数据类型、对象、布尔条件等。
详细方法说明
方法 | 功能描述 | 适用场景 |
---|---|---|
assertEquals(expected, actual) |
验证两个值相等 | 比较计算结果与预期值、对象相等性判断 |
assertNotEquals(expected, actual) |
验证两个值不相等 | 确保两个对象不相同 |
assertTrue(condition) |
验证条件为 true | 布尔表达式验证 |
assertFalse(condition) |
验证条件为 false | 布尔表达式验证 |
assertNull(object) |
验证对象为 null | 空值检查 |
assertNotNull(object) |
验证对象不为 null | 非空检查 |
扩展示例
@Test
void testExtendedBasicAssertions() {
// 精度控制的数值比较
assertEquals(0.333, 1.0/3.0, 0.001, "除法精度验证失败");
// 字符串比较
String expectedStr = "Hello";
String actualStr = "HELLO".toLowerCase();
assertEquals(expectedStr, actualStr, "字符串转换验证失败");
// 对象比较(需实现equals方法)
Person p1 = new Person("John", 30);
Person p2 = new Person("John", 30);
assertEquals(p1, p2, "对象相等性验证失败");
// 链式断言
String message = "Hello World";
assertAll("message属性验证",
() -> assertNotNull(message),
() -> assertTrue(message.startsWith("Hello")),
() -> assertTrue(message.endsWith("World"))
);
}
4.2 数组与集合断言
数组和集合断言专门用于验证数组或集合类型的数据结构。
详细方法说明
方法 | 功能描述 | 适用场景 |
---|---|---|
assertArrayEquals(expected, actual) |
验证两个数组内容相等 | 基本类型数组、对象数组比较 |
assertIterableEquals(expected, actual) |
验证两个集合内容相等 | List、Set等集合类型比较 |
扩展示例
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.HashSet;
@Test
void testExtendedArrayAndIterable() {
// 多维数组比较
int[][] expectedMatrix = {{1,2}, {3,4}};
int[][] actualMatrix = {{1,2}, {3,4}};
assertArrayEquals(expectedMatrix, actualMatrix);
// 集合顺序不敏感的比较
Set<String> expectedSet = new HashSet<>(Arrays.asList("a", "b", "c"));
Set<String> actualSet = new HashSet<>(Arrays.asList("c", "b", "a"));
assertEquals(expectedSet, actualSet);
// 使用自定义比较器
List<String> names = Arrays.asList("John", "Alice", "Bob");
assertTrue(names.containsAll(Arrays.asList("Alice", "Bob")),
"集合应包含指定元素");
// 集合大小验证
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
assertEquals(5, numbers.size(), "集合大小不正确");
}
4.3 异常断言
异常断言用于验证代码是否按预期抛出特定异常。
详细方法说明
方法 | 功能描述 | 适用场景 |
---|---|---|
assertThrows(异常类型.class, 可执行代码) |
验证是否抛出指定异常 | 边界条件、非法输入验证 |
扩展示例
// 业务方法:文件读取
public String readFile(String path) throws IOException {
if (path == null) {
throw new IllegalArgumentException("路径不能为null");
}
if (!new File(path).exists()) {
throw new FileNotFoundException("文件不存在");
}
return Files.readString(Paths.get(path));
}
// 测试异常抛出
@Test
void testFileOperations() {
FileProcessor processor = new FileProcessor();
// 验证空路径异常
IllegalArgumentException nullEx = assertThrows(
IllegalArgumentException.class,
() -> processor.readFile(null)
);
assertEquals("路径不能为null", nullEx.getMessage());
// 验证文件不存在异常
FileNotFoundException notFoundEx = assertThrows(
FileNotFoundException.class,
() -> processor.readFile("nonexistent.txt")
);
assertTrue(notFoundEx.getMessage().contains("不存在"));
// 验证无异常情况
assertDoesNotThrow(
() -> processor.readFile("existing.txt"),
"正常文件读取不应抛出异常"
);
}
4.4 超时断言
超时断言用于验证方法执行时间是否符合预期。
详细方法说明
方法 | 功能描述 | 适用场景 |
---|---|---|
assertTimeout(时间, 可执行代码) |
验证代码在指定时间内完成 | 性能测试、算法效率验证 |
assertTimeoutPreemptively(时间, 可执行代码) |
超时立即终止测试 | 严格时间限制的场景 |
扩展示例
@Test
void testExtendedTimeout() {
// 简单超时验证
assertTimeout(Duration.ofMillis(100), () -> {
// 模拟耗时操作
Thread.sleep(50);
});
// 带返回值的超时验证
String result = assertTimeout(Duration.ofSeconds(1), () -> {
Thread.sleep(500);
return "Done";
});
assertEquals("Done", result);
// 严格超时(超时立即终止)
assertTimeoutPreemptively(Duration.ofMillis(100), () -> {
// 如果耗时超过100ms会立即终止
Thread.sleep(50);
});
// 性能基准测试
long executionTime = assertTimeout(Duration.ofSeconds(2), () -> {
long start = System.currentTimeMillis();
// 执行待测方法
performComplexCalculation();
return System.currentTimeMillis() - start;
});
assertTrue(executionTime < 1000, "方法执行时间过长");
}
五、JUnit 5 进阶功能:提升测试效率
5.1 参数化测试(重复执行不同参数的测试)
参数化测试是JUnit 5中强大的功能之一,它允许开发者通过提供多组输入参数来重复执行同一个测试逻辑。相比传统测试方法只能固定使用一组参数进行测试,参数化测试显著提高了测试覆盖率和代码复用性。
实现原理与技术要点
参数化测试需要两个核心注解配合使用:
@ParameterizedTest
:标记方法为参数化测试方法- 参数源注解:提供具体参数值,如
@ValueSource
、@CsvSource
等
JUnit 5内置了多种参数源类型:
- 简单值:
@ValueSource
(适用于单参数) - CSV格式:
@CsvSource
(适用于多参数组合) - 方法提供:
@MethodSource
(通过方法返回参数流) - 枚举值:
@EnumSource
- 文件内容:
@CsvFileSource
(从CSV文件读取)
详细示例解析
示例1:单参数测试(@ValueSource)
测试计算器类的isPositive
方法,判断数字是否为正数:
// 业务方法实现
public class Calculator {
public boolean isPositive(int num) {
return num > 0;
}
}
// 测试类
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
private final Calculator calculator = new Calculator();
// 测试正数情况(预期结果true)
@ParameterizedTest(name = "测试正数 #{index} - 输入值: {arguments}")
@ValueSource(ints = {1, 2, 3, 100, Integer.MAX_VALUE})
void testIsPositive_True(int num) {
assertTrue(calculator.isPositive(num),
() -> "输入值 " + num + " 应被识别为正数");
}
// 测试非正数情况(预期结果false)
@ParameterizedTest(name = "测试非正数 #{index} - 输入值: {arguments}")
@ValueSource(ints = {-1, 0, -2, -100, Integer.MIN_VALUE})
void testIsPositive_False(int num) {
assertFalse(calculator.isPositive(num),
() -> "输入值 " + num + " 应被识别为非正数");
}
}
示例2:多参数组合测试(@CsvSource)
测试加法方法的多组输入输出组合:
@ParameterizedTest(name = "测试加法 {0} + {1} = {2}")
@CsvSource({
// 常规测试用例
"1, 2, 3",
"0, 0, 0",
"-1, 5, 4",
// 边界值测试用例
"2147483647, 1, -2147483648", // 整数溢出情况
"-2147483648, -1, 2147483647"
})
void testAddWithCsv(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b),
() -> String.format("%d + %d 应等于 %d", a, b, expected));
}
// 更复杂的多参数组合(使用@CsvFileSource)
@ParameterizedTest
@CsvFileSource(resources = "/test-data.csv", numLinesToSkip = 1)
void testAddWithCsvFile(int a, int b, int expected) {
// 从test-data.csv文件读取测试数据
}
5.2 测试套件(批量执行多个测试类)
测试套件(Suit)是组织和执行多个测试类的高级方式,特别适合大型项目中的测试管理。通过测试套件可以:
- 逻辑分组相关测试类
- 按特定顺序执行测试
- 过滤需要运行的测试集合
- 创建分层测试结构(套件嵌套套件)
完整实现步骤
步骤1:配置Maven依赖
<!-- 必须依赖 -->
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-suite-api</artifactId>
<version>1.9.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-suite-engine</artifactId>
<version>1.9.2</version>
<scope>test</scope>
</dependency>
<!-- 可选:支持其他注解 -->
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-suite-commons</artifactId>
<version>1.9.2</version>
<scope>test</scope>
</dependency>
步骤2:创建测试套件类
import org.junit.platform.suite.api.*;
// 标记为测试套件
@Suite
// 指定包含的测试类
@SelectClasses({
CalculatorTest.class,
StringUtilsTest.class,
DatabaseTest.class
})
// 可选:包含指定包下的所有测试类
@SelectPackages("com.example.tests")
// 可选:包含/排除特定标签的测试
@IncludeTags("fast")
@ExcludeTags("slow")
// 可选:设置执行顺序
@SuiteDisplayName("核心功能测试套件")
@Order(1)
public class CoreFunctionTestSuite {
// 套件类体为空,仅作为配置容器
}
高级套件配置
// 嵌套套件示例
@Suite
@SelectClasses({
UnitTestSuite.class,
IntegrationTestSuite.class
})
public class AllTestsSuite {}
// 动态过滤测试
@Suite
@SelectPackages("com.example")
@IncludeClassNamePatterns("^.*Test$")
@ExcludeClassNamePatterns("^.*SlowTest$")
public class FilteredTestSuite {}
5.3 动态测试(运行时生成测试用例)
动态测试(Dynamic Test)是JUnit 5引入的创新特性,它允许在运行时动态生成测试用例。与静态定义的测试方法不同,动态测试的用例可以在测试执行时根据各种条件(如外部数据源、算法结果等)即时生成。
核心组件
@TestFactory
:标记动态测试工厂方法DynamicTest
:表示单个动态测试用例DynamicContainer
:组织动态测试的分组容器
完整实现示例
基本动态测试示例
import org.junit.jupiter.api.*;
import java.util.stream.Stream;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
class DynamicCalculatorTest {
private final Calculator calculator = new Calculator();
// 简单动态测试工厂
@TestFactory
Stream<DynamicTest> dynamicTestsForAddition() {
// 准备测试数据
int[][] testCases = {
{1, 1, 2},
{0, 0, 0},
{-1, -1, -2},
{100, 200, 300}
};
// 生成动态测试流
return Arrays.stream(testCases)
.map(data -> dynamicTest(
data[0] + " + " + data[1] + " = " + data[2],
() -> assertEquals(data[2], calculator.add(data[0], data[1]))
));
}
}
高级应用场景
场景1:从外部文件加载测试数据
@TestFactory
Stream<DynamicTest> generateTestsFromFile() throws IOException {
// 读取测试数据文件
List<String> lines = Files.readAllLines(
Paths.get("src/test/resources/test-data.csv"));
return lines.stream()
.skip(1) // 跳过标题行
.map(line -> line.split(","))
.map(data -> dynamicTest(
"测试: " + data[0] + " + " + data[1],
() -> {
int a = Integer.parseInt(data[0].trim());
int b = Integer.parseInt(data[1].trim());
int expected = Integer.parseInt(data[2].trim());
assertEquals(expected, calculator.add(a, b));
}
));
}
场景2:组合静态和动态测试
@TestFactory
Collection<DynamicNode> mixedTests() {
return Arrays.asList(
// 静态描述的动态测试
dynamicTest("基础加法", () ->
assertEquals(2, calculator.add(1, 1))),
// 动态测试容器(分组)
DynamicContainer.dynamicContainer("高级运算",
Stream.of(
dynamicTest("大数相加", () ->
assertEquals(10000, calculator.add(5000, 5000))),
dynamicTest("负数相加", () ->
assertEquals(-10, calculator.add(-5, -5)))
)),
// 从方法生成的动态测试
generateEdgeCaseTests()
);
}
private List<DynamicTest> generateEdgeCaseTests() {
return Arrays.asList(
dynamicTest("MAX_VALUE + 1", () ->
assertEquals(Integer.MIN_VALUE,
calculator.add(Integer.MAX_VALUE, 1))),
dynamicTest("MIN_VALUE + (-1)", () ->
assertEquals(Integer.MAX_VALUE,
calculator.add(Integer.MIN_VALUE, -1)))
);
}
动态测试的生命周期
需要注意的是,动态测试与常规测试在生命周期上的区别:
- 动态测试工厂方法(
@TestFactory
)在测试类的生命周期中执行 - 每个动态测试用例(DynamicTest)作为独立测试执行
- 动态测试不支持
@BeforeEach
和@AfterEach
方法 - 需要通过工厂方法内部处理前置/后置逻辑
@TestFactory
Stream<DynamicTest> dynamicTestsWithSetup() {
// 共享资源(在工厂方法中初始化)
DatabaseTestUtil dbUtil = new DatabaseTestUtil();
dbUtil.initializeTestData();
return IntStream.range(0, 5)
.mapToObj(i -> dynamicTest("数据库测试 #" + i, () -> {
// 测试执行
assertTrue(dbUtil.testRecordExists(i));
// 清理(直接在测试中处理)
dbUtil.cleanupAfterTest(i);
}));
}
六、JUnit 与 Spring Boot 集成:实战场景
在 Spring Boot 项目中,JUnit 已被默认集成,只需引入spring-boot-starter-test依赖,即可同时获得 JUnit 5、Mockito(模拟依赖)等测试工具。
6.1 依赖引入(Spring Boot)
在Spring Boot项目中,要使用JUnit 5进行测试,需要在pom.xml中添加以下依赖配置:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<version>2.7.0</version> <!-- 根据实际Spring Boot版本调整 -->
<exclusions>
<!-- 排除JUnit 4依赖(如需兼容可保留) -->
<exclusion>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
</exclusion>
</exclusions>
</dependency>
这个依赖会包含:
- JUnit 5核心库
- Spring Test框架
- Mockito测试框架
- AssertJ断言库
- JSONassert库
- Hamcrest匹配器
6.2 测试 Spring Bean(Service 层示例)
业务代码结构
DAO层接口
// UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
/**
* 根据用户名查询用户
* @param username 用户名
* @return Optional包装的用户对象
*/
Optional<User> findByUsername(String username);
}
Service层实现
// UserService.java
@Service
@Transactional
public class UserService {
@Autowired
private UserRepository userRepository;
/**
* 根据用户名获取用户信息
* @param username 用户名
* @return 用户实体
* @throws RuntimeException 当用户不存在时抛出
*/
public User getUserByUsername(String username) {
return userRepository.findByUsername(username)
.orElseThrow(() -> new RuntimeException("用户不存在"));
}
}
测试类实现
基础测试类配置
// UserServiceTest.java
@ExtendWith(MockitoExtension.class) // 启用Mockito扩展
class UserServiceTest {
@Mock
private UserRepository userRepository; // 模拟DAO层
@InjectMocks
private UserService userService; // 注入模拟对象
// 测试用例...
}
测试场景1:正常查询用户
@Test
void testGetUserByUsername_Success() {
// 1. 准备测试数据
User mockUser = new User();
mockUser.setId(1L);
mockUser.setUsername("testUser");
mockUser.setPassword("123456");
// 2. 设置模拟行为
when(userRepository.findByUsername("testUser"))
.thenReturn(Optional.of(mockUser));
// 3. 执行测试方法
User result = userService.getUserByUsername("testUser");
// 4. 验证结果
assertNotNull(result);
assertEquals("testUser", result.getUsername());
assertEquals(1L, result.getId());
// 5. 验证交互行为
verify(userRepository, times(1))
.findByUsername("testUser");
verifyNoMoreInteractions(userRepository);
}
测试场景2:查询不存在的用户
@Test
void testGetUserByUsername_NotExists() {
// 1. 设置模拟行为
when(userRepository.findByUsername("nonExistentUser"))
.thenReturn(Optional.empty());
// 2. 验证异常抛出
RuntimeException exception = assertThrows(
RuntimeException.class,
() -> userService.getUserByUsername("nonExistentUser")
);
// 3. 验证异常信息
assertEquals("用户不存在", exception.getMessage());
// 4. 验证交互行为
verify(userRepository, times(1))
.findByUsername("nonExistentUser");
}
6.3 测试Controller层(API接口测试)
基础测试类配置
// UserControllerTest.java
@WebMvcTest(UserController.class) // 只加载Controller相关配置
@AutoConfigureMockMvc // 自动配置MockMvc
class UserControllerTest {
@Autowired
private MockMvc mockMvc; // 模拟HTTP请求
@MockBean
private UserService userService; // 模拟Service层
// 测试用例...
}
测试GET请求
@Test
void testGetUserByUsername() throws Exception {
// 1. 准备测试数据
User mockUser = new User();
mockUser.setId(1L);
mockUser.setUsername("testUser");
mockUser.setPassword("123456");
// 2. 设置模拟行为
when(userService.getUserByUsername("testUser"))
.thenReturn(mockUser);
// 3. 执行并验证HTTP请求
mockMvc.perform(get("/api/users")
.param("username", "testUser")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.username").value("testUser"))
.andExpect(jsonPath("$.password").doesNotExist()); // 敏感字段不应返回
// 4. 验证服务调用
verify(userService, times(1))
.getUserByUsername("testUser");
}
测试POST请求
@Test
void testCreateUser() throws Exception {
// 1. 准备测试数据
User newUser = new User();
newUser.setUsername("newUser");
newUser.setPassword("newPass");
User savedUser = new User();
savedUser.setId(2L);
savedUser.setUsername("newUser");
savedUser.setPassword("encodedPass");
// 2. 设置模拟行为
when(userService.createUser(any(User.class)))
.thenReturn(savedUser);
// 3. 执行并验证HTTP请求
mockMvc.perform(post("/api/users")
.content(new ObjectMapper().writeValueAsString(newUser))
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/users/2"))
.andExpect(jsonPath("$.id").value(2))
.andExpect(jsonPath("$.username").value("newUser"));
// 4. 验证服务调用
verify(userService, times(1))
.createUser(any(User.class));
}
测试异常处理
@Test
void testGetUser_NotFound() throws Exception {
// 1. 设置模拟行为
when(userService.getUserByUsername("unknown"))
.thenThrow(new RuntimeException("用户不存在"));
// 2. 执行并验证HTTP请求
mockMvc.perform(get("/api/users")
.param("username", "unknown"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value("用户不存在"));
}
七、JUnit 常见问题与最佳实践
7.1 常见问题解决
问题 1:JUnit 5 测试方法不执行(Maven 环境)
详细原因分析: Maven Surefire 插件是Maven项目默认使用的测试运行插件。在2.x版本中,该插件主要针对JUnit 4设计,无法自动识别JUnit 5的测试类结构(如@Test注解位于org.junit.jupiter.api包下)。这会导致Maven执行测试时跳过所有JUnit 5测试方法。
解决方案步骤:
- 在pom.xml中定位到
<build><plugins>
部分 - 添加或更新Surefire插件配置:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0</version>
<dependencies>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-surefire-provider</artifactId>
<version>1.6.2</version>
</dependency>
</dependencies>
</plugin>
3.执行mvn clean test
验证测试是否正常执行
典型报错示例:
[INFO] --- maven-surefire-plugin:2.22.2:test (default-test) ---
[INFO] No tests to run.
问题 2:@BeforeAll方法报错 "必须是静态方法"
技术背景: JUnit 5默认采用TestInstance.Lifecycle.PER_METHOD
策略,即每个测试方法执行前都会创建新的测试类实例。因此@BeforeAll需要在类加载时就执行,必须声明为static。
应用场景对比:
- 静态方法场景:适合简单的测试环境初始化,如数据库连接池创建
- 非静态方法场景(配合
@TestInstance
):@TestInstance(TestInstance.Lifecycle.PER_CLASS) class UserServiceTest { private UserRepository repository; // 可注入依赖 @BeforeAll void setupAll() { // 非静态方法 repository = new InMemoryUserRepository(); } }
常见错误示例:
org.junit.platform.commons.JUnitException: @BeforeAll method 'void com.example.Test.setup()' must be static.
问题 3:Mockito 模拟对象为 null
框架对比说明:
场景 | Spring Boot测试 | 纯JUnit测试 |
---|---|---|
注解 | @MockBean | @Mock |
初始化方式 | 自动由Spring上下文管理 | 需要手动初始化 |
典型配置 | @SpringBootTest | @ExtendWith(MockitoExtension.class) |
正确使用示例:
1.Spring Boot环境:
@SpringBootTest
class OrderServiceTest {
@MockBean
private PaymentGateway paymentGateway; // 自动注入模拟对象
@Test void test() {
when(paymentGateway.process(any())).thenReturn(true);
}
}
2.纯JUnit环境:
@ExtendWith(MockitoExtension.class)
class CalculatorTest {
@Mock
private Random random;
@Test void test() {
when(random.nextInt()).thenReturn(42);
}
}
7.2 最佳实践
1. 测试方法命名规范
命名模板: [测试目标]_[测试条件]_[预期结果]
实际案例:
deposit_negativeAmount_throwIllegalArgumentException
validatePassword_lengthLessThan8_returnFalse
processOrder_outOfStockItem_triggerNotification
工具支持:
- 使用
@DisplayName
注解提供更友好的测试显示名称:@Test @DisplayName("当用户名为空时应该抛出异常") void register_nullUsername_throwException() { // 测试代码 }
2. 单一测试原则
反模式示例:
@Test
void testAdd() {
// 测试正数
assertEquals(5, calculator.add(2, 3));
// 测试负数
assertEquals(-1, calculator.add(2, -3));
// 测试零值
assertEquals(0, calculator.add(0, 0));
}
改进方案:
@Test
void add_twoPositives_returnSum() {...}
@Test
void add_positiveAndNegative_returnDifference() {...}
@Test
void add_twoZeros_returnZero() {...}
3. 避免依赖外部环境
数据库测试方案:
# application-test.yml
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
database-platform: org.hibernate.dialect.H2Dialect
第三方服务Mock示例:
@Test
void getWeather_withMockApi() {
// 模拟天气API返回
when(weatherApi.getCurrent("Beijing"))
.thenReturn(new WeatherData(25, "Sunny"));
WeatherReport report = service.generateReport("Beijing");
assertTrue(report.contains("Sunny"));
}
4. 控制测试粒度
单元测试示例
@ExtendWith(MockitoExtension.class)
class UserServiceUnitTest {
@Mock
private UserRepository repository;
@InjectMocks
private UserService service;
@Test
void findById_existingUser() {
when(repository.findById(1L)).thenReturn(Optional.of(new User()));
assertNotNull(service.findUser(1L));
}
}
集成测试示例
@SpringBootTest
@AutoConfigureMockMvc
class UserControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
void getUsers_shouldReturn200() throws Exception {
mockMvc.perform(get("/api/users"))
.andExpect(status().isOk());
}
}
5. 定期执行测试
CI配置示例(GitHub Actions):
name: Java CI
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up JDK
uses: actions/setup-java@v1
with:
java-version: '11'
- name: Run tests
run: mvn test
开发流程建议:
- 本地修改代码 → 执行相关测试
- 提交前 → 执行模块所有测试
- 推送前 → 执行完整测试套件
- CI流水线 → 执行完整构建+测试+质量检查