1. 项目概述:为什么我们需要JUnit?

在Java开发的日常里,你肯定遇到过这样的场景:修改了一个看似无关紧要的工具类方法,结果上线后,整个订单模块的结算逻辑崩了。或者,你接手了一个祖传代码,想重构某个核心算法,但心里完全没底,生怕一动就引发连锁反应。这种时候,如果有一套可靠的“安全网”,能让你在每次修改后快速验证核心逻辑是否依然正确,那该多好。

JUnit就是这个“安全网”。它不是一个庞大的、需要复杂配置的测试平台,而是一个轻量级、专注的单元测试框架。所谓单元测试,就是针对软件中的最小可测试单元(在Java里通常是一个方法)进行检查和验证。JUnit让你能用Java代码来写测试代码,然后用它来运行这些测试,并给出清晰的结果:哪些通过了,哪些失败了,失败的原因是什么。

我见过太多项目,初期为了赶进度完全不做单元测试,后期维护成本呈指数级增长。每次改动都像在走钢丝,全凭开发者的记忆和“感觉”。而引入JUnit,就像是给代码上了保险。它不仅仅是测试,更是一种设计思想的体现——迫使你将代码写得更加模块化、可测试,因为难以测试的代码,往往也意味着高耦合和低内聚。接下来,我们就从零开始,快速上手这个Java开发者必备的工具。

2. JUnit框架快速入门:5分钟写出你的第一个测试

理论说再多,不如动手跑一遍。我们抛开所有复杂的配置和概念,用最快的方式感受一下JUnit的威力。假设我们有一个简单的计算器类 Calculator

2.1 环境准备与依赖引入

现在主流的Java项目都使用Maven或Gradle管理依赖。这里以Maven为例,添加JUnit依赖非常简单。JUnit 5(又称JUnit Jupiter)是目前的主流版本,它由多个模块组成,我们通常只需要引入 junit-jupiter 这个聚合依赖。

在你的 pom.xml 文件中,找到 <dependencies> 部分,添加如下内容:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.0</version> <!-- 请使用最新稳定版本 -->
    <scope>test</scope>
</dependency>

注意 <scope>test</scope> ,这表示这个依赖只在运行测试时生效,不会打包到最终的制品(如JAR包)中,这是标准做法。

提示:如果你在IDE(如IntelliJ IDEA或Eclipse)中创建项目时选择了Maven模板,通常JUnit依赖已经被自动添加了。你可以检查一下,避免重复。

添加依赖后,IDE通常会自动下载相关的库文件。接下来,我们需要遵循Maven的项目结构标准:生产代码(即要被测试的代码)放在 src/main/java 目录下,而测试代码则放在 src/test/java 目录下。这两个目录的结构(包名)应该一一对应,这样既清晰又符合惯例。

2.2 创建被测试类与测试类

首先,在 src/main/java 下创建我们的计算器类:

package com.example.demo;

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

    public int subtract(int a, int b) {
        return a - b;
    }

    public int multiply(int a, int b) {
        return a * b;
    }

    public int divide(int a, int b) {
        if (b == 0) {
            throw new IllegalArgumentException("除数不能为零");
        }
        return a / b;
    }
}

然后,在 src/test/java 下创建对应的测试类。测试类的命名通常是在被测试类名后加上 Test ,这是一种广泛接受的约定。

package com.example.demo;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class CalculatorTest {

    @Test
    void testAdd() {
        Calculator calculator = new Calculator();
        int result = calculator.add(2, 3);
        assertEquals(5, result, "2 + 3 应该等于 5");
    }

    @Test
    void testSubtract() {
        Calculator calculator = new Calculator();
        int result = calculator.subtract(5, 3);
        assertEquals(2, result);
    }
}

我们来拆解一下这段测试代码:

  1. 导入 import org.junit.jupiter.api.Test; 引入了JUnit 5的核心注解 @Test ,用于标记一个方法是一个测试方法。
  2. 静态导入 import static org.junit.jupiter.api.Assertions.*; 静态导入了 Assertions 类中的所有静态断言方法。这是为了在写断言时更简洁,可以直接写 assertEquals(...) 而不是 Assertions.assertEquals(...)
  3. 测试方法 testAdd testSubtract 就是我们的测试用例。它们必须是 void 返回类型,并且可以被 @Test 注解标记。
  4. 断言 assertEquals(5, result, “2 + 3 应该等于 5”); 这是测试的核心。它断言期望值(5)与实际值( result )相等。如果相等,测试通过;如果不相等,测试失败,并会显示可选的错误信息(第三个参数)。断言是验证代码行为是否符合预期的工具。

