杰瑞科技汇

Java JUnit单元测试如何高效编写?

目录

  1. 什么是单元测试?
  2. 为什么使用单元测试?
  3. JUnit 5 核心组件与入门
    • Maven/Gradle 依赖配置
    • 第一个 JUnit 5 测试
    • 核心生命周期注解:@BeforeEach, @AfterEach, @BeforeAll, @AfterAll
  4. 核心测试注解
    • @Test: 定义测试方法
    • @DisplayName: 为测试方法/类设置自定义名称
    • @Disabled: 禁用某个测试
  5. 核心断言方法
    • Assertions 类详解
    • 常见断言示例
  6. 高级测试特性
    • 参数化测试 (@ParameterizedTest)
    • 假设 (@Assumptions)
    • 异常测试 (assertThrows)
    • 测试套件 (@Suite)
  7. Mock 测试 (Mockito)
    • 什么是 Mock?
    • Mockito 简介与集成
    • Mock 对象、Stubbing 和 Verification
  8. 最佳实践

什么是单元测试?

单元测试是针对软件中最小可测试单元(在 Java 中通常是一个方法)进行验证的测试,它的目的是确保每个单元都能按照预期独立、正确地工作。

Java JUnit单元测试如何高效编写?-图1
(图片来源网络,侵删)

关键点:

  • 最小单元:通常是方法。
  • 独立性:每个测试应该独立运行,不依赖其他测试的结果或外部环境(如数据库、网络)。
  • 自动化:单元测试应该是自动化的,可以频繁地运行。

为什么使用单元测试?

  • 提高代码质量:在编写代码的同时思考如何测试,能促使你写出更清晰、更模块化、更易于维护的代码。
  • 快速反馈:当代码逻辑修改后,可以立即运行单元测试来验证改动是否破坏了原有功能,快速定位问题。
  • 文档作用:单元测试本身就是一份活的文档,它展示了代码的预期行为和使用方式。
  • 重构信心:有了全面的单元测试,你可以放心地对代码进行重构和优化,因为测试会告诉你是否引入了新的错误。
  • 简化集成:通过单元测试确保每个模块都正确工作,可以大大降低后期集成测试的难度和成本。

JUnit 5 核心组件与入门

JUnit 5 是目前的主流版本,它由三个模块组成:

  • JUnit Jupiter: JUnit 5 的核心引擎,包含新的编程模型和扩展模型(我们主要使用这个)。
  • JUnit Vintage: 提供对 JUnit 4 和 JUnit 3 的测试运行支持。
  • JUnit Platform: 测试框架的基础,用于启动测试框架和在 JVM 上发现测试。

Maven/Gradle 依赖配置

Maven (pom.xml)

<dependencies>
    <!-- JUnit 5 Jupiter 引擎 -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-engine</artifactId>
        <version>5.9.3</version> <!-- 使用最新版本 -->
        <scope>test</scope>
    </dependency>
</dependencies>

Gradle (build.gradle)

Java JUnit单元测试如何高效编写?-图2
(图片来源网络,侵删)
dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.9.3' // 使用最新版本
}
// 确保使用 JUnit 5 的测试任务
test {
    useJUnitPlatform()
}

第一个 JUnit 5 测试

假设我们有一个简单的 Calculator 类需要测试。

待测试代码 (src/main/java/Calculator.java)

public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
    public int subtract(int a, int b) {
        return a - b;
    }
}

测试代码 (src/test/java/CalculatorTest.java)

import org.junit.jupiter.api.Test; // 导入 Test 注解
import static org.junit.jupiter.api.Assertions.assertEquals; // 导入静态断言方法
public class CalculatorTest {
    // @Test 注解标记这是一个测试方法
    @Test
    void testAddition() {
        // 1. 准备测试数据
        Calculator calculator = new Calculator();
        int a = 5;
        int b = 3;
        int expected = 8;
        // 2. 执行测试方法
        int result = calculator.add(a, b);
        // 3. 验证结果是否符合预期
        assertEquals(expected, result, "5 + 3 应该等于 8");
    }
    @Test
    void testSubtraction() {
        Calculator calculator = new Calculator();
        int a = 10;
        int b = 4;
        int expected = 6;
        int result = calculator.subtract(a, b);
        assertEquals(expected, result, "10 - 4 应该等于 6");
    }
}

