JUnit4单元测试实战:从核心注解到Mockito依赖隔离完整指南

📅 发布时间:2026/7/17 3:53:54 👁️ 浏览次数:
JUnit4单元测试实战:从核心注解到Mockito依赖隔离完整指南
1. 项目概述为什么单元测试是开发者的“安全网”在软件开发的日常里我们常常会遇到这样的场景你信心满满地修改了一个看似无关紧要的公共方法结果上线后一个你从未想过的、八竿子打不着的功能模块突然崩溃了。排查问题花了大半天最后发现是那个“无关紧要”的方法里一个边界条件判断被你不小心改动了。这种“牵一发而动全身”的痛相信很多开发者都经历过。而单元测试就是用来抵御这种风险、为你的代码质量兜底的第一道也是最重要的一道“安全网”。JUnit作为Java领域事实上的单元测试标准框架其重要性不言而喻。而JUnit4虽然现在JUnit5已经普及但它依然是大量遗留项目和许多开发者入门单元测试的“必经之路”。理解JUnit4不仅是掌握一个工具更是理解单元测试的核心思想隔离、验证、自动化。所谓“实战详解与实例解析”就是要跳出官方文档的条条框框从一个实际开发者的角度去拆解如何用JUnit4写出有效、可维护、能真正发现问题的测试代码而不是为了凑覆盖率而写的“摆设”。这篇文章我会结合我过去在多个项目中推行单元测试的经验从最基础的注解使用到复杂的依赖隔离Mock再到测试代码的组织与优化用大量真实的代码示例带你走一遍完整的JUnit4实战流程。无论你是刚接触单元测试的新手还是想优化现有测试套件的老手都能从中找到可以直接“抄作业”的实用技巧和避坑指南。2. JUnit4核心注解与生命周期深度拆解很多教程一上来就讲Test这没错但如果不理解JUnit4执行测试方法的完整生命周期你很容易写出一些看似能跑通但实际上存在隐患的测试。JUnit4的魔力很大程度上就藏在几个核心注解和它们的执行顺序里。2.1 四大金刚Test, Before, After, BeforeClass/AfterClassTest这是测试方法的标志。但一个常见的误区是认为Test方法里什么都能写。实际上一个良好的单元测试方法应该遵循“3A”原则准备Arrange、执行Act、断言Assert。先准备测试数据和预期结果再调用被测方法最后验证实际结果是否符合预期。Test public void testAdd() { // Arrange: 准备 Calculator calculator new Calculator(); int a 5; int b 3; int expected 8; // Act: 执行 int actual calculator.add(a, b); // Assert: 断言 assertEquals(加法计算错误, expected, actual); }Before和After这两个注解的方法会在每个Test方法执行之前和之后运行。它们是用来做测试的“准备”和“清理”工作的绝佳位置。public class UserServiceTest { private UserService userService; private DatabaseConnection mockConnection; // 假设的依赖 Before public void setUp() { // 每个测试方法前都新建一个干净的UserService实例 mockConnection Mockito.mock(DatabaseConnection.class); userService new UserService(mockConnection); System.out.println(setUp: 准备测试环境); } After public void tearDown() { // 每个测试方法后进行清理如关闭临时文件、重置静态变量等 userService null; System.out.println(tearDown: 清理测试环境); } Test public void testMethod1() { /* ... */ } Test public void testMethod2() { /* ... */ } }运行testMethod1和testMethod2你会看到setUp和tearDown各执行了两次。这确保了测试之间的隔离性一个测试的失败不会污染另一个测试的环境。BeforeClass和AfterClass这两个注解的方法是静态的static它们在整个测试类所有测试开始前和所有测试结束后各执行一次。通常用于执行非常耗资源的初始化比如建立数据库连接池、加载大型配置文件。public class ExpensiveResourceTest { private static DataSource dataSource; BeforeClass public static void setUpClass() { // 模拟一个耗时的资源初始化比如连接池 dataSource createHikariDataSource(); System.out.println(setUpClass: 初始化昂贵资源只执行一次); } AfterClass public static void tearDownClass() { if (dataSource ! null) { dataSource.close(); } System.out.println(tearDownClass: 关闭昂贵资源只执行一次); } Test public void testQuery1() { // 使用 dataSource System.out.println(testQuery1 执行); } Test public void testQuery2() { // 使用 dataSource System.out.println(testQuery2 执行); } }一个非常重要的实操心得绝对不要在Before或BeforeClass里初始化那些会被测试方法修改状态的对象尤其是静态对象。比如你在Before里初始化了一个List并添加了一些数据然后在某个Test方法里清空了这个List。那么下一个Test方法运行时Before会再次执行重新初始化List吗会但这意味着前一个测试对List的修改影响了后一个测试的初始状态吗从逻辑上看没有因为Before重新创建了对象。但关键在于这可能会掩盖一些并发或状态残留的问题。最安全的做法是在Before里只做最小化、无状态的准备工作比如创建对象实例、Mock依赖。测试数据最好在Test方法内部“按需创建”。2.2 被忽视的利器Ignore 与 超时、异常测试Ignore这个注解太有用了但经常被低估。当一个测试暂时失败比如依赖的外部接口挂了或者你正在重构代码测试还没适配好时与其注释掉测试方法这会导致你彻底忘记它不如加上Ignore并附上原因。这样测试报告会明确显示有多少测试被忽略了提醒你后续处理。Test Ignore(待第三方服务恢复后启用 - 张三 2023-10-27) public void testExternalApiCall() { // 调用一个暂时不可用的外部API }超时测试Test(timeout毫秒)用于检测方法执行是否超时。这对于测试那些可能有死循环、或依赖网络请求性能的方法非常有用。Test(timeout 1000) // 1秒内必须执行完 public void testPerformanceCriticalMethod() { // 执行一个性能关键的方法 someService.processLargeData(); }注意超时测试的结果可能受运行环境如CI服务器负载影响所以超时时间要设置得合理不要太极限。异常测试Test(expected异常类.class)用于验证方法在特定输入下是否会抛出预期的异常。Test(expected IllegalArgumentException.class) public void testDivideByZero() { Calculator calculator new Calculator(); calculator.divide(10, 0); // 预期这里会抛出IllegalArgumentException }踩过的坑expected属性只能验证异常类型无法验证异常信息message。如果你需要验证异常的具体信息或者异常是在一段代码块中抛出的而不是测试方法直接抛出更推荐使用JUnit4的ExpectedException规则Rule或者try-catch-assert模式。3. 断言的艺术从assertEquals到Hamcrest断言是测试的灵魂它决定了测试的“检查点”是否精准。JUnit4自带的org.junit.Assert类提供了很多基础的断言方法但用好它们需要技巧。3.1 基础断言不仅仅是assertEqualsassertEquals(expected, actual)是最常用的但要注意参数顺序。习惯上把期望值放前面实际值放后面这样断言失败时的错误信息更易读。对于浮点数比较务必使用assertEquals(double expected, double actual, double delta)其中delta是允许的误差范围。Test public void testFloatCalculation() { double result 10.0 / 3.0; // 错误写法assertEquals(3.333, result); // 很可能失败因为浮点数精度问题 // 正确写法 assertEquals(3.333, result, 0.001); // 允许0.001的误差 }assertTrue和assertFalse用于布尔条件。assertNull和assertNotNull用于对象空值判断。assertSame和assertNotSame用于判断是否是同一个对象引用而assertEquals判断的是equals()。一个关键技巧总是为断言提供有意义的失败信息message参数。当测试失败时一个清晰的错误信息能帮你快速定位问题。Test public void testUserCreation() { User user userService.createUser(testUser); // 不好的写法 assertNotNull(user); // 好的写法 assertNotNull(创建用户后返回的对象不应为null, user); assertEquals(新创建用户的用户名应一致, testUser, user.getUsername()); }3.2 进阶选择Hamcrest匹配器JUnit4自带的断言有时在表达复杂条件时显得笨拙。Hamcrest库通过提供一组可读性极高的“匹配器Matcher”让断言语句读起来像自然语言。你需要额外引入hamcrest-library依赖。import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; Test public void testWithHamcrest() { ListString result someService.getItems(); // 断言集合包含特定元素 assertThat(result, hasItem(expectedItem)); // 断言集合大小 assertThat(result, hasSize(5)); // 断言集合包含多个元素顺序无关 assertThat(result, containsInAnyOrder(a, b, c)); // 断言对象多个属性 User user getUser(); assertThat(user, allOf( hasProperty(name, is(张三)), hasProperty(age, greaterThan(18)) )); // 断言数字范围 int score getScore(); assertThat(score, allOf(greaterThanOrEqualTo(0), lessThanOrEqualTo(100))); }Hamcrest的强大在于其组合能力你可以用allOf与、anyOf或、not非来组合复杂的断言条件代码的意图一目了然。我个人的体会是在团队项目中推广Hamcrest能显著提升测试代码的可读性和可维护性。4. 依赖隔离实战当你的代码需要“朋友”单元测试的核心是“单元”意味着我们要把被测试的类或方法隔离出来让它依赖的外部组件数据库、网络服务、文件系统、其他复杂类都变成可控的“演员”。这就是Mock模拟和Stub桩的概念。在JUnit4生态中Mockito是进行依赖隔离的绝对主力。4.1 Mockito核心模拟行为与验证交互假设我们有一个OrderService它依赖PaymentGateway支付网关和InventoryService库存服务。public class OrderService { private PaymentGateway paymentGateway; private InventoryService inventoryService; public OrderService(PaymentGateway paymentGateway, InventoryService inventoryService) { this.paymentGateway paymentGateway; this.inventoryService inventoryService; } public boolean placeOrder(Order order) { // 1. 检查库存 if (!inventoryService.isItemInStock(order.getItemId(), order.getQuantity())) { return false; } // 2. 尝试支付 boolean paymentSuccess paymentGateway.charge(order.getTotalAmount(), order.getCreditCard()); if (!paymentSuccess) { return false; } // 3. 扣减库存 inventoryService.reduceStock(order.getItemId(), order.getQuantity()); return true; } }我们要测试placeOrder方法但绝不能真的去调用支付网关和库存数据库。这时就用Mockito。import static org.mockito.Mockito.*; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; RunWith(MockitoJUnitRunner.class) // 使用Mockito的JUnit运行器 public class OrderServiceTest { Mock private PaymentGateway mockPaymentGateway; // 声明Mock对象 Mock private InventoryService mockInventoryService; InjectMocks private OrderService orderService; // Mockito会自动将上面的mock注入到这里 Test public void testPlaceOrder_Success() { // Arrange Order testOrder new Order(item123, 2, 100.0, card_xxx); // 设定Mock对象的行为当调用isItemInStock时返回true when(mockInventoryService.isItemInStock(item123, 2)).thenReturn(true); // 设定Mock对象的行为当调用charge时返回true when(mockPaymentGateway.charge(100.0, card_xxx)).thenReturn(true); // Act boolean result orderService.placeOrder(testOrder); // Assert assertTrue(result); // 验证交互reduceStock方法被调用了一次且参数是特定的 verify(mockInventoryService, times(1)).reduceStock(item123, 2); // 验证charge方法被调用了一次 verify(mockPaymentGateway, times(1)).charge(100.0, card_xxx); } Test public void testPlaceOrder_OutOfStock() { // Arrange Order testOrder new Order(item123, 2, 100.0, card_xxx); // 模拟库存不足 when(mockInventoryService.isItemInStock(item123, 2)).thenReturn(false); // Act boolean result orderService.placeOrder(testOrder); // Assert assertFalse(result); // 验证因为库存不足后续的支付和扣库存都不应该发生 verify(mockPaymentGateway, never()).charge(anyDouble(), anyString()); verify(mockInventoryService, never()).reduceStock(anyString(), anyInt()); } }关键点解析RunWith(MockitoJUnitRunner.class) 这是关键它告诉JUnit使用Mockito的测试运行器这样才能让Mock和InjectMocks注解生效。Mock 创建一个Mock对象。这个对象的所有方法默认返回“空”值0, false, null等。InjectMocks 创建被测类的真实实例并自动将上面声明的Mock对象注入进去通过构造函数或setter方法。when(...).thenReturn(...) 这是打桩Stubbing用于定义当Mock对象的某个方法以特定参数被调用时应该返回什么值。这是为了给测试提供必要的输入。verify(...) 这是验证Verification用于检查在测试执行过程中Mock对象的某个方法是否被调用、调用了几次、参数是什么。这是为了验证被测对象的行为是否符合预期。4.2 高级Mock技巧与常见陷阱参数匹配器Argument Matchers 当你不关心具体的参数值或者参数是复杂对象时可以使用any(),anyString(),anyInt(),eq()等匹配器。// 模拟无论传入什么字符串都返回true when(mockService.doSomething(anyString())).thenReturn(true); // 验证方法被调用且第一个参数是abc第二个参数是任意整数 verify(mockService).doSomething(eq(abc), anyInt());注意一旦在某个方法调用中使用了一个参数匹配器那么该方法的所有参数都必须使用匹配器。不能混合使用具体值和匹配器eq()除外它本身就是匹配器。抛出异常 可以模拟方法抛出异常。when(mockFileService.readFile(anyString())).thenThrow(new IOException(File not found));验证调用顺序 使用InOrder对象。InOrder inOrder inOrder(mockA, mockB); inOrder.verify(mockA).method1(); inOrder.verify(mockB).method2(); // 确保method1在method2之前被调用一个巨大的坑过度Mock。Mock是为了隔离不可控依赖。但如果你把被测类依赖的所有其他类都Mock了甚至把同一个模块下的其他类也Mock了那你的测试就变成了“在真空中测试”失去了意义。单元测试应该聚焦于当前类的逻辑Mock掉的是真正的外部依赖如数据库、HTTP客户端、第三方SDK。对于同一个模块内、共同完成一个业务逻辑的多个类应该通过集成测试来覆盖。5. 测试套件与参数化测试提升测试效率当项目变大测试类越来越多如何组织和管理它们如何对同一个方法用多组不同数据进行测试JUnit4提供了相应的工具。5.1 测试套件Test Suite测试套件允许你将多个测试类组合在一起运行。这在你想按模块或功能分组运行测试时非常有用。import org.junit.runner.RunWith; import org.junit.runners.Suite; RunWith(Suite.class) Suite.SuiteClasses({ CalculatorTest.class, UserServiceTest.class, OrderServiceTest.class }) public class AllUnitTests { // 这个类本身是空的只是一个套件的容器 }运行AllUnitTests就会依次运行CalculatorTest、UserServiceTest和OrderServiceTest中的所有测试方法。在IDE或Maven/Gradle中配置运行这个套件类即可。5.2 参数化测试Parameterized Tests如果你有一个方法其逻辑需要针对多种不同的输入组合进行验证写多个几乎相同的Test方法非常冗余。参数化测试可以解决这个问题。你需要使用RunWith(Parameterized.class)并提供一个返回数据集合的Parameters方法。import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; import org.junit.runners.Parameterized.Parameters; import java.util.Arrays; import java.util.Collection; import static org.junit.Assert.assertEquals; RunWith(Parameterized.class) public class FibonacciTest { // 这两个字段会被注入 private int input; private int expected; // 构造函数参数顺序和Parameters方法返回的集合中每个Object数组的顺序一致 public FibonacciTest(int input, int expected) { this.input input; this.expected expected; } // 提供测试数据 Parameters(name {index}: fib({0}) {1}) // 为每个测试用例生成易读的名字 public static CollectionObject[] data() { return Arrays.asList(new Object[][] { { 0, 0 }, { 1, 1 }, { 2, 1 }, { 3, 2 }, { 4, 3 }, { 5, 5 }, { 6, 8 } }); } Test public void testFibonacci() { Fibonacci fib new Fibonacci(); assertEquals(expected, fib.compute(input)); } }运行这个测试类JUnit会为data()方法返回的每一组数据如{0,0},{1,1}都创建一个FibonacciTest实例并运行一次testFibonacci()方法。测试报告里会清晰地显示7个测试用例。实操心得参数化测试非常适合测试纯函数无副作用输出只由输入决定和包含复杂分支条件的方法。它能极大减少重复代码让测试用例的输入和预期输出一目了然。但要注意如果测试逻辑本身很复杂比如需要为每组数据准备不同的Mock行为参数化测试可能会让测试方法变得难以理解这时或许拆分成多个独立的Test方法更清晰。6. 测试代码的组织与可维护性实践写测试代码和写生产代码一样需要良好的设计和规范。糟糕的测试代码会成为项目的负担而不是资产。6.1 命名规范让人一眼看懂测试意图测试方法的命名应该清晰地表达它在测试什么以及在什么条件下。一个流行的约定是methodName_StateUnderTest_ExpectedBehavior。calculateTax_IncomeBelowThreshold_ReturnsZeroplaceOrder_ItemOutOfStock_ReturnsFalsesaveUser_NullUsername_ThrowsIllegalArgumentException即使不使用这么长的命名也至少要像testPlaceOrderSuccess、testPlaceOrderOutOfStock这样让人一眼就知道区别。6.2 遵循单一职责原则SRP一个Test方法应该只测试一个特定的行为或场景。不要在一个测试方法里验证多个不相关的逻辑。如果某个方法有多个重要的执行路径比如if-else的不同分支应该为每个路径写一个单独的测试方法。反面教材Test public void testUserService() { // 测试创建用户 User u1 service.createUser(...); assertNotNull(u1); // 测试查找用户 User u2 service.findUser(...); assertEquals(u1, u2); // 测试删除用户 service.deleteUser(...); assertNull(service.findUser(...)); }这个测试方法混合了创建、查找、删除三个操作。如果“查找”测试失败了你很难定位是查找逻辑本身有问题还是前面“创建”的步骤没成功。而且测试报告只会显示testUserService失败了信息量很少。6.3 使用“准备-执行-断言”AAA模式前面提到过这是组织测试方法结构的黄金法则。把准备数据的代码Arrange、调用被测方法的代码Act、验证结果的代码Assert清晰地分开通常用空行隔开。这能让测试逻辑非常清晰。Test public void testApplyDiscount_EligibleCustomer_GetsDiscount() { // Arrange Customer customer new Customer(true); // 合格客户 Order order new Order(100.0); DiscountService service new DiscountService(); // Act double finalPrice service.applyDiscount(order, customer); // Assert assertEquals(90.0, finalPrice, 0.001); // 打9折 }6.4 测试数据的构建使用Builder或Helper方法如果创建测试对象很复杂不要在每个测试方法里重复一堆setXXX。可以使用建造者模式Builder Pattern为你的领域对象创建测试专用的Builder。在测试类里创建私有的Helper方法来生成常用的测试对象。public class TestDataHelper { public static Order createStandardOrder() { Order order new Order(); order.setId(UUID.randomUUID().toString()); order.setAmount(100.0); order.setStatus(OrderStatus.PENDING); // ... 设置其他默认值 return order; } public static Order createOrderWithItem(String itemId, int quantity) { Order order createStandardOrder(); order.addItem(new OrderItem(itemId, quantity)); return order; } }这样你的测试方法就会非常简洁Test public void testSomething() { // Arrange Order testOrder TestDataHelper.createOrderWithItem(ITM001, 2); // ... 其他准备和断言 }6.5 保持测试的独立性与幂等性每个测试都应该是独立的不依赖于其他测试的运行顺序或结果。这也是为什么我们要在Before中初始化在After中清理。同时测试应该是幂等的即运行一次和运行多次的结果应该一样。这意味着测试不能依赖外部可变状态比如数据库里一条特定的记录或者每次运行都要能自己创建并清理所需的环境。7. 集成测试与Spring环境下的JUnit4单元测试是基础但有些逻辑涉及Spring容器、数据库事务等需要启动一个轻量级的集成环境来测试。这时我们会用到Spring TestContext Framework。7.1 配置Spring测试环境你需要引入spring-test依赖并使用RunWith(SpringJUnit4ClassRunner.class)和ContextConfiguration注解。RunWith(SpringJUnit4ClassRunner.class) // 指定Spring的测试运行器 ContextConfiguration(classes {TestConfig.class}) // 指定Spring配置类 public class UserServiceIntegrationTest { Autowired private UserService userService; // 从Spring容器中注入真实的Bean Autowired private DataSource dataSource; // 可以注入其他Bean如数据源 Test Transactional // 使用事务测试后自动回滚不污染数据库 public void testCreateAndFindUser() { // Arrange User newUser new User(integrationUser); // Act User savedUser userService.save(newUser); User foundUser userService.findById(savedUser.getId()); // Assert assertNotNull(foundUser); assertEquals(savedUser.getUsername(), foundUser.getUsername()); // 由于有Transactional保存的数据在测试结束后会被回滚 } }这里的TestConfig.class是一个专门用于测试的Spring配置类它可能配置了一个内存数据库如H2和必要的基础设施。7.2 使用MockBean与SpyBean在集成测试中你可能只想Mock掉其中的一两个外部依赖比如一个调用远程API的EmailService而其他部分如数据库访问的Repository使用真实的Bean。Spring Boot提供了MockBean和SpyBean注解。RunWith(SpringRunner.class) // Spring Boot 1.4 的简化写法等价于SpringJUnit4ClassRunner SpringBootTest // 加载完整的Spring Boot应用上下文或指定类 public class OrderServiceIntegrationTest { Autowired private OrderService orderService; // 真实的OrderService MockBean private PaymentGateway paymentGateway; // Mock掉支付网关 Autowired private OrderRepository orderRepository; // 使用真实的Repository操作内存数据库 Test Transactional public void testPlaceOrderIntegration_Success() { // Arrange Order order new Order(...); orderRepository.save(order); // 先持久化到测试数据库 when(paymentGateway.charge(anyDouble(), anyString())).thenReturn(true); // Mock支付成功 // Act boolean result orderService.placeOrder(order.getId()); // Assert assertTrue(result); Order updatedOrder orderRepository.findById(order.getId()).orElse(null); assertNotNull(updatedOrder); assertEquals(OrderStatus.PAID, updatedOrder.getStatus()); // 验证状态已更新 verify(paymentGateway).charge(eq(order.getTotalAmount()), anyString()); } }MockBean会在Spring应用上下文中用Mockito的Mock对象替换掉原有类型的Bean。这样OrderService里注入的PaymentGateway就是被我们控制行为的Mock对象了而OrderRepository和数据库交互的部分则是真实的。重要提示集成测试比单元测试慢得多因为它们要启动Spring容器、连接数据库等。所以应该将集成测试和单元测试分开通常单元测试放在src/test/java下的某个包如unit集成测试放在另一个包如integration并使用不同的Maven/Gradle profile或任务来分别运行。8. 常见问题排查与性能优化实录即使掌握了所有技术点在实际编写和运行测试时你依然会遇到各种各样的问题。这里记录一些我踩过的坑和解决方案。8.1 测试失败排查清单问题现象可能原因排查步骤与解决方案java.lang.NoClassDefFoundError或ClassNotFoundException缺少依赖库。JUnit、Hamcrest、Mockito等jar包没有正确引入项目。1. 检查构建工具Maven/Gradle的pom.xml或build.gradle文件确保test作用域的依赖已添加。2. 在IDE中检查项目的Build Path或Dependencies。3. 运行mvn dependency:tree或gradle dependencies查看依赖树。Mock或InjectMocks注解不生效字段为null没有使用Mockito的测试运行器。在测试类上添加注解RunWith(MockitoJUnitRunner.class)。对于Spring集成测试使用RunWith(SpringRunner.class)。测试方法被跳过显示为Ignored测试方法上加了Ignore注解。移除Ignore注解或者检查是否满足Ignore中注释的条件。AssertionError但错误信息不清晰断言时没有提供自定义的错误信息message参数。为assertEquals,assertTrue等断言方法添加第一个String类型的message参数描述断言的目的。Mock对象的方法没有按预期返回设定的值1. 打桩when(...).thenReturn(...)的代码在调用方法之后执行。2. 方法调用的参数与打桩时指定的参数不匹配。1. 确保打桩代码在调用被测方法之前执行。2. 检查参数是否完全一致。使用eq()匹配器或any()系列匹配器来放宽条件。使用verify(mock, times(0))来检查方法是否被意外调用。测试在CI服务器上失败本地却成功1. 环境差异时区、编码、文件路径。2. 测试依赖外部服务或资源CI环境不可用。3. 测试非幂等依赖了本地数据库的特定状态。4. 并发问题。1. 确保测试不依赖本地环境。使用相对路径避免硬编码。2. 将对外部服务的依赖彻底Mock掉。3. 使用内存数据库H2并在Before中初始化数据After中清理。4. 检查测试是否使用了静态变量或共享资源确保每个测试是独立的。测试运行速度极慢1. 在BeforeClass中初始化了过于耗时的资源如连接真实数据库。2. 每个测试都重复创建重量级对象。3. 测试本身执行了慢操作如文件IO、网络请求。1. 评估资源是否真的需要在类级别共享或许可以移到Before中或懒加载。2. 使用轻量级替代品如Mock对象、内存数据库。3. 对慢操作进行Mock。使用Rule和Timeout规则为测试类设置全局超时。8.2 提升测试套件性能隔离慢测试 将运行时间长的集成测试、端到端测试与快速的单元测试分开。可以使用JUnit Categories分类或不同的Maven模块/Gradle SourceSet。并行运行测试 现代构建工具和IDE都支持并行运行测试。在Maven的surefire插件中配置parallelmethods/parallel和threadCount4/threadCount。注意并行测试要求测试之间绝对独立不能有共享状态。重用Spring上下文 对于Spring集成测试启动容器是最耗时的部分。Spring TestContext Framework默认会为每个测试类缓存应用上下文。确保你的测试配置ContextConfiguration是相同的以最大化上下文重用。使用内存数据库 在测试中绝对不要连接生产或开发数据库。使用H2、HSQLDB等内存数据库它们速度快且易于清理。避免不必要的日志 测试中过多的日志输出尤其是到控制台会拖慢速度。在测试的logback或log4j配置中将日志级别设为WARN或ERROR。8.3 测试代码的“坏味道”与重构脆弱测试 对内部实现细节如私有字段、特定方法调用顺序断言过多导致生产代码稍有改动即使是重构不改变功能测试就失败。应对 测试应该关注公共API的行为和输出而不是实现细节。冗长测试 一个测试方法有几十行准备代码。应对 提取Helper方法或使用Builder模式构建测试数据。重复测试 多个测试方法有大量相同的准备代码。应对 将公共准备逻辑移到Before方法或父类中。测试逻辑复杂 测试方法里充满了条件判断和循环。应对 测试代码应该简单直白。如果测试逻辑复杂说明被测方法可能职责过多需要考虑拆分。忽略测试 代码库中存在大量被Ignore的测试。应对 定期审查被忽略的测试修复它们或直接删除。被忽略的测试会给人“测试覆盖良好”的假象。编写单元测试是一个需要持续练习和反思的技能。它不仅仅是验证代码正确性的工具更是一种设计驱动。当你发现一个类很难测试时这往往是一个强烈的信号提示你的代码耦合度太高、职责不够单一是时候进行重构了。从这个角度看单元测试是提升代码质量的最佳实践之一而JUnit4则是你实践这一理念的得力伙伴。从今天开始为你写的每一段核心业务逻辑都配上一个简洁、清晰、高效的单元测试吧。