2.3 运行测试并查看结果

在IDE中运行测试非常简单。通常,你可以在测试类或测试方法旁边看到一个绿色的运行按钮(▶️)。点击它,选择“Run ‘CalculatorTest’”。

运行完成后,IDE会有一个专门的“Run”窗口显示结果。你会看到绿色的对勾和“Tests passed: 2”之类的信息。如果测试失败,则会显示红色的叉,并详细指出是哪个断言失败了,期望值是什么,实际值是什么,这能极大提升调试效率。

你也可以通过Maven命令在终端运行所有测试: mvn test 。Maven会编译代码并运行 src/test/java 下所有以 Test 开头或结尾的类的测试方法。

实操心得:在早期,养成每实现一个小功能就为其编写一个测试的习惯。不要想着等所有功能做完再补测试,那样工作量会巨大,且容易遗漏。测试应该和功能代码同步编写,甚至是先写测试(测试驱动开发,TDD)。

3. JUnit框架常见注解深度解析

JUnit 5通过一系列注解来组织测试的生命周期、定义测试行为以及进行条件化测试。理解这些注解是编写高效、可维护测试套件的关键。

3.1 核心生命周期注解:@BeforeEach, @AfterEach, @BeforeAll, @AfterAll

测试类中经常会有一些重复的代码,比如在每个测试方法前都需要初始化一个对象,或者结束后需要清理资源。JUnit提供了生命周期注解来帮你处理这些重复工作。

  • @BeforeEach : 在每个 @Test 方法 之前 执行。常用于初始化测试上下文,比如创建被测对象、准备测试数据。
  • @AfterEach : 在每个 @Test 方法 之后 执行。常用于清理资源,比如关闭数据库连接、删除临时文件。
  • @BeforeAll : 在所有 @Test 方法 之前 且只执行一次 。该方法必须是 static 的。常用于执行耗时且一次性的设置,如启动嵌入式数据库、加载全局配置。
  • @AfterAll : 在所有 @Test 方法 之后 且只执行一次 。该方法也必须是 static 的。常用于关闭 @BeforeAll 中启动的全局资源。
import org.junit.jupiter.api.*;

class LifecycleTest {
    private Calculator calculator;
    private static String sharedResource;

    @BeforeAll
    static void initAll() {
        sharedResource = "Initialized";
        System.out.println("@BeforeAll - 初始化共享资源: " + sharedResource);
    }

    @BeforeEach
    void init() {
        calculator = new Calculator(); // 每个测试方法前都获得一个全新的Calculator实例
        System.out.println("@BeforeEach - 创建新的Calculator实例");
    }

    @Test
    void testOne() {
        System.out.println("执行 testOne");
        assertEquals(2, calculator.add(1, 1));
    }

    @Test
    void testTwo() {
        System.out.println("执行 testTwo");
        assertEquals(0, calculator.subtract(1, 1));
    }

    @AfterEach
    void tearDown() {
        calculator = null; // 显式置空,帮助GC(非必须,但演示用途)
        System.out.println("@AfterEach - 清理当前测试上下文");
    }

    @AfterAll
    static void tearDownAll() {
        sharedResource = null;
        System.out.println("@AfterAll - 清理共享资源");
    }
}

运行上述测试,控制台输出顺序将是:

@BeforeAll - 初始化共享资源: Initialized
@BeforeEach - 创建新的Calculator实例
执行 testOne
@AfterEach - 清理当前测试上下文
@BeforeEach - 创建新的Calculator实例
执行 testTwo
@AfterEach - 清理当前测试上下文
@AfterAll - 清理共享资源

注意事项: @BeforeAll @AfterAll 注解的方法必须是 static 的,因为它们在整个测试类的生命周期中只运行一次,不依赖于任何测试实例的状态。而 @BeforeEach @AfterEach 则运行在测试实例的上下文中。