如何运行测试?

Java JUnit单元测试如何高效编写?-图3
(图片来源网络,侵删)
  • 在 IDE (如 IntelliJ IDEA 或 Eclipse) 中,右键点击 CalculatorTest.java 文件,选择 "Run 'CalculatorTest'"。
  • 在命令行,使用 Maven: mvn test 或 Gradle: gradle test

核心生命周期注解

JUnit 提供了生命周期方法,用于在测试执行的不同阶段执行代码。

  • @BeforeEach: 在每个 @Test 方法执行之前运行,常用于初始化测试资源。
  • @AfterEach: 在每个 @Test 方法执行之后运行,常用于清理测试资源。
  • @BeforeAll: 在所有 @Test 方法执行之前运行,必须是 static 方法,常用于执行一次性的、昂贵的设置(如连接数据库)。
  • @AfterAll: 在所有 @Test 方法执行之后运行,必须是 static 方法,常用于执行一次性的清理(如关闭数据库连接)。

示例:

import org.junit.jupiter.api.*;
class LifecycleExampleTest {
    @BeforeAll
    static void setupAll() {
        System.out.println("在所有测试之前执行一次 (初始化数据库连接)");
    }
    @AfterAll
    static void tearDownAll() {
        System.out.println("在所有测试之后执行一次 (关闭数据库连接)");
    }
    @BeforeEach
    void setup() {
        System.out.println("在每个测试方法之前执行 (创建测试对象)");
    }
    @AfterEach
    void tearDown() {
        System.out.println("在每个测试方法之后执行 (清理测试数据)");
    }
    @Test
    void testOne() {
        System.out.println("执行测试方法 testOne");
    }
    @Test
    void testTwo() {
        System.out.println("执行测试方法 testTwo");
    }
}

执行顺序输出:

在所有测试之前执行一次 (初始化数据库连接)
在每个测试方法之前执行 (创建测试对象)
执行测试方法 testOne
在每个测试方法之后执行 (清理测试数据)
在每个测试方法之前执行 (创建测试对象)
执行测试方法 testTwo
在每个测试方法之后执行 (清理测试数据)
在所有测试之后执行一次 (关闭数据库连接)

核心测试注解

  • @Test: 将一个方法标记为测试用例。

  • @DisplayName: 为测试类或测试方法提供一个更具描述性的名称,这个名称会显示在测试报告中。

    @Test
    @DisplayName("测试两个正数相加")
    void testAddPositiveNumbers() {
        // ...
    }
  • @Disabled (或 @Ignore in JUnit 4): 用于禁用一个测试,测试运行器会跳过它。

    @Test
    @Disabled("这个功能暂时不可用,等待修复")
    void testFeatureThatIsNotReady() {
        // ...
    }

核心断言方法

断言是测试的核心,用于验证实际结果是否与预期结果一致,JUnit 5 的断言都位于 org.junit.jupiter.api.Assertions 类中,并且是静态方法,可以直接导入使用。

常用断言方法:

方法 描述
assertEquals(expected, actual) 验证 actual 是否等于 expected
assertNotEquals(unexpected, actual) 验证 actual 是否不等于 unexpected
assertTrue(condition) 验证 condition 是否为 true
assertFalse(condition) 验证 condition 是否为 false
assertNull(object) 验证 object 是否为 null
assertNotNull(object) 验证 object 是否不为 null
assertSame(expected, actual) 验证 actualexpected 是否是同一个对象(引用相等)。
assertNotSame(unexpected, actual) 验证 actualunexpected 是否不是同一个对象。
assertThrows(expectedThrowable, executable) 验证 executable 的执行是否抛出了 expectedThrowable 类型的异常。返回抛出的异常对象,可用于进一步验证。
assertDoesNotThrow(executable) 验证 executable 的执行没有抛出任何异常。

