Java单元测试实战:JUnit 5核心用法与Mockito集成指南

📅 2026/7/13 6:53:14 👁️ 阅读次数 📝 编程学习
Java单元测试实战:JUnit 5核心用法与Mockito集成指南

1. 为什么你的代码需要单元测试?

如果你写过Java,大概率遇到过这种情况:你写了一个计算器类,里面有加减乘除方法。你信心满满地写完,然后在main方法里随便调了一下add(1, 2),看到控制台输出3,就觉得万事大吉,提交代码了。结果上线后,用户输入了一个负数,或者一个很大的数,程序直接崩了,或者返回了一个莫名其妙的结果。然后你开始熬夜排查,在成百上千行日志里寻找那个不起眼的NullPointerException

这种场景太常见了。我们习惯于在开发时进行一些零散的、手动的“测试”,但这种测试既不系统,也无法复用,更无法保证代码在后续修改中不会引入新的问题。单元测试,就是为了解决这个问题而生的。它不是什么高深莫测的“测试工程师”专属技能,而是每一位开发者都应该掌握的基本功。简单说,单元测试就是针对你代码中最小的、可测试的单元(在Java里通常就是一个方法)编写的一段自动化测试代码。它的核心目标是:确保你的每一个“零件”在出厂前都是合格的,并且未来任何维修(代码修改)都不会破坏它原有的功能。

JUnit就是这个领域的“行业标准”。它不是一个庞大的、复杂的测试管理平台,而是一个轻量级、专注的框架,让你能用几行注解和断言,就构建起对自身代码的防护网。当你开始为你的Calculator.add()方法编写第一个@Test方法时,你就迈出了从“代码编写者”向“软件工程师”转变的关键一步。

2. JUnit 5 核心概念与项目搭建实战

在开始写测试之前,我们得先把“战场”布置好。现在JUnit的主流版本是JUnit 5(简称JUnit Jupiter),它和老的JUnit 4在架构和API上有不少区别,建议新项目直接上5。

2.1 理解JUnit 5的模块化架构

JUnit 5被设计得更模块化,主要包含三个子项目:

  • JUnit Platform: 这是在JVM上启动测试框架的基础。无论是IDE(如IntelliJ IDEA、Eclipse)还是构建工具(如Maven、Gradle),都是通过这个平台来发现和执行测试的。你可以把它想象成测试引擎的“插槽”。
  • JUnit Jupiter: 这是编写新测试的编程模型和扩展模型。我们平时用的@Test@BeforeEach这些注解,以及Assertions类,都来自这个模块。这是我们开发者主要打交道的部分。
  • JUnit Vintage: 这是一个为了兼容老项目而存在的模块,它允许在JUnit 5平台上运行JUnit 3或JUnit 4的测试。如果你的项目是老古董,需要它来过渡。

对于新手,记住一点就行:在你的项目依赖里,引入junit-jupiter(它通常会自动包含平台依赖),你就拥有了写测试的全部能力。

2.2 三种主流构建工具的依赖配置

光说不练假把式,我们直接看如何在项目中引入JUnit 5。这里以最常用的Maven和Gradle为例。

Maven配置:在你的pom.xml文件的<dependencies>部分加入以下依赖。注意scopetest,这意味着这些库只在编译和运行测试代码时可用,不会被打进最终的生产包。

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

Gradle配置 (Kotlin DSL):在你的build.gradle.kts文件的dependencies块中加入:

dependencies { testImplementation("org.junit.jupiter:junit-jupiter:5.10.0") }

Gradle配置 (Groovy DSL):在你的build.gradle文件中加入:

dependencies { testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0' }

配置好后,你的项目目录结构通常会遵循Maven/Gradle的约定:

your-project/ ├── src/ │ ├── main/ │ │ └── java/ (你的生产代码在这里,例如 com/yourcompany/Calculator.java) │ └── test/ │ └── java/ (你的测试代码在这里,例如 com/yourcompany/CalculatorTest.java) └── pom.xml 或 build.gradle

实操心得:我强烈建议你从一开始就严格遵守src/main/javasrc/test/java的目录分离。这不仅仅是约定,更是为了利用构建工具的“测试作用域”特性。你的测试代码可以访问主代码,但主代码完全不知道测试代码的存在,保持了清晰的边界。很多IDE(如IntelliJ IDEA)在创建新类时,都可以通过快捷键(如Ctrl+Shift+T/Cmd+Shift+T)直接跳转到对应测试类或创建测试类,前提就是目录结构正确。

2.3 编写你的第一个测试类

假设我们有一个非常简单的Calculator类:

// src/main/java/com/example/Calculator.java package com.example; public class Calculator { public int add(int a, int b) { return a + b; } public int subtract(int a, int b) { return a - b; } }

现在,我们在测试目录下创建对应的测试类CalculatorTest

// src/test/java/com/example/CalculatorTest.java package com.example; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; public class CalculatorTest { @Test void testAdd() { // 1. 准备 (Arrange) Calculator calculator = new Calculator(); int a = 5; int b = 3; // 2. 执行 (Act) int result = calculator.add(a, b); // 3. 断言 (Assert) assertEquals(8, result, "5 + 3 应该等于 8"); } @Test void testSubtract() { Calculator calculator = new Calculator(); assertEquals(2, calculator.subtract(5, 3)); } }

我们来拆解一下这个测试:

  1. @Test注解: 这是JUnit的“魔法标记”。任何被它标注的方法,都会被JUnit识别为一个独立的测试用例。方法名最好能清晰地描述测试意图,比如testAdd_PositiveNumbers
  2. assertEquals方法: 这是最核心的“断言”方法。它来自Assertions类,我们通过static importimport static ...)直接使用,让代码更简洁。它的作用是判断实际值(result)是否等于期望值(8)。如果不相等,测试就会失败,并打印出可选的错误信息(“5 + 3 应该等于 8”)。
  3. 测试结构 (AAA模式): 这是一个良好的测试编写模式。
    • Arrange (准备): 设置测试所需的所有数据和对象状态。这里我们创建了Calculator实例和输入参数。
    • Act (执行): 调用你要测试的那个方法。
    • Assert (断言): 验证执行结果是否符合预期。

在IDE里,你通常可以在测试类或方法上右键,选择“Run ...Test”。如果一切正常,你会看到一个绿色的进度条或对勾。如果assertEquals失败,你会看到一个红色的失败提示,并清晰地看到期望值和实际值是什么。

3. 深入断言:不止是“相等”

断言是测试的灵魂,它定义了什么是“正确”。JUnit Jupiter的Assertions类提供了丰富的断言方法,远不止一个assertEquals