3.2 测试配置与禁用注解:@DisplayName, @Disabled

  • @DisplayName : 为测试类或测试方法设置一个更易读、更具描述性的名称,这个名称会显示在测试报告和IDE中,替代原本的方法名。这对于测试报告的可读性非常有帮助。

    @Test
    @DisplayName("测试两数相加的正向场景")
    void additionWithPositiveNumbers() {
        // ...
    }
    
    @Test
    @DisplayName("😱 测试除以零的异常情况")
    void divisionByZeroShouldThrowException() {
        // ...
    }
    
  • @Disabled : 用于临时禁用某个测试类或测试方法。被禁用的测试不会被执行。在重构代码、测试依赖的外部服务不可用,或者某个测试用例暂时不需要运行时非常有用。 务必附上禁用原因 ,这是良好的团队协作习惯。

    @Test
    @Disabled("暂时禁用,因为依赖的支付网关正在维护中")
    void testPaymentIntegration() {
        // ...
    }
    
    @Disabled("整个类的测试尚未完成")
    class IncompleteFeatureTest {
        // ...
    }
    

3.3 条件化测试注解:@EnabledOnOs, @DisabledIf

在实际项目中,有些测试可能只在特定环境下才需要运行,比如只在Linux上、只在Java 11以上版本、或者只有当某个系统属性被设置时才运行。JUnit 5提供了丰富的条件化执行注解。

  • @EnabledOnOs / @DisabledOnOs : 根据操作系统条件执行或禁用测试。

    @Test
    @EnabledOnOs(OS.LINUX, OS.MAC)
    void onlyOnLinuxOrMac() {
        // 这个测试只会在Linux或Mac系统上运行
    }
    
    @Test
    @DisabledOnOs(OS.WINDOWS)
    void notOnWindows() {
        // 这个测试在Windows上不会运行
    }
    
  • @EnabledIfSystemProperty / @DisabledIfSystemProperty : 根据JVM系统属性条件执行或禁用测试。

    @Test
    @EnabledIfSystemProperty(named = "env", matches = "ci")
    void onlyOnCIServer() {
        // 只有当系统属性 `env` 的值为 `ci` 时才运行(常用于持续集成环境)
    }
    
  • @EnabledIf / @DisabledIf : 这是更灵活的条件注解,允许你通过一个返回布尔值的方法来动态决定是否执行测试。

    @Test
    @EnabledIf("customCondition")
    void conditionallyEnabledTest() {
        // ...
    }
    
    boolean customCondition() {
        return someExternalService.isAvailable();
    }
    

条件化测试能帮助你构建更智能、适应性更强的测试套件,避免在不适合的环境下运行注定失败的测试,从而保持测试结果的清洁。

4. 断言与假设:验证代码行为的核心工具

写测试的本质就是“断言”你的代码行为符合预期。JUnit 5的 Assertions 类提供了丰富的断言方法。同时, Assumptions 类用于“假设”,它决定了测试是否继续执行。

4.1 常用断言方法详解

断言方法通常遵循一个模式:第一个参数是期望值,第二个参数是实际值(计算出的结果),第三个可选参数是测试失败时显示的消息。

断言方法 用途说明 示例
assertEquals 断言两个对象(或基本类型)相等。对于对象,使用 equals() 方法比较。 assertEquals(4, calculator.multiply(2, 2))
assertNotEquals 断言两个对象不相等。 assertNotEquals(0, result)
assertTrue / assertFalse 断言条件为真/假。 assertTrue(list.isEmpty())
assertNull / assertNotNull 断言对象引用为 null / 不为 null assertNotNull(userService)
assertSame / assertNotSame 断言两个对象引用指向 同一个 对象/不是同一个对象( == 比较)。 assertSame(expectedSingleton, actualInstance)
assertArrayEquals 断言两个数组内容相等。 assertArrayEquals(new int[]{1,2}, actualArray)
assertIterableEquals 断言两个可迭代对象(如List, Set)的内容相等且顺序一致。 assertIterableEquals(expectedList, actualList)
assertThrows 断言执行特定代码会抛出预期的异常 。这是测试异常情况的利器。 assertThrows(IllegalArgumentException.class, () -> calculator.divide(1, 0))
assertTimeout / assertTimeoutPreemptively 断言代码执行能在指定时间内完成。后者会在超时后立即终止执行。 assertTimeout(Duration.ofSeconds(2), () -> heavyTask())
assertAll 分组断言 ,执行其中所有断言,并收集所有失败信息一起报告,而不是在第一个失败时就停止。 见下方示例

重点讲一下 assertAll assertThrows ,因为它们非常实用。

assertAll 分组断言 :在测试一个方法有多个副作用或返回一个复杂对象时,你可能需要验证多个属性。使用 assertAll 可以确保所有断言都被执行,从而一次性看到所有问题,而不是修好一个失败后才发现另一个。