示例:

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class AssertionsExampleTest {
    @Test
    void testAssertions() {
        String nullString = null;
        String notNullString = "Hello";
        // assertEquals
        assertEquals(4, 2 + 2, "算术错误");
        // assertTrue / assertFalse
        assertTrue(5 > 3);
        assertFalse(5 < 3);
        // assertNull / assertNotNull
        assertNull(nullString);
        assertNotNull(notNullString);
        // assertSame / assertNotSame
        Object obj1 = new Object();
        Object obj2 = obj1;
        Object obj3 = new Object();
        assertSame(obj1, obj2);
        assertNotSame(obj1, obj3);
        // assertThrows
        assertThrows(ArithmeticException.class, () -> {
            int result = 10 / 0;
        }, "除以零应该抛出 ArithmeticException");
        // assertThrows 并获取异常对象进行验证
        Exception exception = assertThrows(IllegalArgumentException.class, () -> {
            throw new IllegalArgumentException("参数不能为空");
        });
        assertEquals("参数不能为空", exception.getMessage());
    }
}

高级测试特性

参数化测试 (@ParameterizedTest)

使用 @ParameterizedTest 可以使用不同的参数多次运行同一个测试逻辑,避免代码重复。

需要配合 @ValueSource 等参数源注解使用。

示例:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertTrue;
@ParameterizedTest
@ValueSource(strings = { "racecar", "radar", "able was I ere I saw elba" })
void testPalindrome(String candidate) {
    assertTrue(isPalindrome(candidate));
}
// 一个简单的回文判断方法
private boolean isPalindrome(String str) {
    return str.equals(new StringBuilder(str).reverse().toString());
}

@ValueSource 提供基本类型的值数组,JUnit 5 还支持 @EnumSource, @MethodSource, @CsvSource 等更复杂的参数源。

假设 (@Assumptions)

假设允许你根据运行时的条件来动态地启用或禁用测试,如果假设不成立,测试会被跳过,而不是标记为失败。

示例:

import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Test;
class AssumptionsExampleTest {
    @Test
    void testOnlyOnCiServer() {
        // 假设我们只在 CI 服务器上运行此测试
        boolean isCiServer = "true".equals(System.getenv("CI"));
        Assumptions.assumeTrue(isCiServer, "此测试仅在 CI 服务器上运行");
        // 只有当 isCiServer 为 true 时,下面的代码才会被执行
        System.out.println("测试在 CI 服务器上成功运行");
    }
    @Test
    void testForSpecificEnvironment() {
        String environment = System.getProperty("os.name");
        Assumptions.assumeFalse(environment.contains("Windows"), "此测试不在 Windows 环境下运行");
        System.out.println("测试在非 Windows 环境下成功运行");
    }
}

异常测试 (assertThrows)

前面已经提到,这是验证代码是否按预期抛出异常的标准方式。

测试套件 (@Suite)

当你有多个测试类,并希望一次性运行它们时,可以使用测试套件。

步骤:

  1. 创建一个空的“套件”类。
  2. 使用 @SelectClasses 注解来指定要包含的测试类。

示例:

import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
// 测试类 A
class TestA {
    @Test @DisplayName("测试 A - 方法 1") void testA1() {}
    @Test @DisplayName("测试 A - 方法 2") void testA2() {}
}
// 测试类 B
class TestB {
    @Test @DisplayName("测试 B - 方法 1") void testB1() {}
    @Test @DisplayName("测试 B - 方法 2") void testB2() {}
}
// 套件类
import org.junit.jupiter.api.api.Suite;
import org.junit.jupiter.api.SuiteDisplayName;
@Suite
@SuiteDisplayName("我的自定义测试套件")
@SelectClasses({TestA.class, TestB.class})
class MyTestSuite {
    // 这个类是空的,它只是一个容器
}

Mock 测试 (Mockito)

在单元测试中,我们希望测试的类是孤立的,如果一个类依赖于其他类(如数据库访问、外部服务),我们不希望这些依赖在测试中实际运行,因为它们可能不稳定、缓慢或不可用,这时就需要 Mock

Mock 是一个模拟对象,你可以预先设定它的行为(调用某个方法时返回什么值),并在测试后验证它是否被正确调用。

Mockito 是 Java 中最流行的 Mock 框架之一。

Mockito 简介

Mockito 允许你:

  1. 模拟接口或类:创建一个模拟对象。
  2. Stubbing (存根):当调用模拟对象的某个方法时,让它返回一个预设的值或抛出预设的异常。
  3. Verification (验证):验证模拟对象的某个方法是否被调用,以及被调用的次数和参数。

Maven/Gradle 依赖配置

Maven (pom.xml)

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>5.3.1</version> <!-- 使用最新版本 -->
    <scope>test</scope>
