第66章

Java单元测试

掌握JUnit 5测试框架的使用方法与测试最佳实践

学习目标

JUnit 5 核心特性详解

JUnit 5是Java生态系统中最流行的单元测试框架,它提供了丰富的注解、断言方法和扩展机制。JUnit 5由三个子项目组成:JUnit Platform、JUnit Jupiter和JUnit Vintage,为现代Java应用提供了强大的测试支持。

测试注解

常用注解示例:
@Test
void testMethod() {
    // 测试方法
}

@BeforeEach
void setUp() {
    // 每个测试前执行
}
  • @Test - 标记测试方法
  • @BeforeEach - 每个测试前执行
  • @AfterEach - 每个测试后执行
  • @DisplayName - 自定义显示名称

断言方法

断言示例:
assertEquals(expected, actual);
assertTrue(condition);
assertThrows(Exception.class, () -> {
    // 可能抛出异常的代码
});
  • assertEquals - 相等断言
  • assertTrue/False - 布尔断言
  • assertThrows - 异常断言
  • assertAll - 组合断言

参数化测试

参数化测试示例:
@ParameterizedTest
@ValueSource(strings = {"hello", "world"})
void testWithParameter(String argument) {
    assertNotNull(argument);
}
  • @ValueSource - 简单值源
  • @CsvSource - CSV数据源
  • @MethodSource - 方法数据源
  • @EnumSource - 枚举数据源

JUnit 5 测试注解详解

JUnit 5提供了丰富的注解来控制测试的执行流程和行为,这些注解能够帮助我们构建结构化和可维护的测试代码。

注解 说明 使用场景
@Test 标记测试方法 基本测试方法
@BeforeEach 每个测试前执行 测试数据准备
@AfterEach 每个测试后执行 资源清理
@BeforeAll 所有测试前执行一次 类级别初始化
@AfterAll 所有测试后执行一次 类级别清理
@DisplayName 自定义测试显示名称 提高可读性
@Disabled 禁用测试 临时跳过测试
@ParameterizedTest 参数化测试 多组数据测试
@RepeatedTest 重复测试 压力测试
@Timeout 超时测试 性能测试

完整测试代码示例

以下是一个完整的JUnit 5测试示例,展示了各种测试注解、断言方法和测试技巧的使用。

Calculator.java - 被测试的类
/**
 * 计算器类 - 用于演示单元测试
 * 
 * 这个类提供基本的数学运算功能
 * 包括加法、减法、乘法、除法等操作
 */
public class Calculator {
    
    /**
     * 加法运算
     */
    public int add(int a, int b) {
        return a + b;
    }
    
    /**
     * 除法运算
     */
    public double divide(int a, int b) {
        if (b == 0) {
            throw new IllegalArgumentException("除数不能为0");
        }
        return (double) a / b;
    }
    
    /**
     * 计算阶乘
     */
    public long factorial(int n) {
        if (n < 0) {
            throw new IllegalArgumentException("阶乘的参数不能为负数");
        }
        if (n == 0 || n == 1) {
            return 1;
        }
        long result = 1;
        for (int i = 2; i <= n; i++) {
            result *= i;
        }
        return result;
    }
}
CalculatorTest.java - 测试类
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

/**
 * Calculator类的单元测试
 * 演示JUnit 5的各种测试注解和断言方法
 */
class CalculatorTest {
    
    private Calculator calculator;
    
    @BeforeAll
    static void setUpClass() {
        System.out.println("开始执行Calculator测试类");
    }
    
    @BeforeEach
    void setUp() {
        calculator = new Calculator();
        System.out.println("创建Calculator实例");
    }
    
    @Test
    @DisplayName("测试加法运算")
    void testAdd() {
        // 测试正数相加
        assertEquals(5, calculator.add(2, 3));
        assertEquals(0, calculator.add(-2, 2));
        assertEquals(-5, calculator.add(-2, -3));
        
        // 测试边界值
        assertEquals(1, calculator.add(0, 1));
        assertEquals(0, calculator.add(0, 0));
    }
    