@Test
@DisplayName("创建用户后验证所有属性")
void testCreateUser() {
    User user = userService.createUser("张三", 30, "zhangsan@example.com");

    assertAll("用户属性验证",
        () -> assertEquals("张三", user.getName(), "姓名不匹配"),
        () -> assertEquals(30, user.getAge(), "年龄不匹配"),
        () -> assertNotNull(user.getId(), "ID不应为空"),
        () -> assertTrue(user.getEmail().contains("@"), "邮箱格式不正确")
    );
}

assertThrows 异常断言 :这是测试错误处理逻辑的标准方式。它接受一个异常类型和一个可执行代码块(通常用lambda表达式)。如果代码块抛出了指定类型(或其子类)的异常,测试通过;如果没抛出或抛出了其他异常,测试失败。

@Test
@DisplayName("除以零应抛出IllegalArgumentException")
void testDivideByZero() {
    Calculator calculator = new Calculator();
    // 断言执行 lambda 表达式会抛出 IllegalArgumentException
    Exception exception = assertThrows(IllegalArgumentException.class, () -> {
        calculator.divide(10, 0);
    });
    // 还可以进一步断言异常信息
    assertEquals("除数不能为零", exception.getMessage());
}

4.2 使用假设(Assumptions)控制测试流程

假设(Assumptions)与断言(Assertions)不同。断言失败意味着测试失败。而假设失败意味着测试被 中止 ,并标记为“跳过”(Aborted),而不是失败。这适用于那些前置条件不满足时,测试就没有意义或无法执行的场景。

import org.junit.jupiter.api.Assumptions;

@Test
void testOnlyWhenDatabaseIsAvailable() {
    // 假设数据库连接是可用的
    Assumptions.assumeTrue(databaseService.isConnected(), "数据库未连接,跳过测试");

    // 如果上一行假设失败,后面的代码都不会执行
    List<User> users = userService.getAllUsers();
    assertFalse(users.isEmpty());
}

@Test
@EnabledIfSystemProperty(named = "os.arch", matches = ".*64.*")
void testOnlyOn64BitArch() {
    // 这个测试只会在64位系统上运行,否则会被跳过
    // 使用 @EnabledIfSystemProperty 注解是更声明式的做法,但 assumeTrue 可以在方法体内动态判断。
}

假设非常适合用于集成测试或依赖外部环境(数据库、网络服务)的测试。它能让你清晰地分离“环境问题”和“代码逻辑问题”。

5. 测试套件与参数化测试:提升测试效率

当测试规模增长后,如何组织测试以及如何避免为多组输入数据编写重复的测试代码就变得很重要。JUnit 5提供了测试套件和参数化测试来解决这些问题。

5.1 使用@Suite组建测试套件

测试套件允许你将多个测试类组合在一起运行。这在你想有选择地运行某一组相关的测试时非常有用,比如只运行“集成测试”套件或“数据库相关”测试套件。

你需要额外引入JUnit Platform Suite的依赖:

<dependency>
    <groupId>org.junit.platform</groupId>
    <artifactId>junit-platform-suite</artifactId>
    <version>1.10.0</version>
    <scope>test</scope>
</dependency>

然后,创建一个空类,并用 @Suite 注解标记它,通过 @SelectClasses @SelectPackages 来指定要包含的测试。

import org.junit.platform.suite.api.SelectClasses;
import org.junit.platform.suite.api.SelectPackages;
import org.junit.platform.suite.api.Suite;

@Suite
@SelectClasses({
    CalculatorTest.class,
    UserServiceTest.class,
    OrderServiceIntegrationTest.class
})
// 或者选择整个包
// @SelectPackages("com.example.service.integration")
public class AllIntegrationTestsSuite {
    // 这个类本身没有测试方法,它只是一个套件的入口
}

现在,运行 AllIntegrationTestsSuite 类,就会自动运行其中指定的所有测试类。

5.2 参数化测试:@ParameterizedTest与数据源

如果你有一个方法,需要对多组不同的输入输出进行测试,为每一组都写一个单独的 @Test 方法是非常冗余的。参数化测试(Parameterized Test)允许你只写一个测试方法,然后为其提供多组参数。

首先,需要引入参数化测试模块的依赖:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-params</artifactId>
    <version>5.10.0</version>
    <scope>test</scope>
</dependency>

然后,使用 @ParameterizedTest 替代 @Test ,并通过诸如 @ValueSource , @CsvSource , @MethodSource 等注解来提供参数。

1. @ValueSource: 提供一组基本类型或String的常量值。