</dependency>
<!-- 如果要支持 JUnit 5 的扩展,推荐添加这个 -->
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <version>5.3.1</version>
    <scope>test</scope>
</dependency>

示例:使用 Mockito

假设我们有一个 UserService,它依赖于一个 UserRepository 来获取用户数据。

待测试代码 (src/main/java/UserService.java)

public class UserService {
    private final UserRepository userRepository;
    // 通过构造函数注入依赖
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
    public String getUserNameById(Long id) {
        if (id == null || id <= 0) {
            throw new IllegalArgumentException("ID 必须是正数");
        }
        User user = userRepository.findById(id);
        if (user == null) {
            return "用户不存在";
        }
        return user.getName();
    }
}

依赖接口 (src/main/java/UserRepository.java)

public interface UserRepository {
    User findById(Long id);
}

实体类 (src/main/java/User.java)

public class User {
    private Long id;
    private String name;
    // ... constructor, getters, setters
}

测试代码 (src/test/java/UserServiceTest.java)

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
// 1. 使用 @ExtendWith 启用 Mockito 扩展
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
    // 2. 创建一个模拟对象,Mockito 会自动管理它的生命周期。
    @Mock
    private UserRepository userRepository;
    // 3. 创建一个真实对象,并将其字段标记为 @Mock 的对象会自动注入进去。
    @InjectMocks
    private UserService userService;
    @Test
    void testGetUserNameById_Success() {
        // 4. Stubbing (存根): 当调用 userRepository.findById(1L) 时,返回一个预设的 User 对象
        User mockUser = new User(1L, "张三");
        when(userRepository.findById(1L)).thenReturn(mockUser);
        // 5. 执行被测试的方法
        String result = userService.getUserNameById(1L);
        // 6. 验证结果
        assertEquals("张三", result);
        // 7. Verification (验证): 验证 userRepository.findById(1L) 确实被调用了一次
        verify(userRepository, times(1)).findById(1L);
    }
    @Test
    void testGetUserNameById_UserNotFound() {
        // Stubbing: 当调用 userRepository.findById(99L) 时,返回 null
        when(userRepository.findById(99L)).thenReturn(null);
        String result = userService.getUserNameById(99L);
        assertEquals("用户不存在", result);
        verify(userRepository).findById(99L);
    }
    @Test
    void testGetUserNameById_InvalidId() {
        // 验证当传入无效 ID 时,方法会抛出预期的异常
        assertThrows(IllegalArgumentException.class, () -> {
            userService.getUserNameById(-1L);
        });
        // 验证对于无效的 ID,userRepository.findById 根本没有被调用
        verify(userRepository, never()).findById(anyLong());
    }
}

最佳实践

  1. 测试名称要清晰:测试方法名应该清晰地描述被测试的功能和场景,testAdditionWithPositiveNumbers
  2. 一个测试只测一件事:每个测试方法应该只验证一个断言或一个核心功能点。
  3. 保持测试的独立性:避免测试之间有依赖关系,使用 @BeforeEach@AfterEach 来保证每个测试都在一个干净的环境中运行。
  4. 测试覆盖率不是唯一目标:不要为了追求 100% 的覆盖率而编写无意义的测试(如只测试 getter/setter),专注于测试核心业务逻辑和边界条件。
  5. 使用 @DisplayName:为测试和测试类起一个有意义的名字,让测试报告更易读。
  6. F.I.R.S.T 原则
    • Fast (快速):测试应该运行得很快。
    • Independent (独立):测试之间不应该相互依赖。
    • Repeatable (可重复):无论在什么环境下运行,结果都应该一致。
    • Self-Validating (自验证):测试应该通过或失败,不需要人工检查输出。
    • Timely (及时):测试代码应该与生产代码同时编写(TDD)或紧跟其后。

JUnit 5 是一个强大、灵活且现代化的测试框架,掌握它是每个 Java 开发者的必备技能,通过编写高质量的单元测试,你可以显著提升代码质量、加速开发流程,并为未来的代码重构提供坚实的保障。

从基础的 @TestassertEquals 开始,逐步探索参数化测试、Mockito 等高级特性,你的测试技能会越来越成熟,测试不是负担,而是提升开发效率和软件质量的强大工具。

分享:
扫描分享到社交APP
上一篇
下一篇