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部分加入以下依赖。注意scope是test这意味着这些库只在编译和运行测试代码时可用不会被打进最终的生产包。dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter/artifactId version5.10.0/version !-- 请使用当时最新稳定版 -- scopetest/scope /dependencyGradle配置 (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/java和src/test/java的目录分离。这不仅仅是约定更是为了利用构建工具的“测试作用域”特性。你的测试代码可以访问主代码但主代码完全不知道测试代码的存在保持了清晰的边界。很多IDE如IntelliJ IDEA在创建新类时都可以通过快捷键如CtrlShiftT/CmdShiftT直接跳转到对应测试类或创建测试类前提就是目录结构正确。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)); } }我们来拆解一下这个测试Test注解 这是JUnit的“魔法标记”。任何被它标注的方法都会被JUnit识别为一个独立的测试用例。方法名最好能清晰地描述测试意图比如testAdd_PositiveNumbers。assertEquals方法 这是最核心的“断言”方法。它来自Assertions类我们通过static importimport static ...直接使用让代码更简洁。它的作用是判断实际值result是否等于期望值8。如果不相等测试就会失败并打印出可选的错误信息“5 3 应该等于 8”。测试结构 (AAA模式) 这是一个良好的测试编写模式。Arrange (准备) 设置测试所需的所有数据和对象状态。这里我们创建了Calculator实例和输入参数。Act (执行) 调用你要测试的那个方法。Assert (断言) 验证执行结果是否符合预期。在IDE里你通常可以在测试类或方法上右键选择“Run ...Test”。如果一切正常你会看到一个绿色的进度条或对勾。如果assertEquals失败你会看到一个红色的失败提示并清晰地看到期望值和实际值是什么。3. 深入断言不止是“相等”断言是测试的灵魂它定义了什么是“正确”。JUnit Jupiter的Assertions类提供了丰富的断言方法远不止一个assertEquals。3.1 基础断言方法详解assertEquals(expected, actual)/assertNotEquals(...): 判断相等或不相等。对于对象默认使用equals()方法进行比较。这里有个大坑对于浮点数float,double永远不要直接用assertEquals因为浮点数计算有精度损失。必须使用重载版本指定一个可接受的误差范围deltaTest 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() { ListString 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, johnexample.com); assertAll(person, () - assertEquals(John, person.getName()), () - assertEquals(30, person.getAge()), () - assertTrue(person.getEmail().contains()) ); // 如果name断言失败age和email的断言依然会执行并在最终报告中一并显示。 }注意事项assertAll非常适合用来验证一个复杂对象的多个属性或者一个操作产生的多个副作用。它能给你一个完整的“测试报告”而不是零碎的信息。但也要注意不要滥用如果一个测试方法需要验证太多不相关的东西可能意味着它应该被拆分成多个更专注的测试。4. 测试生命周期与FixtureBeforeEach,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默认不保证测试方法的执行顺序。你不能假设testA在testB之前运行。每个测试都应该能独立运行并产生正确的结果。为什么因为当测试套件变得庞大并行运行测试是提高效率的常见手段。如果测试之间有依赖并行运行就会出错。BeforeEach和AfterEach正是为了保障这种独立性而设计的它们确保每个测试都在一个已知的初始状态下开始。踩过的坑早期我写过这样的测试testCreateUser创建一个用户testLoginUser尝试用这个用户登录。当测试顺序改变或单独运行testLoginUser时它就失败了。这就是典型的测试耦合。正确的做法是每个测试都自己准备所需的数据。如果创建用户的逻辑很复杂可以把它提取到一个BeforeEach方法里或者更优雅地提取到一个工具方法中让两个测试都去调用它但数据本身如用户ID是各自独立的。5. 高级测试场景参数化、条件化与超时基础测试能覆盖大部分场景但现实世界的代码更复杂。JUnit 5提供了一些强大的扩展功能来处理这些高级场景。5.1 参数化测试用多组数据驱动一个测试如果你的一个方法需要对多种不同的输入进行测试写一堆几乎相同的Test方法非常枯燥。参数化测试ParameterizedTest允许你定义一个数据源然后测试方法会为数据源中的每一组数据运行一次。首先你需要引入额外的依赖Mavendependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter-params/artifactId version5.10.0/version scopetest/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, // a2, b3, expected5 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 StreamString 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) // 只在系统属性envci时运行 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 groupIdorg.mockito/groupId artifactIdmockito-core/artifactId version5.11.0/version !-- 使用最新版本 -- scopetest/scope /dependency !-- 如果需要与JUnit 5更深度集成使用ExtendWith可以引入mockito-junit-jupiter -- dependency groupIdorg.mockito/groupId artifactIdmockito-junit-jupiter/artifactId version5.11.0/version scopetest/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. SpyMock 我们通常说的Mock指的是像上面例子中那样一个完全虚拟的对象其行为完全由测试定义。Mockito创建的默认就是这种。Stub 侧重于为方法调用提供预设的返回值就是when().thenReturn()做的事。Mock对象通常也扮演了Stub的角色。Spy 是对一个真实对象的“部分Mock”。你可以监视一个真实对象验证它的某些方法被调用同时为它的另一些方法打桩。使用Spy注解。Spy ListString 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.1SpringBootTest启动完整的应用上下文当你需要测试一个涉及多个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.activetest}) // 指定测试配置文件 class UserServiceIntegrationTest { Autowired private UserService userService; // 注入真实的Spring Bean Autowired private UserRepository userRepository; // 注入Repository Test Transactional // 测试后回滚数据保持测试隔离 void testCreateAndFindUser() { // 准备数据 User newUser new User(testUser, testexample.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, aliceexample.com); entityManager.persist(user); entityManager.flush(); // 执行Repository查询 User found userRepository.findByUsername(alice); // 断言 assertNotNull(found); assertEquals(aliceexample.com, found.getEmail()); } }这个测试会真实地与数据库H2交互验证你的Query注解或方法命名是否正确。JsonTest: 专门测试JSON序列化/反序列化。RestClientTest: 专门测试RestTemplate或WebClient的客户端。注意事项选择哪种测试策略取决于你要测试的“单元”有多大。遵循测试金字塔原则大量快速的单元测试隔离的作为底座适量集成测试DataJpaTest,WebMvcTest作为中间层少量端到端测试SpringBootTest启动完整应用作为顶层。不要滥用SpringBootTest否则你的测试套件会慢得让你不想运行它。8. 测试中的常见陷阱与最佳实践写了这么多测试你可能已经踩过或即将踩到一些坑。这里总结一些血泪教训和行业共识的最佳实践。8.1 测试命名与结构测试方法名要具有描述性 不要用test1,testAdd这种名字。好的测试名应该能清晰地表达测试的场景和预期。一个流行的约定是MethodName_StateUnderTest_ExpectedBehavior。add_PositiveNumbers_ReturnsSumwithdraw_AmountExceedsBalance_ThrowsInsufficientFundsExceptionprocessOrder_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 避免这些常见的“反模式”测试过于脆弱 测试依赖于实现细节而不是公开的API行为。比如测试一个排序方法时去断言内部使用的临时列表的中间状态。一旦内部实现改变比如换了排序算法测试就毫无理由地失败了。测试行为而非实现。一个测试断言太多事 一个测试方法应该只测试一件事。如果方法名里出现了“and”比如testCreateUserAndSendEmail很可能就应该拆成两个测试。这样当一个失败时你能精准定位问题。不测试边界条件和异常流 只测试“快乐路径”正常情况是远远不够的。要特别关注边界值0 null 空字符串集合的最后一个元素、非法输入、异常情况。这些地方往往是Bug的温床。// 好的测试套件应该包含这些 Test void add_TwoPositiveNumbers() {...} Test void add_PositiveAndNegative() {...} Test void add_WithZero() {...} Test void add_MaxIntCausesOverflow() {...} // 边界 Test void divide_ByZero_ThrowsException() {...} // 异常忽略测试的可读性 测试代码也是代码需要保持清晰。使用有意义的变量名提取重复的代码到BeforeEach或工具方法中但要注意平衡过度提取可能会让测试逻辑变得隐晦。依赖外部服务或不稳定的数据 单元测试绝不能依赖网络、数据库除非是内存数据库且数据可控、文件系统或系统时间。使用Mock和Stub来隔离这些不稳定因素。8.4 测试覆盖率是工具不是目标大多数IDE和构建工具如JaCoCo都能生成测试覆盖率报告。这是一个非常有用的工具它能直观地告诉你哪些代码行、分支、方法没有被测试到。但是请记住高测试覆盖率不等于高质量测试你可以写出覆盖率达到100%但毫无用处的测试比如只调用了方法从不断言任何东西。覆盖率报告的作用是发现未被测试的代码盲区而不是一个必须达到的KPI。追求有意义的、针对核心逻辑和复杂分支的测试远比盲目追求一个数字更重要。从今天开始尝试为你写的每一个新方法都配套写一个测试方法。一开始可能会觉得慢但当你重构代码、修复Bug时那一片绿色的“测试通过”提示会给你带来无与伦比的安全感和信心。这才是单元测试和JUnit带给开发者的最大价值。