@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 5, 7})
void testIsOdd(int number) {
    assertTrue(number % 2 != 0, number + " 应该是奇数");
}

2. @CsvSource: 提供逗号分隔值(CSV)格式的数据,每一行是一组参数。非常适合测试多参数方法。

@ParameterizedTest(name = "{0} + {1} = {2}") // 定义测试显示名称,更清晰
@CsvSource({
    "0, 1, 1",
    "1, 2, 3",
    "10, -2, 8",
    "-5, -5, -10"
})
void testAddition(int a, int b, int expectedSum) {
    Calculator calculator = new Calculator();
    assertEquals(expectedSum, calculator.add(a, b),
            () -> String.format("%d + %d 应该等于 %d", a, b, expectedSum)); // 使用lambda延迟消息生成
}

3. @MethodSource: 指定一个返回Stream、Collection等的静态工厂方法作为参数源。这是最灵活的方式,可以生成复杂的对象参数。

@ParameterizedTest
@MethodSource("provideStringsForTest")
void testStringLength(String input, int expectedLength) {
    assertEquals(expectedLength, input.length());
}

private static Stream<Arguments> provideStringsForTest() {
    return Stream.of(
        Arguments.of("hello", 5),
        Arguments.of("JUnit", 5),
        Arguments.of("", 0),
        Arguments.of("  ", 2) // 注意空格
    );
}

参数化测试极大地减少了重复代码,让测试用例的覆盖更加清晰和集中。测试报告也会为每一组参数分别显示结果。

6. 常见问题与排查技巧实录

即使掌握了基本用法,在实际编写和运行测试时,还是会遇到各种“坑”。下面是我在多年实践中总结的一些常见问题及其解决方法。

6.1 依赖与类路径问题

问题现象: 在IDE中运行测试正常,但使用 mvn test 命令时失败,报错 NoClassDefFoundError ClassNotFoundException ,特别是找不到 org/junit/platform... org/junit/jupiter... 相关的类。

排查与解决:

  1. 检查依赖作用域 :确保JUnit依赖的 <scope> test 。如果误设为 compile runtime ,在某些打包插件配置下可能导致问题,但通常不是主因。
  2. 检查依赖版本冲突 :这是最常见的原因。你的项目可能引入了其他传递依赖,这些依赖包含了旧版本的JUnit(如JUnit 4)。使用Maven命令检查依赖树:
    mvn dependency:tree -Dincludes=*junit*
    
    查看输出中是否有多个不同版本的JUnit。如果有,需要在你的 pom.xml 中显式声明你想要的JUnit 5版本,并排除传递过来的旧版本依赖。
    <dependency>
        <groupId>some.other.library</groupId>
        <artifactId>other-lib</artifactId>
        <version>...</version>
        <exclusions>
            <exclusion>
                <groupId>junit</groupId>
                <artifactId>junit</artifactId> <!-- 排除JUnit 4 -->
            </exclusion>
        </exclusions>
    </dependency>
    
  3. 清理并重新构建 :有时本地仓库的元数据可能损坏。尝试运行 mvn clean test

6.2 测试方法不执行

问题现象: 写了 @Test 方法,但运行时被跳过,没有任何错误信息。

排查与解决:

  1. 方法访问权限 :在JUnit 5中,测试方法可以是 package-private (默认)或 public 。但 不能是 private 。确保你的测试方法没有错误地声明为 private
    // 正确
    @Test
    void testSomething() { ... }
    // 或
    @Test
    public void testSomething() { ... }
    
    // 错误 - 不会被执行
    @Test
    private void testSomething() { ... }
    
  2. 方法返回值 :测试方法必须是 void 返回类型。如果误写了返回值,它不会被当作测试方法。
  3. 静态方法 @Test 方法不能是 static 的。
  4. @Disabled 注解 :检查是否不小心给方法或类加上了 @Disabled 注解。
  5. IDE配置 :检查IDE的测试运行配置,确保没有设置过滤条件,排除了你的测试类。

6.3 断言失败信息不清晰

问题现象: 测试失败时,只看到 AssertionFailedError ,但不知道具体是哪个值不对,尤其是比较复杂对象或数组时。

