JUnit 详解

发布于:2025-09-05 ⋅ 阅读:(23) ⋅ 点赞:(0)

一、JUnit 简介:什么是 JUnit?为什么要用它?

1.1 核心定义

JUnit 是一个开源的、基于 Java 语言的单元测试框架,最初由 Erich Gamma (GoF 设计模式作者之一) 和 Kent Beck (极限编程创始人) 在 1997 年共同开发。作为 xUnit 测试框架家族中最重要的成员,JUnit 目前最新稳定版本为 JUnit 5(代号 Jupiter),于 2017 年发布。

JUnit 的核心作用是帮助开发者:

  1. 编写结构化、可维护的单元测试代码
  2. 自动化执行测试用例
  3. 生成详细的测试报告
  4. 通过断言机制验证代码行为是否符合预期

典型测试场景示例:

@Test
void testAddition() {
    Calculator calc = new Calculator();
    assertEquals(5, calc.add(2, 3));  // 验证2+3是否等于5
}

1.2 为什么选择 JUnit?

  1. 简单易用

    • 采用注解驱动(如 @Test、@BeforeEach)
    • 提供丰富的断言方法(assertEquals、assertTrue 等)
    • 基本测试用例仅需5行代码即可完成
  2. IDE 无缝集成

    • IntelliJ IDEA:内置支持,可一键运行测试并显示彩色结果
    • Eclipse:自带 JUnit 视图,支持测试覆盖率分析
    • VS Code:通过插件提供完整测试支持
  3. 生态完善

    • 构建工具:
      • Maven:通过 surefire 插件执行测试
      • Gradle:内置 test 任务支持
    • 框架整合:
      • Spring Boot 提供 @SpringBootTest 注解
      • Mockito 等模拟框架完美兼容
  4. 进阶功能

    • 参数化测试(@ParameterizedTest):
      @ParameterizedTest
      @ValueSource(ints = {1, 3, 5})
      void testOddNumbers(int number) {
         assertTrue(number % 2 != 0);
      }
      

    • 测试套件(@Suite)
    • 动态测试(@TestFactory)
    • 条件测试(@EnabledOnOs)

二、JUnit 5 环境搭建:从依赖引入到第一个测试用例

1. JUnit 5 架构组成

JUnit 5 采用了模块化设计,由三个核心模块组成:

  1. JUnit Jupiter

    • 包含 JUnit 5 的核心 API,如测试注解(@Test, @BeforeEach等)和断言方法(assertEquals(), assertTrue()等)
    • 引入了新的编程模型和扩展模型
    • 示例:@ParameterizedTest支持参数化测试,能更灵活地编写测试用例
  2. JUnit Vintage

    • 提供向后兼容支持,允许运行 JUnit 3 和 JUnit 4 编写的测试用例
    • 在迁移项目中尤其有用,可以逐步将旧测试迁移到 JUnit 5
    • 需要额外依赖junit-vintage-engine
  3. 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 执行方式

  1. IDE 执行

    • IntelliJ IDEA:右键测试类 → "Run 'CalculatorTest'"
    • Eclipse:右键测试类 → "Run As" → "JUnit Test"
    • 可以执行单个测试方法、整个测试类或整个测试包
  2. Maven 命令行执行

    mvn test  # 执行所有测试
    mvn -Dtest=CalculatorTest test  # 执行特定测试类
    mvn -Dtest=CalculatorTest#testAdd test  # 执行特定测试方法
    

  3. 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 高级功能

  1. 生命周期钩子

    @BeforeAll  // 在测试类执行前运行一次
    static void initAll() { /* 初始化代码 */ }
    
    @AfterEach  // 在每个测试方法执行后运行
    void tearDown() { /* 清理代码 */ }
    
    @AfterAll  // 在测试类执行后运行一次
    static void tearDownAll() { /* 最终清理 */ }
    

  2. 断言增强

    // 多条件断言
    assertAll("多条件验证",
        () -> assertEquals(5, result),
        () -> assertTrue(result > 0)
    );
    
    // 超时断言
    assertTimeout(Duration.ofMillis(100), () -> {
        // 应在100ms内完成的操作
    });
    

  3. 标签和过滤

    @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("数据库连接已关闭");
    }
}

测试执行顺序说明

  1. 首先执行@BeforeAll标记的方法(仅一次)
  2. 对每个测试方法:
    • 执行@BeforeEach方法
    • 执行@Test方法
    • 执行@AfterEach方法
  3. 最后执行@AfterAll标记的方法(仅一次)

典型应用场景

  1. 数据库测试

    • @BeforeAll建立连接池
    • @BeforeEach开始事务
    • @AfterEach回滚事务
    • @AfterAll关闭连接池
  2. 性能测试

    @Test
    @Timeout(value = 100, unit = TimeUnit.MILLISECONDS)
    void shouldRespondIn100Milliseconds() {
        // 测试响应时间
    }
    

  3. 条件测试

    @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中强大的功能之一,它允许开发者通过提供多组输入参数来重复执行同一个测试逻辑。相比传统测试方法只能固定使用一组参数进行测试,参数化测试显著提高了测试覆盖率和代码复用性。

实现原理与技术要点

参数化测试需要两个核心注解配合使用:

  1. @ParameterizedTest:标记方法为参数化测试方法
  2. 参数源注解:提供具体参数值,如@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. 逻辑分组相关测试类
  2. 按特定顺序执行测试
  3. 过滤需要运行的测试集合
  4. 创建分层测试结构(套件嵌套套件)

完整实现步骤

步骤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引入的创新特性,它允许在运行时动态生成测试用例。与静态定义的测试方法不同,动态测试的用例可以在测试执行时根据各种条件(如外部数据源、算法结果等)即时生成。

核心组件

  1. @TestFactory:标记动态测试工厂方法
  2. DynamicTest:表示单个动态测试用例
  3. 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)))
    );
}

动态测试的生命周期

需要注意的是,动态测试与常规测试在生命周期上的区别:

  1. 动态测试工厂方法(@TestFactory)在测试类的生命周期中执行
  2. 每个动态测试用例(DynamicTest)作为独立测试执行
  3. 动态测试不支持@BeforeEach@AfterEach方法
  4. 需要通过工厂方法内部处理前置/后置逻辑
@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测试方法。

解决方案步骤

  1. 在pom.xml中定位到<build><plugins>部分
  2. 添加或更新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

开发流程建议

  1. 本地修改代码 → 执行相关测试
  2. 提交前 → 执行模块所有测试
  3. 推送前 → 执行完整测试套件
  4. CI流水线 → 执行完整构建+测试+质量检查

网站公告

今日签到

点亮在社区的每一天
去签到