JUnit 详解

一、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流水线 → 执行完整构建+测试+质量检查
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值