解决技巧:

  1. 始终提供失败信息 :养成习惯,为 assertEquals , assertTrue 等断言方法的最后一个可选参数( message )提供有意义的描述。对于复杂的对象,可以输出关键属性。
    assertEquals(expectedUser.getName(), actualUser.getName(),
        "用户姓名不一致。预期: " + expectedUser + ", 实际: " + actualUser);
    
  2. 使用 assertAll 分组 :如前所述, assertAll 会执行所有子断言并汇总所有失败信息,让你一目了然。
  3. 利用IDE的差异查看器 :现代IDE(如IntelliJ IDEA)在断言失败时,会高亮显示期望值和实际值的差异,对于字符串、集合等类型特别有用。确保你点击了错误信息以展开详细视图。

6.4 测试依赖与顺序问题

核心原则: 单元测试应该是独立的、不依赖执行顺序的。这是保证测试可靠性的黄金法则。一个测试的成功或失败不应影响另一个测试。

常见陷阱与解决:

  1. 共享可变状态 :在测试类中使用非静态的成员变量,并在多个 @Test 方法中修改它。
    • 错误示例
      class BadTest {
          private List<String> list = new ArrayList<>(); // 共享状态
          @Test void testA() { list.add("A"); assertEquals(1, list.size()); }
          @Test void testB() { list.add("B"); assertEquals(2, list.size()); } // 依赖testA先执行!
      }
      
    • 正确做法 :要么在 @BeforeEach 中重新初始化状态,要么避免使用共享的可变成员变量。每个测试方法都应该从干净的状态开始。
      class GoodTest {
          private List<String> list;
          @BeforeEach void setUp() { list = new ArrayList<>(); } // 每个测试前重置
          @Test void testA() { list.add("A"); assertEquals(1, list.size()); }
          @Test void testB() { list.add("B"); assertEquals(1, list.size()); } // 现在独立了
      }
      
  2. 依赖外部环境状态 :测试修改了数据库、文件系统或静态变量,没有清理。
    • 解决 :使用 @AfterEach @AfterAll 进行清理。对于数据库测试,考虑使用事务并在测试后回滚,或者使用内存数据库(如H2)每次创建新库。
  3. 如果确实需要顺序 :极少数情况下(如集成测试中需要按步骤初始化),可以使用 @TestMethodOrder 注解,并配合 @Order 。但请将其视为最后的手段,并充分记录原因。
    @TestMethodOrder(MethodOrderer.OrderAnnotation.class)
    class IntegrationTest {
        @Test @Order(1) void init() { ... }
        @Test @Order(2) void process() { ... } // 依赖init创建的数据
        @Test @Order(3) void verify() { ... }
    }
    

6.5 测试“不可测试”的代码

有时你会遇到一些难以测试的代码,比如包含大量静态方法调用、紧密耦合、或者有复杂依赖(如网络、数据库)的类。

应对策略:

  1. 重构以提高可测试性 :这是根本解决之道。考虑使用依赖注入(Dependency Injection),将外部依赖(如Service、DAO)通过构造函数或Setter传入,而不是在方法内部直接 new 。这样在测试时就可以传入模拟对象(Mock)。
  2. 使用Mock框架 :对于外部依赖,使用Mockito、EasyMock等框架创建“模拟对象”(Mock),预设它们的行为,从而将被测类与外部环境隔离。这是单元测试的核心技术之一。
    @Test
    void testUserServiceWithMock() {
        // 1. 创建依赖项(UserDao)的Mock
        UserDao mockUserDao = Mockito.mock(UserDao.class);
        // 2. 预设Mock的行为:当调用findById(1L)时,返回一个特定的User对象
        Mockito.when(mockUserDao.findById(1L)).thenReturn(new User(1L, "Mock User"));
        // 3. 将被测类(UserService)与Mock关联
        UserService userService = new UserService(mockUserDao);
        // 4. 执行测试
        User user = userService.getUserById(1L);
        // 5. 验证结果和行为
        assertEquals("Mock User", user.getName());
        Mockito.verify(mockUserDao).findById(1L); // 验证findById方法确实被调用了一次
    }
    
  3. 测试私有方法? 通常不建议直接测试私有方法。私有方法是实现细节,应该通过测试类的公有方法来间接测试。如果觉得私有方法复杂到需要单独测试,那可能意味着它应该被提取到另一个类中,并提升为公有或包私有方法。

编写单元测试是一个技能,更是一种促进代码质量提升的实践。从简单的 assertEquals 开始,逐步运用生命周期管理、参数化测试、Mock等技术,你会发现自己对代码的理解更深了,重构的勇气也更足了。记住,好的测试应该是快速的、独立的、可重复的、自验证的(不需要人工检查结果)、及时的(与代码同步编写)。

更多推荐