
1. 项目概述为什么JUnit5值得你投入时间如果你是一名Java开发者无论你是刚入行的新人还是已经写了多年业务代码的老手单元测试这个话题你一定绕不开。我见过太多项目初期为了赶进度测试代码能省则省结果到了后期代码库像一座摇摇欲坠的危楼没人敢动每次改动都心惊胆战。单元测试就是给这座大楼做的“抗震加固”和“消防演习”。而JUnit无疑是Java世界里进行这场演习最标准、最趁手的工具。从JUnit 4到JUnit 5这不仅仅是一个版本的迭代更像是一次框架的“重生”。JUnit 5在2017年发布它由三个不同的子模块JUnit Platform, JUnit Jupiter, JUnit Vintage组成带来了更清晰的架构、更强大的扩展能力和更符合现代Java比如Lambda表达式的编程模型。但说实话很多团队还停留在JUnit 4或者对JUnit 5的使用仅限于最基本的Test注解这其实浪费了它大半的能力。最近在社区里我看到不少人在搜索“vue单元测试报错”、“单元测试怎么写”、“单元测试 代码生成 插件”这反映出两个问题一是测试的实践正在从前端向后端、向全栈蔓延大家意识到了它的重要性二是很多人对如何写好、用好单元测试工具仍然感到困惑和吃力。所以我想结合自己这些年踩过的坑和积累的经验把JUnit 5从入门到进阶的核心操作掰开揉碎了讲清楚。这不是一份冰冷的官方文档翻译而是一份能让你直接“抄作业”、在项目中落地、并真正感受到测试带来安全感和效率提升的实战指南。2. JUnit5核心架构与设计哲学拆解在动手写第一个测试之前我们必须先理解JUnit 5的“心脏”是如何跳动的。这能帮你从根本上理解后续所有注解和API的行为而不是死记硬背。2.1 三层架构平台、引擎与怀旧JUnit 5被明确地分成了三个模块这种解耦设计是它比JUnit 4聪明的地方。JUnit Platform这是基石。它提供了一个在JVM上启动测试框架的统一API。你的IDE如IntelliJ IDEA、Eclipse和构建工具如Maven、Gradle都是通过这个平台来发现和执行测试的。简单说它定义了“如何发现和运行测试”的规则。JUnit Jupiter这是你编写测试和扩展框架的核心模块。我们用的Test、BeforeEach、ParameterizedTest等注解以及断言库Assertions都来自这个模块。它本身也是一个测试引擎实现可以在JUnit Platform上运行。JUnit Vintage这是一个“兼容层”引擎。它的唯一使命就是在JUnit 5的平台上支持运行那些用JUnit 3或JUnit 4编写的旧测试用例。如果你的项目是全新的完全可以不引入这个依赖。这种架构的好处是巨大的。比如你可以让JUnit Platform同时运行Jupiter引擎的测试和Vintage引擎的测试混合项目。未来如果有新的测试框架比如TestNG想接入也只需要实现一个对应的引擎即可IDE和构建工具无需改动。2.2 与JUnit4的思维差异从“类继承”到“注解驱动”JUnit 4虽然也用注解但它的很多机制如TestRunner还是依赖于类继承和反射的特定约定。JUnit 5彻底拥抱了“注解驱动”和“扩展模型”。最明显的一点是生命周期注解的命名更精准了Before-BeforeEach: 强调是在“每一个”测试方法之前执行。After-AfterEach: 强调是在“每一个”测试方法之后执行。BeforeClass-BeforeAll: 强调是在“所有”测试方法之前执行且方法必须是static。AfterClass-AfterAll: 强调是在“所有”测试方法之后执行且方法必须是static。这不仅仅是改名更是一种理念的强化你的设置和清理代码作用域非常明确。另一个重大差异是断言库。JUnit 4的断言方法如assertEquals位于org.junit.Assert类中是静态方法。JUnit 5的断言位于org.junit.jupiter.api.Assertions类中并且充分利用了Java 8的Lambda表达式支持惰性求值消息这在断言失败时能提升性能。// JUnit 4 assertEquals(“预期结果不符”, expectedValue, actualValue); // JUnit 5 - 基础用法 assertEquals(expectedValue, actualValue, “预期结果不符”); // JUnit 5 - Lambda消息只有断言失败时才会执行字符串拼接性能更优 assertEquals(expectedValue, actualValue, () - “这是一个昂贵的消息拼接” someExpensiveOperation());2.3 扩展模型超越Rule的强大能力JUnit 4的Rule和ClassRule功能强大但机制相对固定。JUnit 5引入了更灵活、更强大的扩展模型Extension Model。你可以通过实现特定的接口如BeforeEachCallback,AfterTestExecutionCallback,ParameterResolver等来创建自定义扩展并通过ExtendWith注解将其应用到测试类或方法上。这是实现自定义需求的神器比如依赖注入自动为测试方法注入特定的资源如数据库连接、HTTP客户端。自定义条件测试基于操作系统、环境变量、系统属性等条件来动态启用或禁用测试。重复测试逻辑封装复杂的测试准备和清理逻辑。测试监控在测试执行前后自动进行日志记录、性能采集等。注意虽然JUnit 5的扩展模型功能强大但对于大多数项目内置的注解和断言已经足够。不要过度设计只有当内置功能无法满足、且相同逻辑在多处重复出现时才考虑编写自定义扩展。3. 从零开始环境搭建与第一个测试理论说再多不如动手跑一遍。我们来看看如何在一个标准的Maven项目中配置和运行你的第一个JUnit 5测试。3.1 依赖配置Maven与Gradle对于Maven项目你需要在pom.xml中引入依赖。关键点是你至少需要junit-jupiter它本身是一个聚合依赖而junit-platform-surefire-provider是让Maven Surefire插件能够运行JUnit 5测试所必须的。dependencies !-- 核心测试依赖 -- dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter/artifactId version5.10.0/version !-- 请使用最新稳定版 -- scopetest/scope /dependency /dependencies build plugins !-- 配置Maven Surefire插件以支持JUnit 5 -- plugin artifactIdmaven-surefire-plugin/artifactId version3.0.0-M7/version /plugin /plugins /build对于GradleKotlin DSL配置更简洁dependencies { testImplementation(“org.junit.jupiter:junit-jupiter:5.10.0”) } tasks.test { useJUnitPlatform() }3.2 编写与运行你的第一个测试用例假设我们有一个简单的计算器类Calculatorpublic class Calculator { public int add(int a, int b) { return a b; } }对应的测试类CalculatorTest应该放在src/test/java的相同包结构下import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; class CalculatorTest { Test void testAdd() { // 1. 准备 (Arrange) Calculator calculator new Calculator(); int a 2; int b 3; int expectedSum 5; // 2. 执行 (Act) int actualSum calculator.add(a, b); // 3. 断言 (Assert) assertEquals(expectedSum, actualSum, “2 3 应该等于 5”); } }这是一个经典的AAA 模式Arrange-Act-Assert它让测试结构非常清晰。现在你可以在IDE中右键点击这个方法或类选择“Run ‘testAdd()’”或者通过命令行执行mvn test/gradle test就能看到测试通过的绿色对勾了。3.3 测试生命周期注解实战让我们用一个更复杂的例子看看生命周期注解如何协作。假设我们测试一个需要数据库连接的UserRepository。import org.junit.jupiter.api.*; import static org.junit.jupiter.api.Assertions.*; class UserRepositoryTest { private static DatabaseConnection globalConnection; // 模拟昂贵的资源如数据库连接池 private UserRepository repository; private User testUser; BeforeAll static void initAll() { // 在所有测试开始前执行一次适合初始化全局、昂贵的资源 globalConnection DatabaseConnectionFactory.create(); globalConnection.start(); System.out.println(“全局数据库连接已建立”); } AfterAll static void tearDownAll() { // 在所有测试结束后执行一次适合清理全局资源 globalConnection.close(); System.out.println(“全局数据库连接已关闭”); } BeforeEach void init() { // 在每个测试方法开始前执行适合初始化测试数据 repository new UserRepository(globalConnection); testUser new User(“testUser”, “testexample.com”); repository.save(testUser); // 确保每个测试都有一个干净的起点 System.out.println(“测试数据准备完毕”); } AfterEach void tearDown() { // 在每个测试方法结束后执行适合清理测试数据 repository.delete(testUser.getId()); System.out.println(“测试数据已清理”); } Test void testFindUserById() { User found repository.findById(testUser.getId()); assertNotNull(found); assertEquals(testUser.getEmail(), found.getEmail()); } Test void testUpdateUser() { testUser.setEmail(“updatedexample.com”); repository.update(testUser); User updated repository.findById(testUser.getId()); assertEquals(“updatedexample.com”, updated.getEmail()); } }执行顺序将是initAll- (init-testFindUserById-tearDown) - (init-testUpdateUser-tearDown) -tearDownAll。这保证了测试的独立性和可重复性。实操心得BeforeEach/AfterEach中做的准备工作目标是让测试方法本身变得极其简单和专注。一个理想的测试方法应该只包含“执行操作”和“验证结果”两步所有“搭建舞台”的工作都应交给生命周期方法。如果发现某个BeforeEach方法只为少数测试服务考虑将其逻辑移到那些测试方法内部或者使用Nested嵌套类来分组共享不同的准备逻辑。4. 进阶测试策略参数化、动态测试与条件执行当你的测试需要覆盖多种输入组合或者测试用例本身需要动态生成时基础注解就不够用了。JUnit 5提供了一系列强大的进阶功能。4.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.*; import static org.junit.jupiter.api.Assertions.*; class ParameterizedDemoTest { ParameterizedTest ValueSource(ints {1, 2, 3, 5, 8}) void testWithValueSource(int number) { assertTrue(number 0); } ParameterizedTest CsvSource({ “apple, 1”, “banana, 2”, “‘lemon, lime’, 3” // 包含逗号的字符串需要用单引号包裹 }) void testWithCsvSource(String fruit, int rank) { assertNotNull(fruit); assertTrue(rank 0); } ParameterizedTest CsvFileSource(resources “/test-data.csv”, numLinesToSkip 1) // 跳过CSV标题行 void testWithCsvFileSource(String name, int age) { assertNotNull(name); assertTrue(age 0); } ParameterizedTest MethodSource(“stringProvider”) // 指定一个静态方法作为数据源 void testWithMethodSource(String argument) { assertNotNull(argument); } static StreamString stringProvider() { return Stream.of(“foo”, “bar”, “baz”); } // 更复杂的例子测试一个判断是否为闰年的方法 ParameterizedTest(name “年份 {0} 是闰年{1}”) // 自定义测试显示名称 CsvSource({ “2000, true”, “2020, true”, “1900, false”, “2021, false” }) void testIsLeapYear(int year, boolean expected) { assertEquals(expected, Year.isLeap(year)); } }4.2 动态测试运行时生成测试用例有时候你的测试用例无法在编译时确定。例如你想遍历一个目录下的所有文件并对每个文件执行一个测试。TestFactory让你可以动态生成测试。import org.junit.jupiter.api.*; import org.junit.jupiter.api.DynamicTest; import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.DynamicTest.dynamicTest; class DynamicTestsDemo { TestFactory StreamDynamicTest dynamicTestsFromStream() { // 假设我们有一组输入和预期的输出 ListString inputList Arrays.asList(“racecar”, “radar”, “level”); ListBoolean expectedResults Arrays.asList(true, true, true); return IntStream.range(0, inputList.size()) .mapToObj(i - dynamicTest( “测试回文” inputList.get(i), // 测试名称 () - assertEquals(expectedResults.get(i), isPalindrome(inputList.get(i))) // 可执行体 )); } private boolean isPalindrome(String str) { return new StringBuilder(str).reverse().toString().equals(str); } TestFactory StreamDynamicTest generateTestsForFiles() throws IOException { Path testDir Paths.get(“src/test/resources/data”); return Files.list(testDir) .filter(path - !Files.isDirectory(path)) .map(path - dynamicTest( “处理文件” path.getFileName(), () - { // 对每个文件执行测试逻辑 String content Files.readString(path); assertFalse(content.isEmpty()); } )); } }动态测试非常灵活但记住它们是在运行时生成的因此IDE对它们的支持如单独运行某一个动态测试可能不如普通的Test方法。4.3 条件测试智能跳过不必要的测试不是所有测试在任何环境下都需要运行。JUnit 5提供了一系列条件执行注解。import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.*; import static org.junit.jupiter.api.Assertions.*; class ConditionalExecutionTest { Test EnabledOnOs(OS.MAC) // 只在Mac系统上运行 void onlyOnMac() { // ... } Test DisabledOnOs(OS.WINDOWS) // 在非Windows系统上运行 void notOnWindows() { // ... } Test EnabledIfSystemProperty(named “os.arch”, matches “.*64.*”) // 只在64位系统运行 void onlyOn64BitArch() { // ... } Test EnabledIfEnvironmentVariable(named “CI”, matches “true”) // 只在CI环境运行 void onlyOnCiServer() { assertEquals(2, 11); } Test Disabled(“待功能X完成后再启用”) // 无条件禁用并给出原因 void featureXTest() { // TODO } // 自定义条件基于复杂逻辑 Test EnabledIf(“customCondition”) void enabledByCustomCondition() { // ... } boolean customCondition() { return someExternalService.isAvailable(); } }合理使用条件测试可以让你的测试套件更智能避免在不适用的环境下运行失败从而干扰真正的失败信号。5. 断言、假设与显示名称让测试更清晰写好测试不仅要逻辑正确还要易于阅读和维护。JUnit 5在断言、前置条件假设和测试展示上提供了很多提升可读性的工具。5.1 强大的断言库除了基础的assertEquals,assertTrueJUnit Jupiter的断言库提供了更多选择分组断言assertAll允许你执行一组断言并收集所有失败信息一起报告而不是在第一个失败时就停止。Test void personDetails() { Person person new Person(“John”, “Doe”); assertAll(“person” () - assertEquals(“John”, person.getFirstName()), () - assertEquals(“Doe”, person.getLastName()), () - assertNotNull(person.getId()) ); }异常断言assertThrows和assertDoesNotThrow让你能清晰地测试异常行为。Test void exceptionTesting() { Calculator calculator new Calculator(); // 断言会抛出特定异常 IllegalArgumentException exception assertThrows( IllegalArgumentException.class, () - calculator.divide(1, 0), “除数不能为零应抛出异常” ); // 还可以进一步断言异常信息 assertEquals(“Divisor cannot be zero”, exception.getMessage()); }超时断言assertTimeout和assertTimeoutPreemptively用于测试执行时间。Test void timeoutNotExceeded() { // 如果执行时间超过2秒测试失败 assertTimeout(ofSeconds(2), () - { // 模拟一个耗时操作 performSomeLongOperation(); }); }assertTimeoutPreemptively与assertTimeout不同它会在另一个线程中执行任务并在超时后立即中断防止任务继续占用资源。5.2 使用假设定义测试运行的前提假设Assumptions用于定义测试运行的前提条件。如果假设不成立测试会被跳过TestAbortedException而不是失败。这非常适合那些依赖外部环境的测试。import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class AssumptionsTest { Test void testOnlyOnCiServer() { // 假设环境变量CI存在且值为true assumeTrue(“true”.equals(System.getenv(“CI”))); // 只有在上面的假设成立时下面的断言才会执行 assertEquals(2, 11); } Test void testAssumingThat() { String env System.getenv(“ENV”); // assumingThat 只在其作用域内生效 assumingThat(“prod”.equals(env), () - { // 这部分断言只在生产环境配置下执行和验证 assertTrue(someProductionOnlyFeature.isEnabled()); }); // 这里的断言无论假设是否成立都会执行 assertNotNull(env); } }假设和条件注解EnabledIf功能有重叠但假设更灵活可以在测试方法内部任何地方使用而条件注解作用于整个方法。5.3 自定义显示名称默认情况下测试在报告和IDE中显示的是方法名。你可以使用DisplayName注解来提供一个更易读的描述甚至支持Emoji和空格。Test DisplayName(“✨ 测试用户注册功能 - 正常流程”) void testUserRegistration() { // ... } Nested DisplayName(“当用户名为空时”) class WhenUsernameIsEmpty { Test DisplayName(“应抛出 IllegalArgumentException 异常”) void shouldThrowException() { // ... } }使用DisplayNameGeneration注解或实现DisplayNameGenerator接口还可以为整个测试类生成自定义的命名策略。6. 测试的组织与嵌套构建清晰的测试结构当测试类变得庞大时如何组织代码就至关重要了。JUnit 5的Nested注解可以帮助你按逻辑分组测试反映被测类的内部状态或行为分类。6.1 使用Nested进行逻辑分组Nested类必须是非静态的内部类。每个嵌套类都可以有自己的生命周期方法BeforeEach,AfterEach并且它们会从外层类继承BeforeAll和AfterAll方法但嵌套类不能有自己静态的BeforeAll/AfterAll方法除非使用TestInstance(Lifecycle.PER_CLASS)。import org.junit.jupiter.api.*; import static org.junit.jupiter.api.Assertions.*; DisplayName(“购物车服务测试”) class ShoppingCartServiceTest { private ShoppingCartService cart; private Product sampleProduct; BeforeEach void init() { cart new ShoppingCartService(); sampleProduct new Product(“P001”, “Java编程思想”, 99.99); } Nested DisplayName(“当购物车为空时”) class WhenCartIsEmpty { Test DisplayName(“添加商品应成功”) void shouldAddItemSuccessfully() { cart.addItem(sampleProduct, 1); assertEquals(1, cart.getItemCount()); } Test DisplayName(“总金额应为0”) void totalPriceShouldBeZero() { assertEquals(0.0, cart.getTotalPrice()); } } Nested DisplayName(“当购物车中有商品时”) class WhenCartHasItems { BeforeEach void addSampleItem() { cart.addItem(sampleProduct, 2); } Test DisplayName(“增加同一商品数量应更新总数”) void shouldUpdateQuantityWhenAddingSameProduct() { cart.addItem(sampleProduct, 1); assertEquals(3, cart.getQuantity(sampleProduct.getId())); } Test DisplayName(“移除商品后购物车应变空”) void shouldBecomeEmptyAfterRemovingItem() { cart.removeItem(sampleProduct.getId()); assertTrue(cart.isEmpty()); } Nested // 甚至可以多层嵌套 DisplayName(“并且应用了折扣券时”) class AndDiscountApplied { private DiscountCoupon coupon; BeforeEach void applyCoupon() { coupon new DiscountCoupon(“SAVE10”, 0.1); // 10%折扣 cart.applyCoupon(coupon); } Test DisplayName(“总金额应正确计算折扣”) void totalPriceShouldReflectDiscount() { double expected 99.99 * 2 * 0.9; // 原价 * 数量 * (1-折扣) assertEquals(expected, cart.getTotalPrice(), 0.001); } } } }这种结构让测试报告读起来就像一篇结构化的文档“购物车服务测试” - “当购物车为空时” - “添加商品应成功”。极大地提升了测试的可读性和维护性。6.2 测试实例生命周期管理默认情况下JUnit 5为每个测试方法创建一个新的测试类实例Lifecycle.PER_METHOD。这意味着BeforeEach和AfterEach方法在每次测试前都会重新初始化字段。但有时初始化成本很高比如启动一个嵌入式数据库你希望在所有测试间共享同一个实例。这时可以使用TestInstance(Lifecycle.PER_CLASS)注解。TestInstance(TestInstance.Lifecycle.PER_CLASS) // 改为每个测试类一个实例 DisplayName(“高成本资源测试”) class ExpensiveResourceTest { private ExpensiveDatabaseConnection connection; // 这个字段将在所有测试方法间共享 BeforeAll // 现在这个方法可以是实例方法不需要static了 void initAll() { connection new ExpensiveDatabaseConnection(); connection.start(); } AfterAll void tearDownAll() { connection.close(); } Test void testQuery1() { // 使用共享的 connection } Test void testQuery2() { // 使用共享的 connection } }使用PER_CLASS模式需要小心因为它会改变测试的隔离性。你必须确保测试方法不会相互干扰例如修改共享状态。通常它只适用于那些资源初始化极其昂贵、且测试本身是只读或能妥善管理状态的情况。7. 集成与Mock在真实项目中测试单元测试的核心是“隔离”但我们的代码往往依赖其他组件如数据库、网络服务、第三方API。这时我们需要用到Mock模拟和Stub桩技术。虽然JUnit 5本身不提供Mock功能但它能与流行的Mock框架如Mockito完美集成。7.1 与Mockito集成进行依赖隔离Mockito是Java领域最流行的Mock框架。结合JUnit 5你可以轻松模拟依赖对象的行为。首先添加Mockito依赖dependency groupIdorg.mockito/groupId artifactIdmockito-junit-jupiter/artifactId !-- 专为JUnit 5集成 -- version5.7.0/version scopetest/scope /dependency然后在测试类上使用ExtendWith(MockitoExtension.class)来启用Mockito的自动注解处理。import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; 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) class OrderServiceTest { Mock private PaymentGateway paymentGateway; // 模拟一个外部支付网关 Mock private InventoryService inventoryService; // 模拟库存服务 InjectMocks private OrderService orderService; // 将被测服务并自动注入上面的Mock Test void placeOrder_ShouldSucceed_WhenPaymentAndInventoryOk() { // 1. 准备 (Arrange) - 定义Mock行为 String orderId “ORDER-123”; when(paymentGateway.process(anyDouble())).thenReturn(new PaymentResult(true, “success”)); when(inventoryService.reserve(anyString(), anyInt())).thenReturn(true); // 2. 执行 (Act) OrderResult result orderService.placeOrder(orderId, “ITEM-001”, 2, 100.0); // 3. 断言 (Assert) assertTrue(result.isSuccess()); assertEquals(orderId, result.getOrderId()); // 4. 验证 (Verify) - 可选验证Mock的交互 verify(paymentGateway, times(1)).process(100.0 * 2); // 验证支付网关被调用了一次且参数正确 verify(inventoryService).reserve(“ITEM-001”, 2); // 验证库存服务被调用 } Test void placeOrder_ShouldFail_WhenPaymentFails() { // 模拟支付失败 when(paymentGateway.process(anyDouble())).thenReturn(new PaymentResult(false, “insufficient funds”)); OrderResult result orderService.placeOrder(“ORDER-456”, “ITEM-001”, 1, 50.0); assertFalse(result.isSuccess()); assertTrue(result.getMessage().contains(“支付失败”)); // 验证库存服务没有被调用因为支付先失败 verify(inventoryService, never()).reserve(anyString(), anyInt()); } }Mock创建模拟对象InjectMocks创建被测对象并自动将Mock字段注入进去。when(...).thenReturn(...)用于定义模拟对象的行为verify(...)用于验证模拟对象是否按预期被调用。7.2 测试Spring Boot应用在Spring Boot项目中测试更加方便。Spring Boot Test提供了丰富的注解来支持不同层次的测试。切片测试使用WebMvcTest只测试Web层自动配置相关的MVC组件但不会加载完整的应用上下文。WebMvcTest(UserController.class) // 只加载UserController相关的Bean class UserControllerTest { Autowired private MockMvc mockMvc; // 用于模拟HTTP请求 MockBean private UserService userService; // 模拟Service层 Test void getUserById_ShouldReturnUser() throws Exception { User mockUser new User(1L, “testUser”); when(userService.getUserById(1L)).thenReturn(mockUser); mockMvc.perform(get(“/api/users/1”)) .andExpect(status().isOk()) .andExpect(jsonPath(“$.username”).value(“testUser”)); } }集成测试使用SpringBootTest加载完整的应用上下文适合测试多个组件的集成。SpringBootTest AutoConfigureMockMvc class UserIntegrationTest { Autowired private MockMvc mockMvc; Autowired private UserRepository userRepository; // 真实的Repository可能连接测试数据库 Test Transactional // 测试后回滚数据 void createUser_ShouldPersistToDatabase() throws Exception { String userJson “{\”username\”: \”newUser\”, \”email\”: \”newexample.com\”}”; mockMvc.perform(post(“/api/users”) .contentType(MediaType.APPLICATION_JSON) .content(userJson)) .andExpect(status().isCreated()); // 验证数据是否真的存入了数据库 assertTrue(userRepository.findByUsername(“newUser”).isPresent()); } }数据层测试使用DataJpaTest只加载JPA相关的配置非常适合测试Repository。DataJpaTest class UserRepositoryTest { Autowired private TestEntityManager entityManager; // 用于操作测试数据库 Autowired private UserRepository userRepository; Test void findByUsername_ShouldReturnUser() { // 先持久化一个实体 User user new User(“john_doe”, “johnexample.com”); entityManager.persist(user); entityManager.flush(); // 查询 OptionalUser found userRepository.findByUsername(“john_doe”); assertTrue(found.isPresent()); assertEquals(“johnexample.com”, found.get().getEmail()); } }Spring Boot通过spring-boot-starter-test依赖提供了所有这些能力它默认包含了JUnit Jupiter、Mockito、AssertJ、Hamcrest等库。8. 常见问题排查与最佳实践即使掌握了所有工具在实际编写测试时还是会遇到各种问题。这里记录了一些我踩过的坑和总结的经验。8.1 典型问题速查表问题现象可能原因解决方案测试方法不执行被忽略1. 方法不是public(JUnit 5支持包可见性但某些IDE/构建工具可能要求public)。2. 方法有返回值或参数。3. 使用了JUnit 4的Test注解(org.junit.Test)。1. 确保方法至少是包可见性建议用public或默认。2.Test方法必须返回void且无参数。有参数需用ParameterizedTest。3. 检查导入确保是org.junit.jupiter.api.Test。BeforeAll/AfterAll方法报错“必须是static”默认测试生命周期是PER_METHOD这些方法需要是静态的。1. 将方法改为static。2. 或者将测试类注解为TestInstance(Lifecycle.PER_CLASS)则这些方法可以是实例方法。断言失败信息不清晰使用了过时的断言方法顺序或消息是立即求值的字符串。使用JUnit 5的断言并将消息放在最后一个参数。对于复杂消息使用Lambda表达式assertEquals(expected, actual, () - “复杂消息: ” expensiveOp())。Mockito的Mock或InjectMocks字段为null没有使用ExtendWith(MockitoExtension.class)注解测试类。在测试类上添加ExtendWith(MockitoExtension.class)。在Spring Boot测试中使用MockBean代替Mock。测试在IDE中运行正常但mvn test失败1. Maven Surefire插件版本太旧不支持JUnit 5。2. 依赖冲突或作用域问题。1. 确保使用Maven Surefire插件 2.22.0 或更高版本并正确配置见3.1节。2. 运行mvn dependency:tree检查依赖确保junit-vintage-engine等冲突依赖被排除。测试执行顺序不可控JUnit 5默认不保证测试方法执行顺序。如果需要固定顺序使用TestMethodOrder注解类并配合MethodOrderer实现如MethodOrderer.OrderAnnotation然后在方法上用Order指定序号。注意真正的单元测试应该是独立的不依赖顺序。ParameterizedTest无法解析数据源1. 未添加junit-jupiter-params依赖。2. 数据源方法不是static或返回类型不对。1. 添加依赖。2. 对于MethodSource提供的方法必须是static的且返回Stream,Collection,Iterable等。8.2 单元测试最佳实践心得测试命名要清晰测试方法名应该反映其意图而不仅仅是方法名。可以用方法名_测试场景_预期结果的格式例如divide_DivisorIsZero_ThrowsException。结合DisplayName使用效果更佳。一个测试只验证一件事不要让一个测试方法承担过多的断言。如果测试失败你应该能立刻知道是哪个功能点出了问题。使用Given-When-Then模式在测试方法内用注释明确划分“准备(Given)”、“执行(When)”、“断言(Then)”三个阶段这能让测试逻辑一目了然。测试行为而非实现单元测试应该关注类的公开行为方法输出、状态变化、对外交互而不是其内部私有实现。这样当内部重构时测试不需要频繁修改。避免测试中的逻辑测试代码本身应该简单直白避免出现if-else、循环等复杂逻辑。复杂的测试代码容易引入bug而且难以理解。合理使用Mock只Mock真正的外部依赖如数据库、网络服务、文件系统。不要Mock被测类所依赖的、你同样拥有控制权的普通对象值对象、DTO等这会让测试变得脆弱。关注测试覆盖率但别迷信高测试覆盖率是好事但100%覆盖率不代表没bug。更重要的是测试用例的质量是否覆盖了正常路径、边界条件和异常情况。让测试快速运行单元测试应该能在几秒内运行完毕。如果测试套件运行缓慢开发人员就不会频繁运行它。避免在单元测试中进行真正的文件I/O、网络调用或数据库访问使用Mock或内存数据库。测试代码也是代码像对待生产代码一样对待测试代码。遵循DRY原则将重复的准备逻辑抽取到BeforeEach方法或工具类中。保持测试代码的整洁和可读性。8.3 关于“单元测试 代码生成 插件”搜索热词中提到了“单元测试 代码生成 插件”。这类工具如IDE的插件、或独立的生成工具可以快速生成测试方法骨架节省你敲击键盘的时间。它们通常能根据类的方法签名自动创建带有Test注解的空方法甚至使用Mockito框架来模拟依赖。我的建议是可以合理利用它们作为起点特别是对于大型项目中有大量Getter/Setter或简单委托方法需要覆盖时。但是千万不要依赖它们来“编写”测试逻辑。测试的核心在于定义“预期行为”这需要你对业务逻辑有深刻理解是代码生成器无法替代的。生成的骨架只是一个空壳里面的断言、模拟行为、数据准备才是测试的灵魂必须由开发者亲手填充。把生成插件当作一个加速器而不是自动驾驶仪。