JUnit单元测试实战指南:从核心原理到Spring Boot集成

发布时间:2026/7/18 13:21:37
JUnit单元测试实战指南:从核心原理到Spring Boot集成 1. 项目概述为什么我们需要认真对待JUnit测试代码写代码尤其是写业务逻辑代码对很多开发者来说是件充满创造性和成就感的事。但一提到写测试尤其是单元测试不少人的第一反应可能就是“麻烦”、“浪费时间”、“业务代码都写不完哪有空写测试”。我干了十多年开发带过不少团队见过太多因为测试缺失或敷衍了事而引发的线上事故——一个看似简单的边界条件没覆盖导致深夜报警一次普通的代码重构因为缺乏测试保障引入了难以察觉的回归缺陷。所以当有人问“JUnit测试代码如何写”时我觉得这背后真正的问题是如何高效地编写出高质量、可维护、真正能为代码质量兜底的测试而不是为了应付KPI或者凑覆盖率而写的“垃圾测试”。JUnit作为Java领域事实上的单元测试标准框架它的使用门槛其实很低加个Test注解用几个assertEquals一个测试方法就出来了。但要把测试写好、写对、写出价值这里面门道就深了。它不仅仅是技术问题更是一种工程实践和思维习惯。好的测试代码应该是项目的“活文档”是代码变更的“安全网”更是设计质量的“照妖镜”。这篇内容我就结合自己踩过的无数坑和总结的经验从“代码分析”的视角深入聊聊JUnit测试代码到底该怎么写。我们不只讲语法更要讲在真实项目中面对复杂的业务逻辑、外部依赖、数据状态时如何构建一套健壮、高效且易于维护的测试体系。2. 测试代码的核心设计哲学与结构拆解在动手写第一行测试代码之前我们必须先建立起正确的认知。测试代码不是生产代码的附属品它本身就是产品的一部分需要同等的设计、维护和重构关注度。2.1 单元测试的“单元”究竟是什么很多人对“单元”的理解停留在“一个类”或“一个方法”。这不够精确容易导致测试过于庞大和脆弱。我更倾向于Martin Fowler提出的“单元”定义一个单元测试通常验证一个“行为单元”Unit of Behavior。这个行为单元可能是一个公开方法但更关键的是它验证的是这个方法在特定输入和特定对象状态下所产生的特定输出或副作用。这意味着一个类可能有多个公开方法而一个公开方法在不同的前置条件fixture下可能对应多个测试用例。例如一个UserService的login方法我们需要测试“用户名密码正确”、“用户名不存在”、“密码错误”、“账户被锁定”等多种场景。每个场景都是一个独立的“行为单元”对应一个或多个测试方法。设计启示不要试图在一个Test方法里验证一个完整业务方法的所有分支。遵循“Given-When-Then”模式让每个测试方法只讲述一个简单的故事Given给定某种初始状态和输入When执行某个操作Then验证输出和最终状态。这样测试才会清晰、独立、易于排查问题。2.2 测试代码的经典结构Arrange, Act, Assert (AAA)这是组织单个测试方法内容的黄金法则几乎适用于所有单元测试框架。Arrange准备设置测试的前置条件。包括创建被测对象System Under Test, SUT、准备测试数据Test Data、模拟Mock或存根Stub外部依赖。这部分代码的目标是让测试环境处于一个已知的、可重复的起点。Act执行调用被测方法执行你想要测试的那个具体操作。这一行代码通常很简单就是调用SUT的某个方法。Assert断言验证执行结果是否符合预期。包括返回值、对象状态的变化、对外部依赖的交互如是否以正确的参数调用了某个Mock对象的方法等。一个结构清晰的测试方法阅读起来应该像读一段自然语言描述。例如测试一个计算器加法Test void shouldReturnSumWhenAddingTwoPositiveNumbers() { // Arrange Calculator calculator new Calculator(); int a 5; int b 3; // Act int result calculator.add(a, b); // Assert assertEquals(8, result); }实操心得我强烈建议在测试方法内部用空行明确分隔这三个阶段。当测试失败时你可以快速定位问题是出在准备阶段数据不对Mock没设好、执行阶段方法调用异常还是断言阶段逻辑错误预期值不对。对于复杂的Arrange阶段可以考虑抽取到私有方法或使用BeforeEach但要注意保持测试的可读性不要让准备逻辑藏得太深。2.3 测试类的组织与命名规范测试类的命名通常遵循被测试类名 Test的约定例如UserServiceTest。它应该和被测试类在同一个包结构下但位于不同的源码根目录下如src/main/java和src/test/java。这是Maven/Gradle的标准目录布局。测试方法的命名则更有讲究。过去流行testMethodName的格式但现在更推荐使用更具描述性的名称特别是结合JUnit 5支持方法名中包含空格通过反引号或使用蛇形命名法snake_case。核心原则是测试方法名应该清晰地表达它要验证的场景。不推荐testLogin推荐login_success_whenUsernameAndPasswordAreCorrectshouldThrowAuthenticationExceptionWhenPasswordIsWronglogin_shouldFailForLockedAccount使用描述性名称当测试报告中出现失败时你一眼就能知道是哪个具体场景出了问题无需再点进去看代码细节。注意事项避免在测试方法名中使用“and”来连接多个断言。如果一个测试方法需要验证多个不相关的事情这通常是一个信号——你可能需要把这个测试方法拆分成多个更小、更专注的测试。一个测试方法应该只验证一个逻辑概念。3. JUnit 5核心注解与生命周期深度解析JUnit 5相较于JUnit 4有较大革新模块化更清晰。我们常用的junit-jupiter引擎提供了丰富的注解来控制测试的生命周期和行为。理解这些注解是写好测试的基础。3.1 核心注解Test, BeforeEach, AfterEach, BeforeAll, AfterAllTest标记一个方法为测试方法。方法可以是void也可以是带返回值的但返回值会被忽略。它是测试执行的入口。BeforeEach/AfterEach分别在每个Test方法执行之前和之后运行。这是你进行测试准备和清理的主要位置。典型用途初始化每个测试独有的对象、重置Mock对象的行为、准备干净的测试数据、打开/关闭测试所需的资源如临时文件、数据库连接。踩坑记录曾经在一个测试类里BeforeEach中初始化了一个昂贵的数据库连接池导致几百个测试跑下来极其缓慢。后来改为BeforeAll只初始化一次并在BeforeEach中只做轻量的数据清理性能提升了几十倍。一定要根据资源开销决定使用Each还是All。BeforeAll/AfterAll分别在所有Test方法执行之前和之后运行一次。这两个注解修饰的方法必须是static的。典型用途初始化所有测试共享的、昂贵的资源如嵌入式数据库服务器、Spring应用上下文在集成测试中、全局配置加载。重要提示由于方法是静态的它们不能访问非静态的实例字段。因此通常在这里初始化的资源需要存储在静态变量中或者通过其他方式如测试扩展提供给实例方法。生命周期示例class LifecycleTest { static ExpensiveResource sharedResource; // 昂贵共享资源 BeforeAll static void initAll() { sharedResource new ExpensiveResource(); sharedResource.start(); System.out.println(BeforeAll - 初始化共享资源); } AfterAll static void tearDownAll() { sharedResource.stop(); System.out.println(AfterAll - 清理共享资源); } DatabaseConnection conn; // 每个测试独立的连接 BeforeEach void init() { conn new DatabaseConnection(); conn.connect(); System.out.println(BeforeEach - 为单个测试建立连接); } AfterEach void tearDown() { conn.disconnect(); System.out.println(AfterEach - 清理单个测试连接); } Test void testOne() { System.out.println(执行 testOne); // 使用 conn 和 sharedResource } Test void testTwo() { System.out.println(执行 testTwo); // 使用另一个 conn 实例和 sharedResource } } // 输出顺序 // BeforeAll - 初始化共享资源 // BeforeEach - 为单个测试建立连接 // 执行 testOne // AfterEach - 清理单个测试连接 // BeforeEach - 为单个测试建立连接 // 执行 testTwo // AfterEach - 清理单个测试连接 // AfterAll - 清理共享资源3.2 条件化测试Disabled, EnabledOnOs, EnabledIfSystemProperty不是所有测试在任何环境下都需要或能够运行。JUnit 5提供了丰富的条件化执行注解。Disabled最简单的直接禁用某个测试类或测试方法。常用于标记尚未实现的测试、已知存在缺陷暂时不修复的测试、或者在特定重构期间跳过的测试。务必在禁用时添加说明Disabled(功能XXX尚未实现待PM确认需求)。EnabledOnOs/DisabledOnOs根据操作系统决定是否执行。比如你的代码调用了Windows特有的API那么相关测试就应该用EnabledOnOs(OS.WINDOWS)标注。EnabledIfSystemProperty/DisabledIfSystemProperty根据系统属性决定。这在控制集成测试或性能测试的运行时非常有用。例如你可以在CI/CD流水线中设置系统属性-Dintegration.testtrue然后给耗时的集成测试类加上EnabledIfSystemProperty(named integration.test, matches true)这样在本地开发快速运行单元测试时就不会执行它们。应用场景我们项目中有一些测试依赖于一个外部的模拟服务该服务只在测试环境中部署。我们在CI流水线中会设置-Denvtest对应的测试类就加了EnabledIfSystemProperty(named env, matches test)。本地开发默认不设置该属性这些测试就不会运行保证了本地构建的速度。3.3 超时与异常测试Timeout, assertThrowsTimeout用于设定测试方法的执行超时时间。如果测试执行时间超过指定时长则测试失败。这对于防止死循环、检测性能回归非常有用。Test Timeout(value 2, unit TimeUnit.SECONDS) // 2秒超时 void shouldFinishWithinTwoSeconds() { // 执行一些可能耗时的操作 }注意Timeout适用于测试方法整体执行时间。如果测试中某个具体操作需要超时控制可能需要结合CompletableFuture或框架特定的超时机制。异常测试验证代码在特定输入下是否会抛出预期的异常。JUnit 5推荐使用Assertions.assertThrows方法它更清晰并且能返回捕获的异常对象供进一步断言。Test void shouldThrowIllegalArgumentExceptionWhenDivisorIsZero() { Calculator calculator new Calculator(); // Act Assert 合并断言会抛出指定异常 IllegalArgumentException exception assertThrows( IllegalArgumentException.class, () - calculator.divide(10, 0) // 执行可能抛出异常的操作 ); // 可选的进一步断言异常信息 assertEquals(Divisor cannot be zero, exception.getMessage()); }旧式的Test(expected Exception.class)注解不够灵活无法获取异常实例进行更细粒度的断言已不推荐使用。4. 断言Assertions的艺术从基础到高级断言是测试的灵魂它定义了什么是“正确”。JUnit Jupiter的Assertions类提供了丰富的静态方法。4.1 基础断言验证相等性、真假与空值assertEquals(expected, actual)/assertNotEquals最常用的断言。用于比较对象是否相等使用equals()方法。对于数组有专门的assertArrayEquals。关键点参数的顺序是(期望值实际值)。这个顺序很重要因为测试失败信息会显示“expected: ... but was: ...”。浮点数比较必须使用重载方法assertEquals(double expected, double actual, double delta)其中delta是允许的误差范围。这是浮点数计算固有精度问题所决定的。assertEquals(0.1, 1.0 - 0.9, 0.000001); // 正确 // assertEquals(0.1, 1.0 - 0.9); // 可能失败assertTrue(boolean condition)/assertFalse断言条件为真或假。常用于验证返回的布尔值或者更复杂的条件表达式。assertTrue(result 0); assertFalse(collection.isEmpty());assertNull(Object actual)/assertNotNull断言对象引用是否为null。4.2 高级断言集合、迭代器与异常链assertIterableEquals(Iterable? expected, Iterable? actual)断言两个可迭代对象如List,Set包含相同顺序的相同元素。比手动遍历比较方便得多。assertLinesMatch(ListString expectedLines, ListString actualLines)非常强大的字符串行匹配断言常用于测试输出文本或日志。它支持正则表达式匹配和快速转发标记比简单的字符串相等或包含更灵活。ListString expected Arrays.asList(Starting process..., , Process completed successfully.); ListString actual fetchLogLines(); assertLinesMatch(expected, actual); // 会忽略中间的任何行assertAll分组断言确保所有断言都被执行。默认情况下JUnit在一个断言失败后会立即停止该测试方法。但有时我们希望收集所有失败的断言信息这时可以用assertAll。Test void testPersonDetails() { Person person service.findPerson(1); assertAll(person, () - assertEquals(John, person.getFirstName()), () - assertEquals(Doe, person.getLastName()), () - assertTrue(person.getAge() 18), () - assertNotNull(person.getAddress()) ); // 如果上面有任何断言失败都会在最后一起报告 }这在验证一个复杂对象的多个属性时非常有用能让你一次看到所有不符合预期的字段而不是修好一个再跑测试发现另一个。4.3 第三方断言库Hamcrest与AssertJ虽然JUnit自带的断言基本够用但Hamcrest和AssertJ提供了更富表现力、更接近于自然语言的断言方式能写出更易读的测试。Hamcrest通过匹配器Matcher链式调用。需要静态导入org.hamcrest.Matchers.*。import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; // ... assertThat(actual, is(not(nullValue()))); // 断言不为空 assertThat(list, hasSize(3)); // 断言集合大小 assertThat(map, hasEntry(key, value)); // 断言Map包含条目 assertThat(str, both(startsWith(Hello)).and(endsWith(World))); // 组合断言AssertJ目前更流行提供极其流畅的API支持几乎所有Java类型的断言并且IDE智能提示非常好。import static org.assertj.core.api.Assertions.*; // ... assertThat(actual).isNotNull(); assertThat(list).hasSize(3).contains(element1, element2); assertThat(map).containsOnlyKeys(key1, key2).containsEntry(key1, 100); assertThat(str).startsWith(Hello).endsWith(World).hasSize(11); assertThat(exception).isInstanceOf(IllegalArgumentException.class) .hasMessageContaining(invalid);AssertJ的错误信息通常也更友好。我个人在项目中更倾向于使用AssertJ它的链式调用让测试代码读起来像句子一样流畅。5. 测试数据驱动参数化测试ParameterizedTest当同一个测试逻辑需要针对多组不同的输入数据进行验证时如果为每组数据都写一个单独的Test方法会导致大量重复代码。参数化测试完美解决了这个问题。JUnit 5的参数化测试功能非常强大通过ParameterizedTest注解结合各种“源”Source来提供数据。5.1 几种常见的数据源ValueSource提供一组字面量值支持基本类型及其包装类、String、Class。ParameterizedTest ValueSource(ints {1, 3, 5, -3, 15}) void isOdd_ShouldReturnTrueForOddNumbers(int number) { assertTrue(NumberUtils.isOdd(number)); }CsvSource以CSV格式提供多组参数每组参数对应测试方法的一次调用。非常适合测试需要多个输入参数的场景。ParameterizedTest CsvSource({ 2, 3, 5, // 235 0, 0, 0, -1, 5, 4, 10, -3, 7 }) void add_ShouldReturnCorrectSum(int a, int b, int expectedSum) { Calculator calc new Calculator(); assertEquals(expectedSum, calc.add(a, b)); }CsvFileSource从类路径下的CSV文件加载测试数据。当数据量很大时这比写在注解里更清晰。ParameterizedTest CsvFileSource(resources /test-data.csv, numLinesToSkip 1) // 跳过标题行 void testWithCsvFileSource(String input, String expected) { // ... }MethodSource指定一个返回StreamArguments、IterableArguments、IteratorArguments或Arguments数组的静态工厂方法作为数据源。这是最灵活的方式可以生成复杂的测试数据。ParameterizedTest MethodSource(provideStringsForIsBlank) void isBlank_ShouldReturnTrueForNullOrBlankStrings(String input, boolean expected) { assertEquals(expected, StringUtils.isBlank(input)); } private static StreamArguments provideStringsForIsBlank() { return Stream.of( Arguments.of(null, true), Arguments.of(, true), Arguments.of( , true), Arguments.of(not blank, false) ); }5.2 参数化测试的显示名称默认情况下参数化测试在IDE或报告中的显示名是[1] 2, 3, 5这种格式可读性差。可以使用ParameterizedTest(name {index} a{0}, b{1}, sum{2})来自定义显示名称其中{index}是调用索引{0},{1}等是参数值。这能让测试报告一目了然。实操心得参数化测试极大地减少了重复代码但要注意它并不适合所有场景。如果每组测试数据需要完全不同的前置准备Arrange或模拟行为强行参数化反而会让测试方法变得复杂难懂。此时拆分成多个独立的Test方法可能更清晰。参数化测试最适合测试纯函数Pure Function或状态变换逻辑。6. 模拟Mock与依赖隔离使用Mockito现代软件很少有完全独立的类它们通常依赖其他服务、组件或数据访问层。单元测试的核心思想是隔离即只测试当前“单元”的逻辑将其依赖替换为可控的“替身”。这就是Mock框架的用武之地而Mockito是Java生态中最流行的选择。6.1 为什么要Mock假设我们要测试一个OrderService的placeOrder方法它内部调用了InventoryService.checkStock()和PaymentService.processPayment()。在单元测试中我们不应该真的去连接库存数据库或支付网关。我们应该关注OrderService自身的业务逻辑如计算总价、创建订单对象、处理业务规则。假设其依赖项InventoryService和PaymentService的行为是符合预期的例如checkStock返回有货processPayment返回成功。验证OrderService是否正确调用了这些依赖项例如以正确的参数调用了processPayment。Mock对象就是这些依赖项的“替身”我们可以预设它们的行为并验证与它们的交互。6.2 Mockito核心用法三步曲创建Mock对象// 使用注解需要配合 ExtendWith(MockitoExtension.class) ExtendWith(MockitoExtension.class) class OrderServiceTest { Mock private InventoryService inventoryServiceMock; Mock private PaymentService paymentServiceMock; InjectMocks // 会自动将上面的mock注入到被测试对象中 private OrderService orderService; // ... 测试方法 } // 或者手动创建 class OrderServiceTest { private InventoryService inventoryServiceMock; private PaymentService paymentServiceMock; private OrderService orderService; BeforeEach void setUp() { inventoryServiceMock Mockito.mock(InventoryService.class); paymentServiceMock Mockito.mock(PaymentService.class); orderService new OrderService(inventoryServiceMock, paymentServiceMock); } }预设行为Stubbing告诉Mock对象当以某种方式调用它时应该返回什么值或抛出什么异常。// 当调用 inventoryServiceMock.checkStock(item1, 2) 时返回 true when(inventoryServiceMock.checkStock(item1, 2)).thenReturn(true); // 当调用 paymentServiceMock.processPayment(any(), eq(100.0)) 时返回一个成功的Transaction对象 Transaction successTx new Transaction(tx123, SUCCESS); when(paymentServiceMock.processPayment(any(Customer.class), eq(100.0))).thenReturn(successTx); // 模拟抛出异常 when(inventoryServiceMock.checkStock(out-of-stock-item, anyInt())).thenThrow(new OutOfStockException());any(),eq(),anyString()等是参数匹配器Argument Matchers用于灵活匹配调用参数。验证交互Verification测试执行后验证Mock对象是否被以预期的方式调用过。// 验证 processPayment 方法被调用了一次且第一个参数是特定的customer第二个参数是100.0 verify(paymentServiceMock, times(1)).processPayment(specificCustomer, 100.0); // 验证 checkStock 方法至少被调用了一次参数任意 verify(inventoryServiceMock, atLeastOnce()).checkStock(anyString(), anyInt()); // 验证某个方法从未被调用 verify(inventoryServiceMock, never()).reserveStock(anyString(), anyInt());6.3 进阶技巧Spy、ArgumentCaptor与BDD风格Spy部分模拟有时候你不想完全模拟一个对象而是想大部分使用真实对象只模拟或验证其中的一两个方法。这时可以用Spy。要小心使用因为真实方法会被调用可能带来副作用。Spy private ListString realList new ArrayList(); Test void testSpy() { realList.add(real); // 模拟 size() 方法返回 100 doReturn(100).when(realList).size(); assertEquals(100, realList.size()); // 模拟生效 assertEquals(1, realList.size()); // 错误真实size是1但被模拟覆盖了。这里演示概念。 // 注意realList里确实有一个元素real }ArgumentCaptor参数捕获器当你需要验证传递给Mock方法的参数的具体值时特别是复杂对象ArgumentCaptor非常有用。Test void testArgumentCaptor() { Order order new Order(...); orderService.placeOrder(order); ArgumentCaptorOrder orderCaptor ArgumentCaptor.forClass(Order.class); verify(paymentServiceMock).processPayment(orderCaptor.capture()); Order capturedOrder orderCaptor.getValue(); assertEquals(order.getId(), capturedOrder.getId()); // 可以对capturedOrder进行更细致的断言 }BDD行为驱动开发风格Mockito支持BDDMockito类提供given(),willReturn(),then()等语法让测试读起来更像自然语言Given-When-Then。import static org.mockito.BDDMockito.*; Test void testPlaceOrder_BDDStyle() { // Given given(inventoryServiceMock.checkStock(anyString(), anyInt())).willReturn(true); given(paymentServiceMock.processPayment(any(), anyDouble())).willReturn(new Transaction(...)); Order order new Order(...); // When OrderResult result orderService.placeOrder(order); // Then then(paymentServiceMock).should().processPayment(any(), eq(order.getTotalAmount())); assertThat(result.isSuccess()).isTrue(); }避坑指南Mock不是银弹。过度使用Mock会导致测试与实现细节耦合过紧白盒测试一旦内部调用方式改变比如方法名没变但参数顺序变了即使业务逻辑正确测试也会失败。这被称为“脆弱的测试”。Mock应该主要用于隔离外部依赖如数据库、网络服务、文件系统对于同一个模块内、共同演化的内部协作对象有时使用真实对象或更轻量的Stub是更好的选择。7. 测试Spring Boot应用SpringBootTest与切片测试对于Spring Boot应用测试变得更加复杂因为涉及容器、依赖注入、事务管理等。Spring Boot Test提供了强大的支持。7.1 集成测试SpringBootTestSpringBootTest用于启动完整的Spring应用上下文适合进行集成测试或端到端测试。它会加载你所有的Configuration和正常启动应用几乎一样。SpringBootTest // 默认会寻找主配置类SpringBootApplication标注的类 class OrderServiceIntegrationTest { Autowired private OrderService orderService; // 注入真实的Bean Autowired private DataSource dataSource; MockBean // Spring Boot特有的注解用于在上下文中替换一个Bean为Mock private PaymentGateway paymentGateway; Test Transactional // 测试后回滚数据保持测试隔离 void shouldCreateOrderAndChargePayment() { // 这里orderService是真实的但paymentGateway被Mock了 // 可以操作真实数据库 // ... } }MockBean这是Spring Boot Test提供的利器。它会在Spring上下文中用Mockito的Mock对象替换掉指定类型的Bean。这样你既可以享受完整的Spring上下文如事务、AOP又可以隔离特定的外部依赖。Transactional在测试方法上使用可以使测试在事务中运行并在测试完成后自动回滚确保不会污染数据库。这是集成测试的常用模式。性能警告SpringBootTest启动整个应用上下文是非常耗时的操作几秒到几十秒。如果你的项目有几百个测试类都这么写测试套件将变得极其缓慢。因此要谨慎使用只为真正需要完整上下文的测试如涉及多个组件协作、事务边界、Spring MVC层使用它。7.2 切片测试Slice Test更轻量级的专注测试Spring Boot Test提供了“切片测试”注解它们只加载应用程序的特定“切片”一部分从而大大加快测试速度。WebMvcTest专注于测试Spring MVC控制器Controller层。它会配置相关的MVC设施如Controller,ControllerAdvice,JsonComponent,Converter,Filter等但不会加载完整的应用上下文如Service,RepositoryBean。你需要用MockBean来模拟Service层的依赖。WebMvcTest(OrderController.class) // 只加载OrderController相关的配置 class OrderControllerTest { Autowired private MockMvc mockMvc; // 用于模拟HTTP请求 MockBean private OrderService orderService; // Controller依赖的Service需要Mock Test void shouldReturnOrderWhenFound() throws Exception { given(orderService.getOrder(1L)).willReturn(new Order(...)); mockMvc.perform(get(/orders/1)) .andExpect(status().isOk()) .andExpect(jsonPath($.id).value(1)); } }DataJpaTest专注于测试JPA仓库Repository层。它会配置一个内存数据库如H2、Hibernate、Spring Data JPA等但不会加载Service或Controller。非常适合测试数据库查询和持久化逻辑。DataJpaTest class OrderRepositoryTest { Autowired private TestEntityManager entityManager; // 用于便捷地操作测试数据 Autowired private OrderRepository orderRepository; Test void shouldFindOrdersByCustomerId() { Customer customer new Customer(...); entityManager.persist(customer); Order order new Order(...); order.setCustomer(customer); entityManager.persist(order); entityManager.flush(); ListOrder found orderRepository.findByCustomerId(customer.getId()); assertThat(found).hasSize(1).contains(order); } }JsonTest专注于测试JSON序列化/反序列化Jackson配置。RestClientTest专注于测试REST客户端如RestTemplate或WebClient。核心原则根据测试的“单元”选择最轻量级的测试注解。测试Controller就用WebMvcTest测试Repository就用DataJpaTest只有测试涉及多个层次交互或复杂事务时才用SpringBootTest。这是保证大型项目测试套件运行速度的关键。8. 测试代码的常见“坏味道”与最佳实践写了这么多年测试也review过无数测试代码我总结了一些典型的“测试坏味道”和对应的最佳实践。8.1 测试坏味道脆弱测试测试过于依赖实现细节如私有字段、方法执行顺序、特定数据结构导致实现代码的重构即使行为不变也会破坏测试。对策面向行为而非实现测试。通过公共API进行验证使用Mock来验证协作契约而非调用顺序细节。缓慢测试测试执行太慢导致开发人员不愿意运行。常见原因频繁启动完整Spring上下文、连接真实外部服务、文件/网络IO。对策使用切片测试、Mock外部依赖、利用内存数据库、合理使用BeforeAll共享昂贵资源。重复测试多个测试方法包含大量相同的准备代码Arrange。对策将公共的准备逻辑提取到BeforeEach方法或工厂方法中。但要注意平衡过度提取会降低单个测试的可读性。条件逻辑测试测试方法内部包含if-else或循环。这通常意味着一个测试方法在测试多个场景。对策拆分成多个独立的测试方法或使用参数化测试。过度验证一个测试断言了太多不相关的事情。当这个测试失败时很难快速定位根本原因。对策遵循“单一断言原则”一个测试方法验证一个逻辑概念或使用assertAll进行分组但相关的断言。神秘访客测试数据或预期结果以“魔数”形式硬编码在测试中没有解释其来源或意义。对策使用有意义的常量或变量名或者通过工厂方法生成测试数据让意图更清晰。8.2 最佳实践清单保持测试独立每个测试不应该依赖于其他测试的运行或状态。使用BeforeEach确保干净的初始状态避免共享可变的测试数据。测试命名清晰测试方法名应该明确表达其意图和场景。使用“should...when...”或“given...when...then...”的格式。测试行为而非方法一个方法可能有多个行为正常流、异常流、边界条件。为每个行为编写独立的测试。覆盖边界条件和异常路径不要只测试“快乐路径”。思考输入为null、空字符串、0、负数、超出范围的值、集合为空等情况。使用assertThrows测试异常。避免测试私有方法私有方法是实现细节应该通过测试公有方法来间接覆盖。如果觉得私有方法复杂到需要单独测试那它可能是一个提取到新类的信号。使用断言消息为重要的断言提供有意义的失败消息这在测试失败时能提供更多上下文。assertEquals(User age should be 25, 25, user.getAge()); // 使用AssertJ更优雅 assertThat(user.getAge()).as(检查用户年龄).isEqualTo(25);定期运行测试将测试集成到构建流程中Maven/Gradle每次提交都运行。考虑使用持续集成CI服务器。测量并关注测试覆盖率使用JaCoCo等工具生成覆盖率报告。不要盲目追求100%覆盖率但要确保核心业务逻辑和复杂分支被覆盖。覆盖率是一个有用的指标但不是目标本身。9. 测试代码的维护与重构测试代码和生产代码一样需要持续维护和重构。随着业务变化测试也会变得过时、冗余或低效。当生产代码重构时如果重构改变了内部结构但外部行为不变理想情况下单元测试不应该大量失败。如果失败了说明测试可能耦合了实现细节这是一个改进测试的机会。删除过时的测试对于已经移除的功能或不再有效的场景果断删除对应的测试。不要留着永远跳过的Disabled测试。重构重复的测试代码发现多个测试类有相似的准备逻辑考虑提取到父类、工具类或使用JUnit 5的TestTemplate和TestTemplateInvocationContext进行更高级的抽象。测试代码也需要Review在代码审查中像审查生产代码一样认真审查测试代码。检查其正确性、可读性、独立性和维护性。编写JUnit测试代码从“能跑”到“写好”是一个需要持续学习和实践的过程。它不仅仅是一项技能更是一种保障软件质量、提升开发效率、促进良好设计的重要工程实践。希望这篇结合了原理、技巧和实战经验的长文能帮助你写出更自信、更可靠、更易维护的测试代码。记住好的测试是你未来修改代码时最坚实的后盾。