Spring Boot单元测试实战:从代码可测性到分层测试策略 1. 项目概述从“不敢测”到“测得爽”的蜕变之路我见过太多Java开发者尤其是刚接触Spring Boot的朋友对单元测试的态度可以用“敬而远之”来形容。项目初期大家忙着搭框架、写业务逻辑觉得测试是“锦上添花”甚至“浪费时间”。等到代码越堆越多改一行代码心惊胆战生怕引发连锁反应时才想起来测试的重要性但面对一堆紧密耦合的Service和Repository又不知从何下手陷入了“不敢测”的困境。这种困境我经历过也带领团队走出来过。今天我们就以Spring Boot JUnit 5这个最主流的组合为例彻底解决这个问题。我们的目标不是让你“会写”测试而是让你“爱写”测试体验到那种代码健壮、重构无忧、交付自信的“测得爽”状态。无论你是测试新手还是写过一些Test但总觉得不得要领的老手这篇实战指南都将带你绕过我踩过的坑直抵高效测试的核心。2. 核心思路构建可测试的代码与高效的测试策略写不出好测试很多时候问题不在测试框架本身而在于代码不可测。我们的首要任务不是学习JUnit 5的注解而是扭转思维设计出易于测试的代码结构。2.1 为什么你的代码“不敢测”—— 识别测试的敌人“不敢测”的根源通常是高耦合度。想象一下你的UserService里直接new了一个EmailSender或者通过static方法调用了一个工具类。当你只想测试UserService的业务逻辑时却不得不启动整个邮件服务器或者依赖那个工具类的复杂环境。这根本不是单元测试这是集成测试甚至系统测试速度慢、不稳定、难以维护。核心敌人一紧耦合的依赖。这是最大的障碍。单元测试要求我们能将被测单元如一个方法与其依赖隔离开单独验证其逻辑。如果依赖是硬编码的隔离就无从谈起。核心敌人二难以模拟的静态方法和构造方法。直接调用FileUtils.readFile(...)或new DatabaseConnection(...)会让测试变得极其脆弱因为你无法控制这些调用的行为比如模拟一个文件不存在的情况。核心敌人三复杂的测试环境配置。觉得启动整个Spring容器太慢或者为了测试一个方法需要配置一整套数据库、消息队列。这违背了单元测试“快速反馈”的初衷。2.2 从“可测试性”出发的设计原则要“测得爽”必须在编码时就考虑测试。这并非额外负担而是编写高质量代码的自然结果。原则一依赖注入Dependency Injection。这是Spring的核心也是可测试性的基石。不要在被测类内部创建依赖对象而是通过构造函数或Setter方法从外部注入。这样在测试时我们就可以轻松地注入一个模拟Mock对象来代替真实的依赖。// 不可测试的写法 public class OrderService { private PaymentGateway paymentGateway new PaymentGateway(); public boolean processOrder(Order order) { return paymentGateway.charge(order.getAmount()); } } // 可测试的写法 Service public class OrderService { private final PaymentGateway paymentGateway; // 通过构造器注入依赖关系明确 public OrderService(PaymentGateway paymentGateway) { this.paymentGateway paymentGateway; } public boolean processOrder(Order order) { return paymentGateway.charge(order.getAmount()); } }原则二面向接口编程。依赖尽可能使用接口类型而不是具体实现类。PaymentGateway是一个接口它可能有StripePaymentGateway或MockPaymentGateway等多种实现。在测试中你可以注入一个模拟的PaymentGateway实现完全控制其行为。原则三单一职责原则SRP。一个类只做一件事。如果一个UserService既处理用户认证又发送邮件还更新积分那么测试它将非常复杂。将其拆分为AuthService、NotificationService、RewardService每个类的测试都会变得简单而专注。遵循这些原则你的代码会自然变得清晰、模块化单元测试的编写也就水到渠成了。3. 环境搭建与核心工具链解析工欲善其事必先利其器。一个顺手的测试环境能极大提升“测得爽”的体验。我们基于Spring Boot 3.x当前主流稳定版本和JUnit 5来搭建。3.1 项目依赖配置MavenSpring Boot的spring-boot-starter-test已经为我们集成了JUnit 5、AssertJ、Hamcrest、Mockito等一整套测试工具开箱即用。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope !-- 排除可选的JUnit 4遗留模块确保纯净的JUnit 5环境 -- exclusions exclusion groupIdorg.junit.vintage/groupId artifactIdjunit-vintage-engine/artifactId /exclusion /exclusions /dependency这个Starter包含了JUnit Jupiter (JUnit 5): 测试框架本体。Spring Test Spring Boot Test: 提供Spring上下文支持、测试切片如WebMvcTest等。AssertJ: 提供流式、可读性极强的断言。Hamcrest: 提供匹配器Matcher用于更灵活的断言。Mockito: 目前最流行的Java Mock框架用于创建和配置模拟对象。JSONassert: 用于JSON断言。JsonPath: 用于解析JSON。注意在Spring Boot 2.2及以上版本默认已使用JUnit 5。如果你从旧项目升级务必检查并排除JUnit 4的依赖避免冲突。3.2 测试代码结构规范清晰的代码结构是可持续测试的基础。我强烈推荐以下约定src/test/java/ └── com/yourcompany/yourapp/ ├── YourApplicationTests.java // Spring Boot生成的入口测试类 ├── service/ │ ├── UserServiceTest.java // 服务层测试 │ └── OrderServiceTest.java ├── controller/ │ └── UserControllerTest.java // 控制器层测试使用WebMvcTest ├── repository/ │ └── UserRepositoryTest.java // 仓库层测试使用DataJpaTest └── utils/ └── DateUtilsTest.java // 工具类测试纯JUnit不启动Spring关键点测试类的命名通常为被测试类名Test。包结构保持与src/main/java一致这样IDE能更好地组织和管理测试。3.3 核心注解初探在深入实战前先快速认识几个最核心的JUnit 5和Spring Boot Test注解它们是你的“测试工具箱”里的基础工具。Test: 标记一个方法为测试方法。这是最基本的。DisplayName: 为测试类或方法提供一个可读的名称会在测试报告中显示。强烈建议使用让测试意图一目了然。例如DisplayName(当用户名为空时注册应失败)。BeforeEach/AfterEach: 在每个Test方法之前/之后运行。用于准备和清理测试数据如初始化对象、重置模拟状态。BeforeAll/AfterAll: 在所有Test方法之前/之后运行一次。必须是static方法。用于执行重量级且一次性的操作如启动嵌入式数据库但Spring Boot Test有更好的方式。SpringBootTest: Spring Boot的“全能型”测试注解。它会启动一个完整的或接近完整的Spring应用上下文。慎用因为它很重启动慢。通常用于集成测试或需要完整上下文的场景。ExtendWith: JUnit 5的扩展机制。Spring Boot Test通过SpringBootTest等注解内部已经集成了ExtendWith(SpringExtension.class)所以我们通常不需要显式写出。理解了这些基础我们就可以开始动手写第一个“爽快”的测试了。4. 实战演练分层测试策略与Mockito深度应用测试不是一刀切。针对代码的不同层次我们应该采用不同的测试策略和Spring Boot提供的“测试切片”Test Slices以达到精度和速度的平衡。4.1 服务层Service测试Mockito的舞台服务层包含核心业务逻辑是单元测试的重点。这里我们追求快速和隔离因此要尽可能模拟Mock掉所有外部依赖如Repository、第三方客户端等。场景测试一个UserService的register方法该方法会检查用户名是否已存在调用UserRepository密码加密调用PasswordEncoder然后保存用户再次调用UserRepository。import org.junit.jupiter.api.DisplayName; 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 java.util.Optional; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; // 使用Mockito扩展这是纯单元测试不启动Spring容器速度极快。 ExtendWith(MockitoExtension.class) class UserServiceTest { Mock // 模拟UserRepository依赖 private UserRepository userRepository; Mock // 模拟PasswordEncoder依赖 private PasswordEncoder passwordEncoder; InjectMocks // 将上面的Mock注入到被测试的UserService实例中 private UserService userService; Test DisplayName(当提供有效用户信息时应成功注册并返回保存的用户) void register_WithValidUser_ShouldSucceed() { // 1. 准备阶段 (Given) // 模拟依赖行为假设用户名不存在密码编码器返回一个编码后的字符串 String rawPassword myPassword123; String encodedPassword encoded_xyz; User userToSave new User(newUser, rawPassword); User savedUser new User(1L, newUser, encodedPassword); given(userRepository.findByUsername(newUser)).willReturn(Optional.empty()); given(passwordEncoder.encode(rawPassword)).willReturn(encodedPassword); given(userRepository.save(any(User.class))).willReturn(savedUser); // 2. 执行阶段 (When) User result userService.register(userToSave); // 3. 断言阶段 (Then) // 使用AssertJ进行流式断言可读性更强 assertThat(result).isNotNull(); assertThat(result.getId()).isEqualTo(1L); assertThat(result.getPassword()).isEqualTo(encodedPassword); // 密码应被编码 // 验证交互确保save方法被调用了一次且参数是密码被编码后的用户 verify(userRepository).save(argThat(user - user.getUsername().equals(newUser) user.getPassword().equals(encodedPassword) )); } Test DisplayName(当用户名已存在时注册应抛出业务异常) void register_WithDuplicateUsername_ShouldThrowException() { // Given String existingUsername oldUser; User existingUser new User(existingUsername, pwd); given(userRepository.findByUsername(existingUsername)).willReturn(Optional.of(existingUser)); // When Then // 断言会抛出特定的异常 assertThatThrownBy(() - userService.register(new User(existingUsername, anyPassword))) .isInstanceOf(DuplicateUsernameException.class) .hasMessageContaining(用户名已存在); // 验证交互因为失败了所以save方法绝对不应该被调用 verify(userRepository, never()).save(any()); } }代码解读与技巧ExtendWith(MockitoExtension.class): 这是关键。它初始化了Mockito框架并处理了Mock和InjectMocks注解。整个测试不启动Spring运行速度在毫秒级。Given-When-Then模式这是行为驱动开发BDD的风格让测试结构异常清晰。given准备模拟数据when执行被测方法then验证结果和交互。given(...).willReturn(...): Mockito的BDD风格语法用于定义模拟对象的行为。比传统的when(...).thenReturn(...)可读性稍好。verify(...): 用于验证模拟对象是否按预期进行了交互。这是单元测试中验证“行为”而非“状态”的重要手段。例如验证save方法被调用了一次times(1)是默认值可省略或者用never()验证它没被调用。argThat: 一个强大的参数匹配器。当需要验证传入模拟方法的参数是否符合复杂条件时使用比简单的eq()更灵活。AssertJ断言assertThat(actual).isEqualTo(expected)是其基本形式。它的优势在于链式调用和丰富的断言方法如isNotNull()、hasSize()、contains()等断言失败时的信息也更友好。实操心得尽量为每个测试方法只准备一种场景Given执行一个动作When做一组相关的断言Then。如果一个测试方法里出现了多个when和杂乱的断言说明它可能测试了太多东西应该拆分成多个测试。这符合单元测试的“单一职责”原则。4.2 控制器层Controller测试使用WebMvcTest进行切片测试控制器测试关注HTTP层请求映射、参数绑定、响应状态和内容。我们不想启动整个应用只想测试Web层。WebMvcTest就是为此而生。场景测试一个UserController的获取用户详情接口。import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import com.fasterxml.jackson.databind.ObjectMapper; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; // WebMvcTest会为指定的控制器启动一个简化的Web上下文不会加载Service、Repository等Bean。 WebMvcTest(UserController.class) class UserControllerTest { Autowired private MockMvc mockMvc; // 模拟MVC环境用于发送虚拟请求 Autowired private ObjectMapper objectMapper; // 用于JSON序列化/反序列化 MockBean // 在Spring上下文中模拟一个Bean这里模拟Service private UserService userService; Test DisplayName(GET /users/{id} - 当用户存在时应返回200和用户JSON) void getUserById_WhenUserExists_ShouldReturnUser() throws Exception { // Given Long userId 1L; User mockUser new User(userId, testUser, encodedPwd); given(userService.getUserById(userId)).willReturn(Optional.of(mockUser)); // When Then mockMvc.perform(get(/users/{id}, userId) // 构建GET请求 .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) // 断言状态码200 .andExpect(content().contentType(MediaType.APPLICATION_JSON)) // 断言内容类型 .andExpect(jsonPath($.id).value(userId)) // 使用JsonPath断言JSON字段 .andExpect(jsonPath($.username).value(testUser)) .andDo(print()); // 打印请求和响应详情调试时非常有用生产测试可移除 } Test DisplayName(GET /users/{id} - 当用户不存在时应返回404) void getUserById_WhenUserNotExists_ShouldReturn404() throws Exception { // Given Long userId 999L; given(userService.getUserById(userId)).willReturn(Optional.empty()); // When Then mockMvc.perform(get(/users/{id}, userId)) .andExpect(status().isNotFound()); } }关键点解析WebMvcTest(UserController.class): 这个注解是“切片测试”的典范。它只加载Web层相关的Bean如Controller,ControllerAdvice,JsonComponent等以及MockMvc本身。UserService不会被加载所以我们需要用MockBean来提供一个模拟版本。这比SpringBootTest快得多。MockMvc: 模拟HTTP请求的核心工具。你可以用它模拟GET、POST、PUT、DELETE等各种请求并设置请求头、参数、内容体。MockBean: 这是Spring Boot Test提供的注解用于在Spring的ApplicationContext中添加一个Mockito模拟对象。它与Mock不同Mock是纯Mockito注解用于非Spring环境。jsonPath: 一个强大的DSL用于从JSON响应体中提取和断言值。$.id表示根节点的id字段。注意事项WebMvcTest默认会应用Spring Security配置。如果你的Controller有安全限制可能需要额外配置WithMockUser等注解来模拟认证用户或者使用AutoConfigureMockMvc(addFilters false)来禁用安全过滤器链。4.3 数据层Repository测试使用DataJpaTest与嵌入式数据库Repository测试需要与真实的数据库交互但为了速度和隔离我们不应使用开发数据库。Spring Boot提供了DataJpaTest它会自动配置一个内存嵌入式数据库如H2并只初始化JPA相关的组件。场景测试UserRepository的查询方法。import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; import javax.persistence.EntityManager; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; // DataJpaTest会配置内存数据库并自动注入TestEntityManager DataJpaTest class UserRepositoryTest { Autowired private TestEntityManager testEntityManager; // 用于便捷地操作测试数据库 Autowired private UserRepository userRepository; Test DisplayName(按用户名查找用户应返回正确用户) void findByUsername_ShouldReturnUser() { // Given: 使用TestEntityManager持久化测试数据不经过Repository User user new User(alice, secret); testEntityManager.persistAndFlush(user); // 立即写入数据库 // When OptionalUser found userRepository.findByUsername(alice); // Then assertThat(found).isPresent(); assertThat(found.get().getUsername()).isEqualTo(alice); } Test DisplayName(查找不存在的用户名应返回空Optional) void findByUsername_WhenNotExists_ShouldReturnEmpty() { // When OptionalUser found userRepository.findByUsername(nobody); // Then assertThat(found).isEmpty(); } Test DisplayName(查找所有活跃用户应只返回状态为ACTIVE的用户) void findAllActiveUsers_ShouldReturnOnlyActiveOnes() { // Given User activeUser new User(active, pwd); activeUser.setStatus(Status.ACTIVE); User inactiveUser new User(inactive, pwd); inactiveUser.setStatus(Status.INACTIVE); testEntityManager.persist(activeUser); testEntityManager.persist(inactiveUser); testEntityManager.flush(); // When ListUser activeUsers userRepository.findAllByStatus(Status.ACTIVE); // Then assertThat(activeUsers).hasSize(1); assertThat(activeUsers.get(0).getUsername()).isEqualTo(active); } }为什么用TestEntityManager而不是userRepository.save()在Given阶段使用TestEntityManager可以更直接地与数据库交互避免被测的save方法本身可能存在问题而影响测试的“准备”阶段。这保证了测试的纯粹性我们只测试findByUsername这个方法save的逻辑由框架保证或另有测试覆盖。踩坑记录DataJpaTest默认会回滚事务每个测试方法执行后数据都会清理保证了测试的独立性。但如果你在测试中手动调用了entityManager.flush()或entityManager.clear()需要注意它们对持久化上下文的影响。另外确保你的实体类映射正确否则在插入测试数据时可能会遇到约束错误。5. 高级技巧与最佳实践让测试更稳健、更高效掌握了基础写法我们来看看如何让测试代码本身也保持高质量并应对更复杂的场景。5.1 测试数据管理BeforeEach与工厂方法重复的测试数据准备代码是“代码臭味”。我们可以重构它们。ExtendWith(MockitoExtension.class) class OrderServiceTest { Mock private InventoryService inventoryService; Mock private PaymentService paymentService; InjectMocks private OrderService orderService; private Order validOrder; private Customer testCustomer; // 在每个测试方法前运行初始化公共的测试数据 BeforeEach void setUp() { testCustomer new Customer(1L, 张三, zhangsanexample.com); validOrder Order.builder() .id(100L) .customer(testCustomer) .items(List.of(new OrderItem(item1, 2, BigDecimal.valueOf(50)))) .totalAmount(BigDecimal.valueOf(100)) .build(); } Test void processOrder_WithSufficientInventory_ShouldSucceed() { // Given: 直接使用setUp中初始化好的validOrder given(inventoryService.isSufficient(any())).willReturn(true); given(paymentService.process(any(BigDecimal.class))).willReturn(new PaymentResult(true, success)); // When Then ... } }对于更复杂或需要多种变体的对象可以考虑使用测试数据工厂如ObjectMother模式或Builder模式将对象构建逻辑封装起来使测试用例更清晰。5.2 参数化测试用一组数据测试多种情况当你想用多组输入输出数据测试同一个方法逻辑时JUnit 5的ParameterizedTest是绝佳选择。import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; class ValidationUtilsTest { ParameterizedTest ValueSource(strings {, , \t, \n}) DisplayName(isBlank - 各种空白字符串应返回true) void isBlank_ShouldReturnTrueForAllBlankStrings(String input) { assertThat(ValidationUtils.isBlank(input)).isTrue(); } ParameterizedTest CsvSource({ abcexample.com, true, invalid-email, false, , false, null, false }) DisplayName(isValidEmail - 应正确验证邮箱格式) void isValidEmail_ShouldValidateCorrectly(String input, boolean expected) { // 处理CSV中的null字符串 String actualInput null.equals(input) ? null : input; assertThat(ValidationUtils.isValidEmail(actualInput)).isEqualTo(expected); } }CsvSource让你能以CSV格式直接在注解中定义多组参数非常方便。还有MethodSource可以引用一个返回流Stream或集合的工厂方法适合更复杂的数据准备。5.3 断言的艺术优先使用AssertJJUnit自带的assertEquals等断言够用但AssertJ提供了流式API和极其丰富的断言方法让测试代码读起来像自然语言并且失败信息更清晰。import static org.assertj.core.api.Assertions.*; Test void assertJExamples() { ListString list Arrays.asList(apple, banana, cherry); // 链式调用可读性强 assertThat(list) .isNotEmpty() .hasSize(3) .contains(banana) // 包含某个元素 .containsExactly(apple, banana, cherry) // 包含且仅包含这些元素顺序一致 .doesNotContain(durian); MapString, Integer map Map.of(a, 1, b, 2); assertThat(map) .containsEntry(a, 1) // 包含特定键值对 .hasSize(2); String message Hello, World!; assertThat(message) .startsWith(Hello) .contains(World) .doesNotContain(Error); // 异常断言 assertThatThrownBy(() - { throw new IllegalArgumentException(wrong arg); }) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining(wrong arg); }5.4 测试私有方法—— 一个常见的误区经常有人问“怎么测试一个private方法” 我的回答是不要直接测试私有方法。私有方法是实现细节应该通过测试其所在的公有方法来间接覆盖。如果你觉得一个私有方法复杂到需要单独测试这通常是一个信号这个方法应该被提取到一个独立的类可能是工具类或策略类中并提升为公有或包私有方法然后单独测试它。这符合“单一职责”和“高内聚”的原则。重构前public class ComplexService { public Result publicMethod(Input input) { // ... 一些逻辑 int intermediate this.veryComplexPrivateMethod(input.getData()); // 复杂的私有方法 // ... 更多逻辑 return result; } private int veryComplexPrivateMethod(Data data) { // 非常复杂的计算逻辑 } }重构后// 将复杂逻辑提取到一个独立的、可测试的类中 public class ComplexCalculator { public int calculate(Data data) { // 现在是公有方法可测试 // 非常复杂的计算逻辑 } } public class ComplexService { private final ComplexCalculator calculator; public ComplexService(ComplexCalculator calculator) { this.calculator calculator; } public Result publicMethod(Input input) { // ... 一些逻辑 int intermediate calculator.calculate(input.getData()); // 调用外部组件 // ... 更多逻辑 return result; } }现在你可以轻松地为ComplexCalculator编写单元测试而ComplexService的测试则可以通过MockComplexCalculator来聚焦于其自身的协调逻辑。6. 常见问题排查与测试心态建设即使掌握了所有技术在实际编写测试时还是会遇到各种问题。这里记录一些典型问题的排查思路和解决方案。6.1 测试失败常见原因速查表现象可能原因排查步骤与解决方案NullPointerException1.Mock/InjectMocks注解未生效。2. 未在BeforeEach中初始化非Mock对象。3. 模拟行为given未设置返回了null。1. 检查测试类是否加了ExtendWith(MockitoExtension.class)纯Mockito测试或相应的Spring测试注解。2. 确保所有测试依赖都在BeforeEach或测试方法内正确初始化。3. 检查是否对所有被调用的模拟方法都设置了willReturn或willThrow。NoSuchBeanDefinitionException1. 在切片测试如WebMvcTest中引用了未在该切片范围内的Bean。2. 漏加了MockBean来提供必需的模拟Bean。1. 确认你的测试注解是否限制了上下文。例如WebMvcTest只加载Web层Bean如果你需要DataSource它不会被加载。2. 对于必需的依赖使用MockBean或Import导入特定的配置类。测试通过但实际功能错误1. 模拟行为设置过于宽松或错误。2. 断言不够充分只验证了部分结果。3. 使用了any()等宽松的参数匹配器掩盖了参数传递错误。1. 仔细检查given设置是否符合真实场景。使用verify验证交互时考虑使用更严格的参数匹配器如eq()、argThat。2. 增加断言覆盖返回值、状态变更、对外交互等所有预期行为。3. 在关键调用上避免过度使用any()尽量指定具体的参数值或条件。测试运行缓慢1. 过度使用SpringBootTest启动了完整上下文。2. 在BeforeAll/BeforeEach中执行了耗时操作如连接真实数据库。3. 测试类之间没有做好隔离上下文重复启动。1. 优先使用切片测试WebMvcTest,DataJpaTest,JsonTest等。2. 使用内存数据库H2进行数据层测试用Mock替代外部HTTP调用。3. 利用Spring Test的上下文缓存机制默认已开启但也要注意清理脏数据。6.2 测试心态建设从负担到习惯最后分享几点心态上的建议这比任何技术点都重要测试驱动开发TDD可以尝试但不强求。TDD先写测试再写实现是一种强大的设计工具但对于初学者可能有些困难。你可以从“测试紧随开发”Test-After开始写完一个功能方法立刻为它补上测试。这同样能获得快速反馈和安全感。把测试代码当成生产代码一样维护。测试代码也需要清晰、可读、无重复。糟糕的测试代码会成为维护的噩梦。定期重构你的测试。追求“测试信心”而不是“测试覆盖率”。100%的测试覆盖率是个美好的目标但有时为了覆盖而覆盖会写出无意义的测试。更重要的是你的测试套件是否能给你足够的信心去重构代码、发布新版本一组精心设计、覆盖核心路径和边界条件的测试远比一堆脆弱的、覆盖所有getter/setter的测试有价值。让测试失败信息成为你的朋友。当测试失败时不要沮丧。清晰的失败信息这正是AssertJ的优势能快速帮你定位问题。这是测试在为你工作提前发现了潜在Bug。“测得爽”是一种正反馈。当你因为有了测试而敢大胆重构、快速添加功能时当你看到CI/CD流水线因为测试通过而绿灯畅行时那种掌控感和自信心就是“测得爽”的最佳回报。从今天开始为你写的每一段核心逻辑配上一个小小的测试吧。