Java单元测试实战指南:从JUnit 5到Mockito的最佳实践

📅 2026/6/24 21:51:24 👤 编程新知 🏷️ 技术资讯
Java单元测试实战指南:从JUnit 5到Mockito的最佳实践 1. 项目概述为什么单元测试是开发者的“安全网”干了这么多年开发我见过太多项目因为缺少有效的单元测试而陷入泥潭。代码改一点功能崩一片线上问题频发团队疲于奔命地“救火”。单元测试说白了就是给代码上的一道“保险”或者说是开发者在编码时为自己铺设的“安全网”。它不是为了应付流程而是为了让你在修改代码时能底气十足地说“我知道这个改动没破坏原有功能。”对于Java开发者而言单元测试更是基本功。无论是刚入行的新手还是经验丰富的老手系统性地掌握单元测试的理念、工具和实践都能让你的代码质量、开发效率和职业自信提升一个档次。这篇文章我将结合自己踩过的无数坑为你详细拆解单元测试的核心概念对比主流工具并分享在不同Java项目类型中落地单元测试的最佳实践和完整代码示例。无论你是想系统学习还是为面试准备“八股文”这里都有你需要的干货。2. 单元测试核心概念深度解析2.1 单元测试究竟是什么不只是“测试代码”很多人把单元测试简单地理解为“写一段代码去测试另一段代码”。这个定义没错但太浅了。单元测试的“单元”通常指一个类中的一个方法它是软件中最小的可测试部分。其核心价值在于隔离性和快速反馈。隔离性意味着测试一个单元时应尽可能屏蔽其外部依赖如数据库、网络服务、文件系统等。为什么因为如果测试失败你希望立刻知道是“这个单元”的逻辑出了问题而不是因为数据库连接超时或者某个远程API挂了。这种隔离是通过Mock模拟或Stub桩等技术实现的后面我们会详细讲。快速反馈则要求单元测试必须执行得非常快。想象一下如果你有上千个单元测试跑一次要半小时你还会频繁地跑吗肯定不会。快速的测试套件理想情况在几分钟内才能融入开发流程比如在提交代码前自动运行确保本次提交没有引入回归错误。所以单元测试的本质是一种设计工具和文档工具。它迫使你思考如何让代码更易于测试这往往意味着更清晰的接口、更低的耦合和更高的内聚——这些都是优秀设计的特征。同时一组好的测试用例本身就是一份活的、不会过时的API使用说明书。2.2 单元测试 vs. 集成测试 vs. 端到端测试找准定位在实际项目中测试是分层的单元测试只是金字塔的底座。理解它们的区别才能合理运用。单元测试关注单个类或方法的内部逻辑。速度快、隔离好、定位问题精确。它是开发者的主要工具数量应该最多。集成测试验证多个模块或服务之间的交互是否正确。例如测试Service层与真实的数据库Repository的集成。速度中等依赖外部环境如测试数据库用于检查模块间的契约。端到端测试模拟真实用户操作验证整个应用流程。例如通过Selenium测试Web页面从登录到下单的完整流程。速度慢、脆弱、维护成本高但能发现跨子系统的问题。一个健康的测试策略应该是“金字塔”形状大量的单元测试构成坚实基础较少的集成测试在中间最少的端到端测试在顶端。很多团队的问题在于把这个金字塔倒过来了写了大量又慢又脆的E2E测试导致测试套件无法高效运行。注意不要试图用单元测试去覆盖所有场景。比如测试一个方法是否将数据正确写入数据库这更应该是一个集成测试。单元测试应该假设“如果数据库层工作正常我的业务逻辑是否正确”。3. 主流Java单元测试工具横向对比与选型指南Java生态中单元测试框架选择丰富但主流组合非常清晰。下面这个表格对比了最常用的工具栈工具类别主要工具核心职责特点与适用场景测试框架JUnit 4 / 5提供测试用例的发现、执行和断言的基础设施。JUnit 5是当前绝对主流和推荐选择。它模块化设计支持Lambda断言库更强大扩展模型更灵活。JUnit 4已停止开发仅用于维护老项目。断言库AssertJ, Hamcrest提供更丰富、更可读的断言语法用于验证测试结果。AssertJ是首选它的流式API非常优雅错误信息清晰。例如assertThat(actual).isEqualTo(expected).startsWith(“foo”)。Hamcrest匹配器也不错但语法稍显冗长。JUnit 5自带的断言也已足够好用。Mock框架Mockito, EasyMock创建和管理模拟对象Mock以隔离被测试单元。Mockito是事实标准API简洁直观社区活跃。对于绝大多数场景Mockito JUnit 5的组合是黄金标准。EasyMock使用较少。测试覆盖率JaCoCo, Cobertura分析测试代码对生产代码的覆盖程度生成报告。JaCoCo是主流它与构建工具Maven/Gradle集成简单报告直观。覆盖率是一个重要指标但切忌盲目追求高数字如100%应关注核心逻辑和复杂分支的覆盖。选型结论与实操建议 对于新项目无脑选择JUnit 5 AssertJ Mockito JaCoCo这个组合。在Maven中你的pom.xml依赖看起来是这样的dependencies !-- JUnit 5 (Jupiter) -- dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter/artifactId version5.10.0/version !-- 请使用最新稳定版 -- scopetest/scope /dependency !-- AssertJ -- dependency groupIdorg.assertj/groupId artifactIdassertj-core/artifactId version3.24.2/version scopetest/scope /dependency !-- Mockito -- dependency groupIdorg.mockito/groupId artifactIdmockito-core/artifactId version5.7.0/version scopetest/scope /dependency dependency groupIdorg.mockito/groupId artifactIdmockito-junit-jupiter/artifactId !-- 与JUnit5集成 -- version5.7.0/version scopetest/scope /dependency /dependencies build plugins !-- 用于执行测试的Maven Surefire插件 -- plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-surefire-plugin/artifactId version3.1.2/version /plugin !-- JaCoCo覆盖率插件 -- plugin groupIdorg.jacoco/groupId artifactIdjacoco-maven-plugin/artifactId version0.8.10/version executions execution goals goalprepare-agent/goal /goals /execution execution idreport/id phasetest/phase goals goalreport/goal /goals /execution /executions /plugin /plugins /build对于老项目仍在使用JUnit 4如果条件允许建议逐步迁移到JUnit 5。迁移过程通常是渐进式的因为JUnit 5提供了一个兼容JUnit 4的引擎junit-vintage-engine允许新旧测试共存。4. 单元测试最佳实践从写好一个测试用例开始知道了用什么工具接下来最关键的是“怎么写”。下面这些实践是我从无数项目和代码审查中总结出来的能让你写的测试更健壮、更有价值。4.1 测试命名清晰即正义测试方法的名字应该清晰地表达它的意图。不要用test1,testAdd这种名字。推荐使用[被测试方法]_[测试场景]_[预期结果]的格式。JUnit 5支持方法名中包含空格通过DisplayName注解可读性更强。// 不推荐 Test void testTransfer() { // ... } // 推荐使用DisplayName Test DisplayName(“当转账金额为正且余额充足时应成功扣款并更新账户”) void transfer_WithSufficientBalance_ShouldSucceed() { // ... } // 推荐传统命名法清晰 Test void transfer_withInsufficientBalance_shouldThrowInsufficientFundsException() { // ... }4.2 遵循Given-When-Then模式结构化的测试逻辑这是组织测试代码的黄金法则能让你的测试像一个小故事一样清晰。Given (准备)设置测试前提初始化测试数据Mock依赖行为。When (执行)调用被测试的方法。Then (验证)断言执行结果是否符合预期。Test void getUserById_withValidId_shouldReturnUser() { // Given Long userId 1L; User expectedUser new User(userId, “Alice”); // Mock当userRepository.findById(1L)被调用时返回expectedUser when(userRepository.findById(userId)).thenReturn(Optional.of(expectedUser)); // When User actualUser userService.getUserById(userId); // Then assertThat(actualUser).isEqualTo(expectedUser); // 验证Mock的交互确实发生了 verify(userRepository).findById(userId); }4.3 测试隔离与Mock的精髓只关注你要测的单元这是单元测试的核心难点。假设你要测一个OrderService的placeOrder方法它内部调用了InventoryService检查库存和PaymentService处理支付。public class OrderService { private InventoryService inventoryService; private PaymentService paymentService; private OrderRepository orderRepository; public Order placeOrder(OrderRequest request) throws InsufficientInventoryException, PaymentFailedException { // 1. 检查库存 boolean inStock inventoryService.checkStock(request.getProductId(), request.getQuantity()); if (!inStock) { throw new InsufficientInventoryException(“库存不足”); } // 2. 处理支付 boolean paymentSuccess paymentService.processPayment(request.getPaymentInfo()); if (!paymentSuccess) { throw new PaymentFailedException(“支付失败”); } // 3. 创建订单 Order order createOrderFromRequest(request); return orderRepository.save(order); } }在单元测试placeOrder时我们不应该去启动一个真实的库存系统或支付网关。我们必须Mock掉InventoryService和PaymentService。ExtendWith(MockitoExtension.class) // JUnit5集成Mockito class OrderServiceTest { Mock private InventoryService inventoryService; Mock private PaymentService paymentService; Mock private OrderRepository orderRepository; InjectMocks // 将上面的Mock注入到被测试对象中 private OrderService orderService; Test void placeOrder_withSufficientStockAndSuccessfulPayment_shouldSaveOrder() throws Exception { // Given OrderRequest request new OrderRequest(...); Order expectedOrder new Order(...); when(inventoryService.checkStock(any(), anyInt())).thenReturn(true); when(paymentService.processPayment(any())).thenReturn(true); when(orderRepository.save(any(Order.class))).thenReturn(expectedOrder); // When Order result orderService.placeOrder(request); // Then assertThat(result).isEqualTo(expectedOrder); verify(inventoryService).checkStock(eq(request.getProductId()), eq(request.getQuantity())); verify(paymentService).processPayment(eq(request.getPaymentInfo())); verify(orderRepository).save(any(Order.class)); } Test void placeOrder_withInsufficientStock_shouldThrowException() { // Given OrderRequest request new OrderRequest(...); when(inventoryService.checkStock(any(), anyInt())).thenReturn(false); // 模拟库存不足 // When Then assertThatThrownBy(() - orderService.placeOrder(request)) .isInstanceOf(InsufficientInventoryException.class) .hasMessageContaining(“库存不足”); // 验证支付服务没有被调用因为库存检查已失败 verify(paymentService, never()).processPayment(any()); } }Mock使用心得Mock行为而非数据重点在于“当调用某个方法时返回什么”或“是否被调用”。不要过度配置Mock对象的复杂内部状态。使用verify进行行为验证除了验证返回值还要验证与依赖的交互是否符合预期如方法是否被调用、调用了几次、参数是什么。这是确保业务逻辑流程正确的关键。谨慎使用any()any()等参数匹配器很方便但有时过于宽松。更推荐使用eq()匹配具体值或者使用自定义的ArgumentMatcher这样测试会更精确。4.4 测试数据准备灵活使用BeforeEach与工厂方法对于每个测试都需要的基础数据或公共设置可以使用JUnit 5的BeforeEach。class UserServiceTest { private UserService userService; Mock private UserRepository userRepository; private User testUser; BeforeEach void setUp() { // 在每个测试方法执行前都会运行 MockitoAnnotations.openMocks(this); // 初始化Mock如果没用ExtendWith userService new UserService(userRepository); testUser new User(1L, “Test User”, “testexample.com”); // 公共测试数据 } }但对于复杂的对象更推荐使用工厂方法如静态方法或Builder模式来创建测试数据这样在每个测试中可以根据需要微调。class TestDataFactory { public static User createUser(Long id, String name) { User user new User(); user.setId(id); user.setName(name); user.setStatus(UserStatus.ACTIVE); // ... 设置其他默认值 return user; } } // 在测试中使用 Test void someTest() { User activeUser TestDataFactory.createUser(1L, “Alice”); User inactiveUser TestDataFactory.createUser(2L, “Bob”); inactiveUser.setStatus(UserStatus.INACTIVE); // 只修改需要测试的字段 // ... }5. 不同Java项目类型的单元测试实战示例理论说再多不如看代码。下面我们针对几种常见的Java项目类型看看单元测试具体怎么写。5.1 纯Java库/工具类项目这类项目不依赖外部框架逻辑相对独立。测试重点是算法、数据转换和工具方法。示例一个简单的字符串工具类// 生产代码StringUtils.java public class StringUtils { /** * 将字符串按指定分隔符连接并过滤掉空值 */ public static String joinSkipNulls(String delimiter, String... parts) { if (parts null || parts.length 0) { return “”; } return Arrays.stream(parts) .filter(Objects::nonNull) .filter(s - !s.trim().isEmpty()) .collect(Collectors.joining(delimiter)); } }// 测试代码StringUtilsTest.java import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; class StringUtilsTest { // 普通测试用例 Test void joinSkipNulls_withNormalStrings_shouldJoinCorrectly() { String result StringUtils.joinSkipNulls(“-”, “a”, “b”, “c”); assertThat(result).isEqualTo(“a-b-c”); } Test void joinSkipNulls_withNullAndEmptyStrings_shouldBeSkipped() { String result StringUtils.joinSkipNulls(“,”, “a”, null, “”, “b”, “ “); assertThat(result).isEqualTo(“a,b”); // 只有“a”和“b”被保留 } Test void joinSkipNulls_withNullArray_shouldReturnEmptyString() { String result StringUtils.joinSkipNulls(“-”, (String[]) null); assertThat(result).isEmpty(); } Test void joinSkipNulls_withEmptyArray_shouldReturnEmptyString() { String result StringUtils.joinSkipNulls(“-”); assertThat(result).isEmpty(); } // 参数化测试用一组数据测试同一个逻辑非常高效 ParameterizedTest MethodSource(“provideDataForJoinSkipNulls”) void joinSkipNulls_ParameterizedTest(String delimiter, String[] parts, String expected) { String result StringUtils.joinSkipNulls(delimiter, parts); assertThat(result).isEqualTo(expected); } private static StreamArguments provideDataForJoinSkipNulls() { return Stream.of( Arguments.of(“-”, new String[]{“1”, “2”, “3”}, “1-2-3”), Arguments.of(“”, new String[]{“a”, “b”}, “ab”), Arguments.of(“,”, new String[]{“x”, null, “y”}, “x,y”), Arguments.of(“|”, new String[]{}, “”) ); } }实操心得工具类的测试要特别注意边界条件null、空数组、空字符串和异常流。参数化测试ParameterizedTest是测试这类方法的神器能大幅减少重复代码。5.2 Spring Boot Web服务项目这是企业级开发中最常见的场景。测试重点在于Service业务逻辑层需要熟练Mock持久层Repository和外部服务Client。示例一个用户注册服务假设我们有UserService它依赖UserRepository和EmailService。// 生产代码UserService.java Service Transactional public class UserService { private final UserRepository userRepository; private final EmailService emailService; private final PasswordEncoder passwordEncoder; public UserService(UserRepository userRepository, EmailService emailService, PasswordEncoder passwordEncoder) { this.userRepository userRepository; this.emailService emailService; this.passwordEncoder passwordEncoder; } public User registerUser(RegistrationRequest request) { // 1. 检查用户名是否已存在 userRepository.findByUsername(request.getUsername()) .ifPresent(u - { throw new DuplicateUsernameException(“用户名已存在: ” request.getUsername()); }); // 2. 创建用户实体并加密密码 User user new User(); user.setUsername(request.getUsername()); user.setEmail(request.getEmail()); user.setPasswordHash(passwordEncoder.encode(request.getPassword())); user.setStatus(UserStatus.PENDING_ACTIVATION); // 3. 保存用户 User savedUser userRepository.save(user); // 4. 发送激活邮件 emailService.sendActivationEmail(savedUser.getEmail(), savedUser.generateActivationToken()); return savedUser; } }// 测试代码UserServiceTest.java 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 org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; ExtendWith(MockitoExtension.class) // 集成Mockito和JUnit5 class UserServiceTest { Mock private UserRepository userRepository; Mock private EmailService emailService; // 这里使用真实编码器因为它是无状态的工具类测试简单且快速。 // 如果PasswordEncoder很复杂也可以Mock。 private PasswordEncoder passwordEncoder new BCryptPasswordEncoder(); InjectMocks private UserService userService; // Mock会自动注入 // 注意在InjectMocks时Spring的Autowired注解无效需通过构造函数。 // 我们的UserService正好使用了构造函数注入所以Mockito可以处理。 // 如果使用字段注入需要额外处理。 Test void registerUser_withNewUsername_shouldSaveUserAndSendEmail() { // Given RegistrationRequest request new RegistrationRequest(“alice”, “aliceexample.com”, “password123”); when(userRepository.findByUsername(“alice”)).thenReturn(Optional.empty()); // 注意保存的用户密码是编码后的我们不能精确预测所以用any()匹配 when(userRepository.save(any(User.class))).thenAnswer(invocation - invocation.getArgument(0)); doNothing().when(emailService).sendActivationEmail(any(String.class), any(String.class)); // When User registeredUser userService.registerUser(request); // Then assertThat(registeredUser).isNotNull(); assertThat(registeredUser.getUsername()).isEqualTo(“alice”); assertThat(registeredUser.getEmail()).isEqualTo(“aliceexample.com”); assertThat(registeredUser.getStatus()).isEqualTo(UserStatus.PENDING_ACTIVATION); // 验证密码确实被编码了不是明文 assertThat(passwordEncoder.matches(“password123”, registeredUser.getPasswordHash())).isTrue(); // 验证交互 verify(userRepository).findByUsername(“alice”); verify(userRepository).save(any(User.class)); verify(emailService).sendActivationEmail(eq(“aliceexample.com”), any(String.class)); } Test void registerUser_withDuplicateUsername_shouldThrowException() { // Given RegistrationRequest request new RegistrationRequest(“bob”, “bobexample.com”, “pass”); User existingUser new User(); when(userRepository.findByUsername(“bob”)).thenReturn(Optional.of(existingUser)); // When Then assertThatThrownBy(() - userService.registerUser(request)) .isInstanceOf(DuplicateUsernameException.class) .hasMessageContaining(“用户名已存在: bob”); // 验证当用户名重复时save和sendEmail方法都没有被调用 verify(userRepository, never()).save(any()); verify(emailService, never()).sendActivationEmail(any(), any()); } }Spring Boot测试心得分层测试对Service层进行单元测试如上例对Controller层进行切片测试如WebMvcTest或集成测试SpringBootTest对Repository层使用DataJpaTest。不要混为一谈。构造函数注入如上例所示使用构造函数注入能让单元测试尤其是Mockito注入变得非常简单。强烈推荐。SpringBootTest慎用这是一个全功能集成测试注解会启动整个Spring容器速度很慢。只在真正需要测试多个组件集成时才使用。对于大多数业务逻辑测试像上面那样只测Service层就足够了。5.3 数据库交互层Repository测试测试Repository或DAO层我们需要一个真实的数据库环境但通常使用内存数据库如H2以保证速度。Spring Boot提供了DataJpaTest来完美支持。// 生产代码UserRepository.java (JPA Repository) public interface UserRepository extends JpaRepositoryUser, Long { OptionalUser findByUsername(String username); ListUser findByStatusOrderByCreatedAtDesc(UserStatus status); }// 测试代码UserRepositoryTest.java 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 // 只配置JPA相关的Bean使用内存数据库 class UserRepositoryTest { Autowired private TestEntityManager testEntityManager; // 用于便捷地操作测试数据 Autowired private UserRepository userRepository; Test void findByUsername_WhenUserExists_ShouldReturnUser() { // Given: 使用TestEntityManager直接持久化一个用户到测试数据库 User savedUser testEntityManager.persistFlushFind(new User(“testUser”, “testemail.com”)); // When OptionalUser found userRepository.findByUsername(“testUser”); // Then assertThat(found).isPresent(); assertThat(found.get().getEmail()).isEqualTo(“testemail.com”); assertThat(found.get().getId()).isEqualTo(savedUser.getId()); } Test void findByUsername_WhenUserNotExists_ShouldReturnEmpty() { // Given: 数据库是空的每个Test方法默认在事务中运行并在结束后回滚 // When OptionalUser found userRepository.findByUsername(“nonExistent”); // Then assertThat(found).isEmpty(); } Test void findByStatusOrderByCreatedAtDesc_ShouldReturnCorrectOrder() { // Given User user1 new User(“u1”, “aa.com”); user1.setStatus(UserStatus.ACTIVE); testEntityManager.persistAndFlush(user1); // 为了测试排序我们手动控制创建时间实际中可能有CreationTimestamp // 这里假设User有setCreatedAt方法 // testEntityManager.persist(user1); // 第一个被创建 User user2 new User(“u2”, “bb.com”); user2.setStatus(UserStatus.ACTIVE); testEntityManager.persistAndFlush(user2); // testEntityManager.persist(user2); // 第二个被创建 // 模拟user2比user1晚创建需要根据具体实体设计调整 // 更可靠的方式是在插入数据时明确设置时间戳 // When ListUser activeUsers userRepository.findByStatusOrderByCreatedAtDesc(UserStatus.ACTIVE); // Then: 应该按创建时间降序排列 assertThat(activeUsers).hasSize(2); // 这里断言顺序需要根据实际插入和排序逻辑来定 // assertThat(activeUsers.get(0).getUsername()).isEqualTo(“u2”); // assertThat(activeUsers.get(1).getUsername()).isEqualTo(“u1”); } }Repository测试心得使用DataJpaTest它自动配置内存数据库只加载JPA相关的Bean测试速度极快。利用TestEntityManager它比直接通过Repository.save()更底层适合在测试中精确控制数据的持久化状态。事务与回滚默认情况下每个Test方法都在一个事务中执行并在方法结束后回滚。这意味着测试之间是隔离的。如果不想回滚可以使用Transactional(propagation Propagation.NOT_SUPPORTED)。5.4 包含外部API调用的服务测试当你的服务需要调用第三方HTTP API时单元测试的关键是Mock这次网络调用。我们可以使用Mockito来Mock一个RestTemplate或WebClient但更优雅的方式是使用Mock Server如MockWebServer from OkHttp或者Mockito配合自定义的Client接口。示例一个调用天气API的服务假设我们有一个WeatherService它通过一个WeatherApiClient接口来获取数据。// 生产代码 public interface WeatherApiClient { WeatherData getCurrentWeather(String city); } Service public class WeatherService { private final WeatherApiClient weatherApiClient; public WeatherService(WeatherApiClient weatherApiClient) { this.weatherApiClient weatherApiClient; } public String getWeatherDescription(String city) { WeatherData data weatherApiClient.getCurrentWeather(city); if (data null) { return “数据暂不可用”; } return String.format(“%s的天气是%s温度%.1f°C”, city, data.getCondition(), data.getTemperature()); } }// 测试代码 ExtendWith(MockitoExtension.class) class WeatherServiceTest { Mock private WeatherApiClient weatherApiClient; InjectMocks private WeatherService weatherService; Test void getWeatherDescription_withValidData_shouldReturnFormattedString() { // Given String city “Beijing”; WeatherData mockData new WeatherData(“Sunny”, 22.5); when(weatherApiClient.getCurrentWeather(city)).thenReturn(mockData); // When String description weatherService.getWeatherDescription(city); // Then assertThat(description).isEqualTo(“Beijing的天气是Sunny温度22.5°C”); verify(weatherApiClient).getCurrentWeather(city); } Test void getWeatherDescription_whenApiReturnsNull_shouldReturnFallbackMessage() { // Given when(weatherApiClient.getCurrentWeather(any())).thenReturn(null); // When String description weatherService.getWeatherDescription(“UnknownCity”); // Then assertThat(description).isEqualTo(“数据暂不可用”); } }外部依赖测试心得面向接口编程通过接口来定义外部依赖这样在单元测试中可以轻松Mock。这是依赖注入和良好设计的原则。不要Mock你不拥有的东西对于非常复杂的第三方库或SDKMock起来可能很痛苦。这时可以考虑使用测试替身Test Double比如为这个外部依赖创建一个轻量级的、用于测试的“假”实现Fake而不是用Mockito去模拟每一个方法。这在测试与像AWS SDK、复杂ORM工具交互的代码时特别有用。集成测试是必要的单元测试Mock了外部API但客户端与实际API的集成是否正确还需要专门的集成测试来验证。6. 常见问题排查与进阶技巧实录即使掌握了基本方法在实际编写测试时还是会遇到各种“坑”。下面是我总结的一些典型问题和解决思路。6.1 测试本身失败如何快速定位断言失败这是最直接的。仔细阅读断言失败信息特别是使用AssertJ时信息很详细对比期望值和实际值。常见原因时间戳、随机生成的ID、数据库自增主键等不可预测的值。解决方案在测试中固定这些值使用固定的种子或者断言对象的特定属性而非整个对象或者使用assertThat(actual).usingRecursiveComparison().ignoringFields(“id”, “createdAt”).isEqualTo(expected)。异常测试未抛出预期异常使用assertThatThrownBy或assertThrows时确保你测试的代码路径确实会抛出异常。有时因为Mock行为设置错误代码走了另一条路。NullPointerException通常是因为Mock对象的行为没有设置。当你调用一个被Mock对象的方法但没使用when().thenReturn()指定其返回值时Mockito默认返回null对于对象或0/false对于基本类型。检查所有被测试方法调用的依赖方法是否都正确配置了。6.2 测试“假通过”False Positive这是最危险的情况测试通过了但生产代码其实有bug。原因1测试过于宽松。比如过度使用any()匹配器或者断言条件太弱只断言不为null。解决方案尽量使用具体的参数匹配eq()断言要精确到关键的业务属性。原因2Mock行为设置错误。你Mock了方法A但生产代码调用的是方法B。解决方案在测试最后使用verify()来验证预期的交互确实发生了。原因3测试了错误的东西。比如你Mock了一个对象的某个方法然后断言这个Mock对象的行为这实际上没有测试任何生产逻辑。解决方案时刻记住单元测试的目标是被测试单元SUT的行为而不是它的依赖。你的断言和验证应该围绕SUT的返回值或状态变化。6.3 测试代码难以维护重复、冗长大量重复的Given代码提取到BeforeEach方法或使用Object Mother模式、Test Data Builder模式来创建复杂的测试对象。测试方法太长一个测试方法最好只测试一个场景或一个分支。如果方法太长说明可能测试了多个东西应该拆分成多个测试。Mock设置繁琐考虑是否被测试类承担了太多职责违反了单一职责原则导致依赖过多。这可能是一个代码需要重构的信号。6.4 依赖注入与Spring上下文问题InjectMocks不工作确保被测试类使用构造函数注入或setter注入。InjectMocks对字段注入Autowired在字段上支持不好。最佳实践是始终使用构造函数注入。需要测试Spring Bean的某些特性如Transactional,Cacheable这时单纯的Mock测试不够需要使用切片测试如WebMvcTest,DataJpaTest或轻量级的SpringBootTest通过TestConfiguration提供特定的Bean定义。6.5 测试私有方法这是一个经典争议。我的建议是不要直接测试私有方法。私有方法是实现细节应该通过测试公有方法来间接覆盖。如果你觉得必须测试一个私有方法那往往意味着这个方法的逻辑足够复杂应该被提取到一个新的类公有方法中。这样代码更清晰也更容易测试。记住测试应该关注行为what而非实现how。6.6 测试覆盖率数字的陷阱JaCoCo报告显示行覆盖率90%是不是就高枕无忧了不是。覆盖率只是一个辅助指标它告诉你代码的哪些部分没有被测试执行到但不能衡量测试的质量。你可以写一堆无意义的断言让覆盖率100%但业务逻辑可能全是错的。正确使用覆盖率关注分支覆盖率而不仅仅是行覆盖率。确保每个if-else、switch-case的分支都被覆盖到。覆盖率的重点是发现未被覆盖的代码特别是核心业务逻辑和复杂条件分支。对于简单的Getter/Setter、自动生成的代码不必强求覆盖。将覆盖率作为代码审查的参考而不是终极目标。一个设计良好、测试用心但覆盖率85%的模块远比一个通过取巧达到100%但测试脆弱的模块可靠。7. 构建持续集成的测试流水线单元测试的价值在持续集成CI中才能最大化体现。通常的流程是开发者本地运行测试 - 提交代码到版本库 - CI服务器如Jenkins, GitLab CI, GitHub Actions自动拉取代码运行完整的测试套件包括单元、集成测试。在Maven中运行测试是mvn test阶段的一部分。集成JaCoCo后可以在mvn verify后生成覆盖率报告位于target/site/jacoco/index.html。一个简单的GitHub Actions配置示例name: Java CI with Maven on: [push, pull_request] jobs: build-and-test: runs-on: ubuntu-latest steps: - uses: actions/checkoutv3 - name: Set up JDK 17 uses: actions/setup-javav3 with: java-version: ‘17’ distribution: ‘temurin’ - name: Build and Run Tests with Maven run: mvn clean verify # 可选上传测试报告 - name: Upload JaCoCo coverage report uses: actions/upload-artifactv3 if: always() # 即使测试失败也上传报告 with: name: jacoco-report path: target/site/jacoco/CI心得快速失败确保测试套件足够快。如果太慢开发者就不会频繁运行CI反馈周期变长。将慢的测试如集成测试、端到端测试与单元测试分开在CI的不同阶段运行。稳定性单元测试必须是稳定的、可重复的。不能依赖外部网络、不能有随机性除非可控、测试之间要完全隔离。不稳定的测试Flaky Tests会严重损害团队对测试的信心。失败即阻断在CI流水线中如果单元测试失败应该阻止代码合并到主分支。这是保证主干代码质量的红线。单元测试不是一项可以一蹴而就的任务而是一种需要融入日常开发习惯的实践。从为一个工具类写第一个测试开始到为复杂的业务服务设计可测试的代码结构每一步都在提升你的代码质量和工程能力。记住好的测试会让你在重构时充满信心在交付时心中有底。