    @Test
    @DisplayName("测试除法运算")
    void testDivide() {
        assertEquals(2.0, calculator.divide(6, 3), 0.001);
        assertEquals(2.5, calculator.divide(5, 2), 0.001);
        assertEquals(-2.0, calculator.divide(-6, 3), 0.001);
    }
    
    @Test
    @DisplayName("测试除零异常")
    void testDivideByZero() {
        IllegalArgumentException exception = assertThrows(
            IllegalArgumentException.class,
            () -> calculator.divide(5, 0)
        );
        assertEquals("除数不能为0", exception.getMessage());
    }
    
    @ParameterizedTest
    @DisplayName("参数化测试加法")
    @CsvSource({
        "1, 2, 3",
        "0, 0, 0",
        "-1, 1, 0",
        "10, -5, 5"
    })
    void testAddParameterized(int a, int b, int expected) {
        assertEquals(expected, calculator.add(a, b));
    }
    
    @RepeatedTest(5)
    @DisplayName("重复测试加法")
    void testAddRepeated() {
        assertEquals(4, calculator.add(2, 2));
    }
    
    @Test
    @Timeout(1)
    @DisplayName("超时测试")
    void testTimeout() {
        assertEquals(100, calculator.add(50, 50));
    }
    
    @Nested
    @DisplayName("数学运算测试组")
    class MathOperationsTest {
        
        @Test
        @DisplayName("组合断言测试")
        void testCombinedAssertions() {
            assertAll("基本运算",
                () -> assertEquals(5, calculator.add(2, 3)),
                () -> assertEquals(6, calculator.multiply(2, 3)),
                () -> assertEquals(2.0, calculator.divide(6, 3))
            );
        }
    }
}
💻 查看完整代码 - 在线IDE体验

单元测试最佳实践

测试编写原则

好的测试实践

@Test
@DisplayName("当输入两个正数时应该返回正确的和")
void shouldReturnSumWhenAddingTwoPositiveNumbers() {
    // Arrange - 准备测试数据
    Calculator calculator = new Calculator();
    
    // Act - 执行被测试的方法
    int result = calculator.add(2, 3);
    
    // Assert - 验证结果
    assertEquals(5, result);
}
  • 测试方法命名清晰描述性
  • 遵循AAA模式(Arrange-Act-Assert)
  • 测试边界条件和异常情况
  • 使用@DisplayName提高可读性
  • 每个测试只验证一个功能点

应该避免的实践

@Test
void test1() {
    // 测试多个不相关的功能
    assertEquals(5, calculator.add(2, 3));
    assertEquals(6, calculator.multiply(2, 3));
    assertEquals("hello", stringUtils.reverse("olleh"));
}
  • 测试方法名称不够描述性
  • 一个测试方法测试多个功能
  • 测试之间存在依赖关系
  • 忽略边界条件和异常情况
  • 测试代码过于复杂

测试类型和策略

常用测试类型

  • 基本功能测试:验证方法的基本功能是否正确
  • 边界值测试:测试输入参数的边界情况
  • 异常测试:验证异常情况的处理是否正确
  • 参数化测试:使用多组数据验证同一功能
  • 性能测试:验证方法的执行时间是否符合要求

测试编写注意事项

  • 测试应该独立运行,不依赖其他测试的结果
  • 测试数据应该在测试方法内部准备,避免全局状态
  • 使用有意义的断言消息,便于问题定位
  • 及时清理测试过程中创建的资源
  • 保持测试代码的简洁性和可读性

测试覆盖率目标

  • 代码覆盖率应该达到80%以上
  • 重点关注核心业务逻辑的测试覆盖
  • 确保所有公共方法都有对应的测试
  • 异常分支和边界条件也要有相应测试
  • 使用JaCoCo等工具监控测试覆盖率

本章小结