3.1 基础断言方法详解

  • assertEquals(expected, actual)/assertNotEquals(...): 判断相等或不相等。对于对象,默认使用equals()方法进行比较。这里有个大坑:对于浮点数(float,double),永远不要直接用assertEquals因为浮点数计算有精度损失。必须使用重载版本,指定一个可接受的误差范围(delta):

    @Test void testFloatingPointCalculation() { double result = 10.0 / 3.0; // 错误:可能因为精度问题失败 // assertEquals(3.333, result); // 正确:允许0.001的误差 assertEquals(3.333, result, 0.001); }
  • assertTrue(condition)/assertFalse(condition): 判断条件为真或为假。常用于测试返回布尔值的方法,或者某个状态。

    @Test void testStringIsNotEmpty() { String str = "hello"; assertTrue(str.length() > 0); assertFalse(str.isEmpty()); }
  • assertNull(object)/assertNotNull(object): 判断对象是否为null。这是防御NullPointerException的第一道防线,对于可能返回null的方法至关重要。

    @Test void testMethodReturnsNonNull() { List<String> list = someService.getList(); assertNotNull(list); // 确保返回的不是null,避免后续操作崩溃 // 进一步断言list的内容... }
  • assertSame(expected, actual)/assertNotSame(...): 判断两个对象引用是否指向同一个对象==比较),而不是值相等。这在测试单例模式或缓存时很有用。

    @Test void testSingletonInstance() { Config instance1 = Config.getInstance(); Config instance2 = Config.getInstance(); assertSame(instance1, instance2); // 应该是同一个实例 }
  • assertArrayEquals(expectedArray, actualArray): 比较两个数组的内容是否一致。它会逐个元素比较。

    @Test void testSplitString() { String str = "a,b,c"; String[] parts = str.split(","); String[] expected = {"a", "b", "c"}; assertArrayEquals(expected, parts); }
  • assertIterableEquals(expected, actual): 比较两个可迭代对象(如List,Set)的内容和顺序。比assertEquals对集合的比较更精确。

  • assertThrows()/assertDoesNotThrow()(JUnit 5):这是测试异常情况的利器!它允许你断言执行某段代码时,应该抛出(或不抛出)特定类型的异常。

    @Test void testDivideByZeroThrowsException() { Calculator calculator = new Calculator(); // 断言:执行calculator.divide(1, 0)会抛出ArithmeticException Exception exception = assertThrows(ArithmeticException.class, () -> { calculator.divide(1, 0); }); // 你还可以进一步断言异常信息 assertEquals("/ by zero", exception.getMessage()); }

3.2 组合断言与软断言

有时,一个测试方法里需要对多个条件进行断言。如果使用普通的assertXxx,第一个断言失败后,整个测试就停止了,你无法知道后面的断言是否通过。JUnit 5提供了assertAll来解决这个问题,它允许你将多个断言组合在一起,全部执行完毕后,再统一报告所有失败。

@Test void testPersonProperties() { Person person = new Person("John", 30, "john@example.com"); assertAll("person", () -> assertEquals("John", person.getName()), () -> assertEquals(30, person.getAge()), () -> assertTrue(person.getEmail().contains("@")) ); // 如果name断言失败,age和email的断言依然会执行,并在最终报告中一并显示。 }

注意事项:assertAll非常适合用来验证一个复杂对象的多个属性,或者一个操作产生的多个副作用。它能给你一个完整的“测试报告”,而不是零碎的信息。但也要注意,不要滥用,如果一个测试方法需要验证太多不相关的东西,可能意味着它应该被拆分成多个更专注的测试。

4. 测试生命周期与Fixture:@BeforeEach,@AfterEach

在写测试时,你经常会发现很多测试方法需要一些相同的准备工作,比如初始化数据库连接、创建测试数据、或者构造一个复杂的测试对象。如果每个@Test方法都写一遍,代码会非常冗余。JUnit提供了几个生命周期注解来帮助我们管理这些“夹具”(Fixture)。

4.1 生命周期注解详解

  • @BeforeAll/@AfterAll: 在整个测试类所有测试方法执行之前/之后各运行一次。这两个方法必须是static的。通常用于非常昂贵且可共享的初始化/清理工作,比如启动嵌入式数据库、建立网络连接。

    public class DatabaseTest { static Connection connection; @BeforeAll static void setUpAll() { // 在整个类开始前,建立数据库连接(只做一次) connection = DriverManager.getConnection("jdbc:h2:mem:test"); } @AfterAll static void tearDownAll() throws SQLException { // 在所有测试结束后,关闭连接(只做一次) if (connection != null) { connection.close(); } } }
  • @BeforeEach/@AfterEach: 在每个@Test测试方法执行之前/之后各运行一次。这是最常用的Fixture。用于为每个测试准备一个干净、独立的环境。

    public class ShoppingCartTest { ShoppingCart cart; Product apple; Product banana; @BeforeEach void setUp() { // 每个测试方法运行前,都会执行 cart = new ShoppingCart(); apple = new Product("Apple", 1.2); banana = new Product("Banana", 0.8); // 确保每个测试都从一个空的购物车开始 } @AfterEach void tearDown() { // 每个测试方法运行后,都会执行 // 可以用来清理资源,比如关闭文件流、删除临时文件 cart.clear(); // 虽然不是必须,但体现了良好的习惯 } @Test void testAddItem() { cart.addItem(apple); assertEquals(1, cart.getItemCount()); } @Test void testRemoveItem() { cart.addItem(apple); cart.removeItem(apple); assertEquals(0, cart.getItemCount()); } // 两个测试互不干扰,因为@BeforeEach为每个测试都创建了新的cart实例。 }

4.2 测试的执行顺序与独立性

一个核心原则是:测试方法之间必须是独立的,不依赖执行顺序。JUnit默认不保证测试方法的执行顺序。你不能假设testAtestB之前运行。每个测试都应该能独立运行并产生正确的结果。

为什么?因为当测试套件变得庞大,并行运行测试是提高效率的常见手段。如果测试之间有依赖,并行运行就会出错。@BeforeEach@AfterEach正是为了保障这种独立性而设计的,它们确保每个测试都在一个已知的初始状态下开始。

踩过的坑:早期我写过这样的测试:testCreateUser创建一个用户,testLoginUser尝试用这个用户登录。当测试顺序改变或单独运行testLoginUser时,它就失败了。这就是典型的测试耦合。正确的做法是,每个测试都自己准备所需的数据。如果创建用户的逻辑很复杂,可以把它提取到一个@BeforeEach方法里,或者更优雅地,提取到一个工具方法中,让两个测试都去调用它,但数据本身(如用户ID)是各自独立的。

5. 高级测试场景:参数化、条件化与超时

基础测试能覆盖大部分场景,但现实世界的代码更复杂。JUnit 5提供了一些强大的扩展功能来处理这些高级场景。

5.1 参数化测试:用多组数据驱动一个测试

如果你的一个方法需要对多种不同的输入进行测试,写一堆几乎相同的@Test方法非常枯燥。参数化测试(@ParameterizedTest)允许你定义一个数据源,然后测试方法会为数据源中的每一组数据运行一次。

首先,你需要引入额外的依赖(Maven):

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

然后就可以使用了:

import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.junit.jupiter.params.provider.CsvSource; import static org.junit.jupiter.api.Assertions.assertTrue; public class ParameterizedTestExample { // 使用 @ValueSource 提供一组简单的值 @ParameterizedTest @ValueSource(ints = {1, 3, 5, -3, 15}) void testIsOdd(int number) { assertTrue(number % 2 != 0); } // 使用 @CsvSource 提供多列数据(逗号分隔值) @ParameterizedTest @CsvSource({ "2, 3, 5", // a=2, b=3, expected=5 "0, 0, 0", "-5, 10, 5", "100, 200, 300" }) void testAddWithCsv(int a, int b, int expectedSum) { Calculator calc = new Calculator(); assertEquals(expectedSum, calc.add(a, b)); } // 更复杂的,可以从方法获取数据源 @ParameterizedTest @MethodSource("stringProvider") void testWithMethodSource(String argument) { assertNotNull(argument); } static Stream<String> stringProvider() { return Stream.of("apple", "banana", "cherry"); } }

参数化测试极大地减少了重复代码,让测试用例的覆盖更加清晰和集中。

5.2 条件化测试:只在特定环境下运行

有些测试可能只在满足某些条件时才需要运行,例如:

  • 只在Windows操作系统上运行。
  • 只在某个环境变量被设置时运行。
  • 只在Java版本大于11时运行。

JUnit 5提供了@EnabledOnOs,@DisabledOnOs,@EnabledIfSystemProperty,@DisabledIfEnvironmentVariable等注解。

@Test @EnabledOnOs(OS.MAC) // 只在Mac上运行这个测试 void testMacSpecificFeature() { // ... } @Test @DisabledOnOs(OS.WINDOWS) // 在Windows上不运行这个测试 void testFeatureNotSupportedOnWindows() { // ... } @Test @EnabledIfSystemProperty(named = "env", matches = "ci") // 只在系统属性env=ci时运行 void testOnlyOnCIServer() { // 这个测试可能比较慢,或者有副作用,只希望在持续集成服务器上运行 }

5.3 超时测试:防止死循环或性能退化

如果一个方法执行时间过长,可能是出现了死循环,或者性能严重退化。@Timeout注解可以方便地为测试设置时间限制。

@Test @Timeout(5) // 测试必须在5秒内完成,否则失败 void testPerformance() { // 执行一些可能很耗时的操作 heavyComputation(); } // 也可以用在类级别,为所有测试方法设置默认超时 @Timeout(3) class TimeoutTestClass { @Test void test1() { /* 必须在3秒内完成 */ } @Test void test2() { /* 必须在3秒内完成 */ } }

6. 测试替身:Mock与Stub入门(结合Mockito)

单元测试的核心是“单元”,即隔离。我们要测试的是A类,但如果A类依赖B类,而B类又连接着数据库、网络等外部系统,测试就会变得缓慢、不稳定且难以设置。这时,我们就需要“测试替身”(Test Double)来模拟B类的行为。Mockito是Java领域最流行的Mock框架。

6.1 为什么需要Mock?

假设我们有一个PaymentService,它依赖一个PaymentGateway(支付网关)来实际处理支付。在单元测试中,我们显然不希望真的去调用第三方支付接口(慢、收费、不可控)。

public class PaymentService { private PaymentGateway gateway; public PaymentService(PaymentGateway gateway) { this.gateway = gateway; } public boolean processPayment(Order order) { // 一些业务逻辑... boolean success = gateway.charge(order.getAmount(), order.getCardToken()); if (success) { // 更新订单状态等... } return success; } }

我们的单元测试应该只关心PaymentService自身的逻辑是否正确,至于PaymentGateway是否真的能扣款,那是集成测试或契约测试的事情。我们需要一个“假的”PaymentGateway

6.2 使用Mockito创建和配置Mock对象

首先,添加Mockito依赖(通常和JUnit一起使用):

<dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <version>5.11.0</version> <!-- 使用最新版本 --> <scope>test</scope> </dependency> <!-- 如果需要与JUnit 5更深度集成(使用@ExtendWith),可以引入mockito-junit-jupiter --> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-junit-jupiter</artifactId> <version>5.11.0</version> <scope>test</scope> </dependency>

然后,我们可以编写测试:

import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.mockito.Mockito.*; import static org.junit.jupiter.api.Assertions.*; @ExtendWith(MockitoExtension.class) // 启用Mockito对JUnit 5的支持 public class PaymentServiceTest { @Mock // 让Mockito创建一个PaymentGateway的Mock对象 private PaymentGateway mockGateway; @Test void testProcessPayment_Success() { // 1. 准备Mock行为 (Stubbing) Order testOrder = new Order("order123", 100.0, "token_abc"); // 当mockGateway.charge(100.0, "token_abc")被调用时,返回true when(mockGateway.charge(100.0, "token_abc")).thenReturn(true); // 2. 创建被测试对象,并注入Mock PaymentService service = new PaymentService(mockGateway); // 3. 执行测试 boolean result = service.processPayment(testOrder); // 4. 断言结果 assertTrue(result); // 5. 验证交互 (可选但推荐) // 验证charge方法确实被调用了一次,且参数匹配 verify(mockGateway, times(1)).charge(100.0, "token_abc"); } @Test void testProcessPayment_Failure() { Order testOrder = new Order("order456", 50.0, "token_def"); // 模拟支付失败 when(mockGateway.charge(50.0, "token_def")).thenReturn(false); PaymentService service = new PaymentService(mockGateway); boolean result = service.processPayment(testOrder); assertFalse(result); verify(mockGateway).charge(50.0, "token_def"); // 默认就是times(1) } }

关键点解析:

  • @Mock注解: 告诉Mockito创建一个该接口/类的Mock对象。它的所有方法默认都会返回“空”值(0,false,null等)。
  • when(...).thenReturn(...): 这是“打桩”(Stubbing),用于配置Mock对象在特定调用时的行为。你可以把它理解为“编程”这个Mock对象:“当你用这些参数调用这个方法时,就返回这个值”。
  • verify(...): 这是“验证”(Verification),用于检查Mock对象的方法是否被调用、调用了几次、用什么参数调用的。这确保了你的被测对象与依赖对象之间的交互符合预期。

6.3 Mock vs. Stub vs. Spy

  • Mock: 我们通常说的Mock,指的是像上面例子中那样,一个完全虚拟的对象,其行为完全由测试定义。Mockito创建的默认就是这种。
  • Stub: 侧重于为方法调用提供预设的返回值(就是when().thenReturn()做的事)。Mock对象通常也扮演了Stub的角色。
  • Spy: 是对一个真实对象的“部分Mock”。你可以监视一个真实对象,验证它的某些方法被调用,同时为它的另一些方法打桩。使用@Spy注解。
    @Spy List<String> realList = new ArrayList<>(); @Test void testSpy() { realList.add("a"); realList.add("b"); // 监视真实对象 assertEquals(2, realList.size()); // 为size()方法打桩,改变其行为 when(realList.size()).thenReturn(100); assertEquals(100, realList.size()); // 返回打桩的值 assertEquals("a", realList.get(0)); // 其他方法依然调用真实对象 }

实操心得:Mockito非常强大,但切忌过度使用。一个常见的反模式是“过度Mock”,即把所有的依赖都Mock掉,最后测试的只是一堆你自己设定的规则在空转,失去了测试的意义。单元测试应该聚焦于单元内部的逻辑。对于简单的值对象(如Order)、工具类,直接new一个真实实例就好。Mock应该主要用于那些有外部依赖(IO、网络、数据库)或行为复杂(需要特定返回值才能推动流程)的协作对象。

7. 集成测试与Spring Boot测试

单元测试是基础,但软件是由多个单元组合而成的。集成测试用于验证这些组合在一起是否能正确工作。在Spring Boot生态中,测试框架提供了强大的支持。

7.1@SpringBootTest:启动完整的应用上下文

当你需要测试一个涉及多个Spring Bean(如Service、Repository、Controller)交互的场景时,@SpringBootTest是你的首选。它会启动一个几乎和真实运行环境一样的Spring应用上下文。

import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.TestPropertySource; import static org.junit.jupiter.api.Assertions.*; @SpringBootTest // 启动Spring Boot应用上下文 @TestPropertySource(properties = {"spring.profiles.active=test"}) // 指定测试配置文件 class UserServiceIntegrationTest { @Autowired private UserService userService; // 注入真实的Spring Bean @Autowired private UserRepository userRepository; // 注入Repository @Test @Transactional // 测试后回滚数据,保持测试隔离 void testCreateAndFindUser() { // 准备数据 User newUser = new User("testUser", "test@example.com"); // 执行 User savedUser = userService.createUser(newUser); // 验证 assertNotNull(savedUser.getId()); assertEquals("testUser", savedUser.getUsername()); // 进一步验证数据库交互 User foundUser = userRepository.findById(savedUser.getId()).orElse(null); assertNotNull(foundUser); assertEquals(savedUser.getEmail(), foundUser.getEmail()); } }

关键点:

  • @SpringBootTest默认会尝试查找主配置类(例如@SpringBootApplication标注的类)并启动整个上下文。这可能会比较慢,因为它要加载所有Bean。
  • @Transactional注解在测试中非常有用。它确保测试方法执行后,数据库操作会被回滚,这样每个测试都不会污染数据库,保证了测试的独立性。注意:这需要你的数据库支持事务,并且相关配置正确。
  • @TestPropertySource允许你为测试覆盖特定的配置属性,比如使用内存数据库H2代替MySQL。

7.2 切片测试:更轻量级的集成测试

启动整个应用上下文太重量级了。Spring Boot提供了“切片测试”注解,只加载你关心的那部分Bean,大大加快测试速度。

  • @WebMvcTest: 专门用于测试Spring MVC控制器(Controller)。它会配置相关的MVC设施,但不会加载完整的服务层和数据库层。通常需要Mock掉Service。

    @WebMvcTest(UserController.class) // 只加载UserController相关的Bean class UserControllerTest { @Autowired private MockMvc mockMvc; // 模拟MVC请求的利器 @MockBean // Spring特有的注解,会将一个Mock对象放入Spring上下文 private UserService userService; @Test void getUserById_ShouldReturnUser() throws Exception { User mockUser = new User(1L, "john_doe"); when(userService.getUserById(1L)).thenReturn(mockUser); mockMvc.perform(get("/api/users/1")) // 发起GET请求 .andExpect(status().isOk()) // 期望状态码200 .andExpect(jsonPath("$.username").value("john_doe")); // 验证JSON响应 } }

    MockMvc让你能够在不启动HTTP服务器的情况下,对Controller的请求处理流程进行全栈测试,非常高效。

  • @DataJpaTest: 专门用于测试JPA Repository层。它会配置一个内存数据库(如H2),并只加载与JPA相关的Bean。

    @DataJpaTest class UserRepositoryTest { @Autowired private TestEntityManager entityManager; // 用于持久化测试数据的工具 @Autowired private UserRepository userRepository; @Test void findByUsername_ShouldReturnUser() { // 使用TestEntityManager准备数据 User user = new User("alice", "alice@example.com"); entityManager.persist(user); entityManager.flush(); // 执行Repository查询 User found = userRepository.findByUsername("alice"); // 断言 assertNotNull(found); assertEquals("alice@example.com", found.getEmail()); } }

    这个测试会真实地与数据库(H2)交互,验证你的@Query注解或方法命名是否正确。

  • @JsonTest: 专门测试JSON序列化/反序列化。

  • @RestClientTest: 专门测试RestTemplate或WebClient的客户端。

注意事项:选择哪种测试策略,取决于你要测试的“单元”有多大。遵循测试金字塔原则:大量快速的单元测试(隔离的)作为底座,适量集成测试(@DataJpaTest,@WebMvcTest)作为中间层,少量端到端测试(@SpringBootTest启动完整应用)作为顶层。不要滥用@SpringBootTest,否则你的测试套件会慢得让你不想运行它。

8. 测试中的常见陷阱与最佳实践

写了这么多测试,你可能已经踩过或即将踩到一些坑。这里总结一些血泪教训和行业共识的最佳实践。

8.1 测试命名与结构

  • 测试方法名要具有描述性: 不要用test1,testAdd这种名字。好的测试名应该能清晰地表达测试的场景和预期。一个流行的约定是MethodName_StateUnderTest_ExpectedBehavior

    • add_PositiveNumbers_ReturnsSum
    • withdraw_AmountExceedsBalance_ThrowsInsufficientFundsException
    • processOrder_NullOrder_ThrowsIllegalArgumentException即使不用这么严格的格式,也要做到见名知意。
  • 遵循 Given-When-Then 模式: 在测试方法内部用注释或空行清晰地划分三个阶段,这能让测试逻辑一目了然。

    @Test void withdraw_AmountExceedsBalance_ThrowsException() { // Given (准备) BankAccount account = new BankAccount(100); double withdrawAmount = 200; // When & Then (执行并断言) assertThrows(InsufficientFundsException.class, () -> { account.withdraw(withdrawAmount); }); }

8.2 测试的FIRST原则

好的单元测试应该遵循FIRST原则:

  • Fast (快速): 测试应该能快速运行。如果测试太慢,开发者就不会频繁运行它。
  • Independent (独立): 测试之间不应该有依赖,也不应该依赖外部环境(如数据库顺序、网络状态)。使用@BeforeEach和干净的Fixture来保证。
  • Repeatable (可重复): 在任何环境(开发机、CI服务器)下运行都能得到相同的结果。
  • Self-Validating (自我验证): 测试应该能自己判断通过还是失败,不需要人工检查日志或输出。
  • Timely (及时): 理想情况下,测试代码应该与生产代码同时编写(测试驱动开发TDD)。

8.3 避免这些常见的“反模式”

  1. 测试过于脆弱: 测试依赖于实现细节,而不是公开的API行为。比如,测试一个排序方法时,去断言内部使用的临时列表的中间状态。一旦内部实现改变(比如换了排序算法),测试就毫无理由地失败了。测试行为,而非实现。

  2. 一个测试断言太多事: 一个测试方法应该只测试一件事。如果方法名里出现了“and”,比如testCreateUserAndSendEmail,很可能就应该拆成两个测试。这样当一个失败时,你能精准定位问题。

  3. 不测试边界条件和异常流: 只测试“快乐路径”(正常情况)是远远不够的。要特别关注边界值(0, null, 空字符串,集合的最后一个元素)、非法输入、异常情况。这些地方往往是Bug的温床。

    // 好的测试套件应该包含这些 @Test void add_TwoPositiveNumbers() {...} @Test void add_PositiveAndNegative() {...} @Test void add_WithZero() {...} @Test void add_MaxIntCausesOverflow() {...} // 边界 @Test void divide_ByZero_ThrowsException() {...} // 异常
  4. 忽略测试的可读性: 测试代码也是代码,需要保持清晰。使用有意义的变量名,提取重复的代码到@BeforeEach或工具方法中,但要注意平衡,过度提取可能会让测试逻辑变得隐晦。

  5. 依赖外部服务或不稳定的数据: 单元测试绝不能依赖网络、数据库(除非是内存数据库且数据可控)、文件系统或系统时间。使用Mock和Stub来隔离这些不稳定因素。

8.4 测试覆盖率:是工具,不是目标

大多数IDE和构建工具(如JaCoCo)都能生成测试覆盖率报告。这是一个非常有用的工具,它能直观地告诉你哪些代码行、分支、方法没有被测试到。

但是,请记住:高测试覆盖率不等于高质量测试!你可以写出覆盖率达到100%但毫无用处的测试(比如只调用了方法,从不断言任何东西)。覆盖率报告的作用是发现未被测试的代码盲区,而不是一个必须达到的KPI。追求有意义的、针对核心逻辑和复杂分支的测试,远比盲目追求一个数字更重要。

从今天开始,尝试为你写的每一个新方法,都配套写一个测试方法。一开始可能会觉得慢,但当你重构代码、修复Bug时,那一片绿色的“测试通过”提示,会给你带来无与伦比的安全感和信心。这才是单元测试和JUnit带给开发者的最大价值。