
单元测试这套东西很多团队都在用但用得好的真不多。大部分项目里的单元测试要么是应付覆盖率指标的玩具测试要么是动不动就依赖Spring容器、跑一次要几十秒的伪单元测试。我这两年带着团队把测试体系重新捋了一遍核心就落在JUnit 5、Mockito和AssertJ这套组合上今天把这套东西的来龙去脉和实操细节一次性讲清楚。先说一个最常见的困惑JUnit 5、Mockito、AssertJ这三个东西到底分别负责什么很多新手容易把Mockito和JUnit搞混以为是竞争关系。其实它们完全是分工协作的JUnit 5是整个测试体系的骨架和执行器负责组织测试生命周期、管理参数化测试、重复测试这些流程控制Mockito专职造假——把外部依赖数据库、Redis、第三方接口全部替换成可控的mock对象AssertJ则是断言层的提升让断言读起来像英文句子一样自然。三者各管一段配合起来才算是完整的测试体系。这里要特别强调一点单元测试的对象是你自己写的代码逻辑不是框架本身。我用这套组合的新手同事经常问我为什么我测试里需要启动整个Spring Boot项目答案很简单一旦你把Spring容器拉起来测试就跑到了集成测试的范畴速度慢、不稳定而且违背了单元测试的核心价值——快速定位问题。所以下面这套方案默认不启动Spring容器纯JUnit 5 Mockito AssertJ的单测写法。1. 技术选型背后的场景思考1.1 为什么单元测试这么难落地先说个真实数据。我接手过一个遗留项目单元测试覆盖率看起来是70%但一跑测试就花十分钟因为每个测试类都用了SpringBootTest。这其实已经不能叫单元测试了而是集成测试。出问题的时候你根本分不清是业务代码的问题还是mock不彻底的问题。也遇到过同事写测试全靠System.out.println去人工核对输出断言基本等于摆设。单元测试难落地的根因有三个。第一是测试代码本身质量堪忧很多团队写测试就是为一个方法写一个测试方法没有设计测试数据、没有覆盖边界条件纯属为了CICD里的覆盖率门槛。第二是测试太依赖环境本地跑得好好的一到CI服务器就挂因为测试代码里藏了绝对路径、外网依赖和时间戳。第三个原因最致命团队缺乏测试思维训练很多人不知道给定-当-然后Arrange-Act-Assert这个最基本的测试结构写出来的测试要么全堆在一个方法里要么断言写得稀烂。JUnit 5 Mockito AssertJ这套方案恰好能把这三个坑挨个填上。JUnit 5的ParameterizedTest帮你用一份测试逻辑覆盖多组数据RepeatedTest解决随机性和稳定性问题Mockito的BDDMockito风格强迫你从行为出发设计测试场景AssertJ则通过流畅API让断言出错时能告诉你预期是X,实际是Y,差异在哪里而不是冷冰冰抛一个AssertionError。1.2 这套组合相对于传统写法的核心优势要理解这套组合的价值最好看一个对比。传统写法用JUnit 4 手动mock 原生断言通常长这样Test public void testFindUser() { UserDao mockDao Mockito.mock(UserDao.class); UserService service new UserService(mockDao); User result new User(1L, 张三); Mockito.when(mockDao.findById(1L)).thenReturn(result); User actual service.findUser(1L); Assert.assertEquals(张三, actual.getName()); }这套代码的问题在维护期暴露无遗。断言用的是JUnit 4的Assert失败时输出信息很模糊mock的when语法其实已经过时了尤其在处理void方法、异常场景时很别扭更关键的是如果不做参数化每一个测试数据都要复制一个方法。同样的测试用JUnit 5 Mockito AssertJ重构之后代码是这样的DisplayName(查询用户测试) class UserServiceTest { Mock private UserDao userDao; InjectMocks private UserService userService; BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } ParameterizedTest(name 根据ID查询-数据#{index}: id{0}, 期望姓名{1}) CsvSource({ 1, 张三, 2, 李四, 3, 王五 }) void findUser_shouldReturnCorrectName(Long id, String expectedName) { // given User mockUser new User(id, expectedName); given(userDao.findById(id)).willReturn(mockUser); // when User actual userService.findUser(id); // then assertThat(actual.getName()).isEqualTo(expectedName); } }一眼就能看出来参数的扩展不需要新增方法测试名称自动带上了当前的数据断言读起来几乎接近自然语言。这套组合的核心优势说白了就三条测试数据的组织更高效参数化、mock场景的描述更业务化行为驱动风格、断言的可读性和失败信息更友好AssertJ。三者单独拆开用体验已经不错但合在一起威力才真正发挥出来。下面逐个展开讲。2. 深度拆解 JUnit 5 的动态测试能力2.1ParameterizedTest一份代码跑N组数据ParameterizedTest是JUnit 5相比JUnit 4最实用的升级之一。在JUnit 4时代如果想要对同一个方法测试多组数据要么写多个Test方法要么用RunWith(Parameterized.class)写一套繁琐的构造函数逻辑特别笨重。JUnit 5直接把这个场景做成了第一公民特性。使用ParameterizedTest的第一步是把原来的Test替换成ParameterizedTest然后喂数据给它。JUnit 5内置了好几种数据来源注解我在项目中用到最频繁的有这几种ValueSource适用于单个参数的最简单场景支持ints、strings、longs等数组。CsvSource适用于多参数场景每个字符串一行逗号分隔优雅解决一组数据多个值的问题。CsvFileSource数据量大时把测试数据放到独立的CSV文件中避免Java代码里堆常量。MethodSource需要复杂逻辑生成数据时使用是一个返回Stream的静态方法。我特别推荐CsvSource。原因在于它兼顾了可读性和扩展性每一行就是一个完整的测试用例测试报告也会按组数据分别展示。比如下面这个场景我要测试一个计算器类的除法方法既要验证正常分支又要验证异常分支用一句话就能喂入三组数据ParameterizedTest(name 除法测试: {0} / {1} {2}) CsvSource({ 10, 2, 5, 9, 3, 3, 7, 1, 7 }) void divide_shouldReturnExpectedResult(int dividend, int divisor, int expected) { Calculator calculator new Calculator(); assertThat(calculator.divide(dividend, divisor)).isEqualTo(expected); }这里的name属性是我很看重的功能点。默认情况下JUnit 5会用方法的测试名称加索引来标识每组数据但如果你配置了name 除法测试: {0} / {1} {2}测试报告里就能直接看到每一组具体数据一旦某组失败你一眼就能锁定是哪组数据、参数是什么不用再去看堆栈猜。对于异常场景的参数化JUnit 5也有一招。比如要测试一个方法传负数时抛异常你可以在同一份CSV数据里区分正常和异常用例配合assertThatThrownBy做断言ParameterizedTest CsvSource({ -1, 参数不能为负数, 0, 参数不能为0 }) void validate_shouldThrowForInvalidInput(int input, String expectedMessage) { Validator validator new Validator(); assertThatThrownBy(() - validator.validate(input)) .isInstanceOf(IllegalArgumentException.class) .hasMessage(expectedMessage); }这种写法的收益很大测试代码量减少了大概60%以上而覆盖的数据量反而提升了数倍。每次需求变更你需要做的只是往CSV源里加一行数据而不是复制粘贴一个测试方法。2.2RepeatedTest验证稳定性的银弹另一个JUnit 5的实用注解是RepeatedTest。这个注解在平时的业务测试中可能用不到但一旦涉及并发场景、随机算法、缓存击穿、线程安全问题时价值就凸显了。RepeatedTest的字面意思是重复执行同一个测试N次。要注意它跟参数化测试的区别参数化是喂不同数据验证不同场景重复测试是同一份数据执行多次来验证稳定性和确定性。举个真实例子。我遇到过一段抢红包的分配算法代码单次测试永远通过但部署上线后偶尔出现总金额对不上的问题。用RepeatedTest把同样的随机分配逻辑跑100遍问题立刻浮出水面——第二轮分配时由于浮点数精度丢失导致总额差了0.01元。用法非常简单RepeatedTest(value 100, name 红包分配-第{currentRepetition}次, 共{totalRepetitions}次) void distributeRedPacket_shouldAlwaysKeepTotalConsistent() { RedPacketService service new RedPacketService(); ListRedPacketItem items service.distribute(100.0, 10); double sum items.stream().mapToDouble(RedPacketItem::getAmount).sum(); assertThat(sum).isEqualTo(100.0, withPrecision(0.001)); }这个name属性挺值得一提测试报告里能清楚看到第42次/共100次这样的进度排查问题时不用靠猜。RepeatedTest还能设置一些辅助配置。比如Timeout可以配合每条重复用例单独计算超时时间结合BeforeEach可以保证每次重复前都重新初始化数据这些细节我建议有需要的同学去看官方文档每个项目结合自己实际情况微调。2.3 JUnit 5 生命周期和嵌套测试的工程实践除了上面两个核心注解想让测试体系真正好用JUnit 5的DisplayName、BeforeEach、AfterEach、Nested等生命周期注解也需要在团队里统一约定。DisplayName我特别推荐强制使用。它能给测试方法起一个中文场景名失败时测试报告会直接显示查询用户测试-根据ID查询-数据#1: id1, 期望姓名张三对排查问题的效率提升非常明显。Nested注解则可以把同一类的测试场景归类到一个内部类里。比如一个UserServiceTest里把注册相关登录相关查询相关分开DisplayName(用户服务测试) class UserServiceTest { Nested DisplayName(注册场景) class RegisterTest { Test DisplayName(用户名已存在时抛出异常) void shouldThrowWhenUsernameExists() { // ... } } Nested DisplayName(登录场景) class LoginTest { // ... } }这种组织方式的好处很直观测试代码本身就是文档团队新同学光看测试类名和方法名就能知道这个服务对外提供了哪些行为甚至能直接从测试代码反推出业务需求。我认为在团队协作中这一点比测试覆盖率数字更有价值。3. Mockito从mock对象到行为驱动测试3.1when和given两种风格到底怎么选Mockito在2018年更新到2.x以后官方推荐风格其实已经悄悄转向BDDMockito。传统风格是Mockito.when(userDao.findById(1L)).thenReturn(user)BDD风格则是given(userDao.findById(1L)).willReturn(user)。它们底层机制完全一样区别是语义上谁先谁后。传统风格把when放在前面读起来像当这个条件发生时返回这个结果这其实是从实现细节出发的写法。BDD风格从行为出发先把给定一个前提given描述出来然后再执行动作、校验结果正好对应测试理论中的Arrange阶段。当测试代码越来越长时BDD风格的语义优势就越明显// 传统风格看起来像测试准备阶段在执行mock配置 when(userDao.save(any(User.class))).thenAnswer(invocation - { User u invocation.getArgument(0); u.setId(1L); return u; }); // BDD风格一眼就看到这是给定Dao保存成功并返回ID为1的对象 given(userDao.save(any(User.class))).willAnswer(invocation - { User u invocation.getArgument(0); u.setId(1L); return u; });实际项目中我要求团队统一使用BDDMockito的given风格并不是因为它功能更强而是它能让测试代码的叙事结构保持一致性先given准备环境再执行被测方法最后用AssertJ做断言。3.2 mock静态方法和构造方法的进阶玩法Mockito的进阶玩法里最常用到的还有mockStatic和mockConstruction这两个能力在处理历史代码时尤其重要。过去很多团队用PowerMock来做静态方法mock但它跟JUnit 5的整合一直很痛苦。Mockito从3.4版本开始官方支持了mockStatic而且跟JUnit 5配合得很顺滑。举个例子代码里有一个IdGenerator.generate()的静态方法每次调用都返回UUID你希望测试时固定返回一个特定值。用Mockito可以这样写Test void createOrder_shouldUseGeneratedId() { try (MockedStaticIdGenerator mockedStatic mockStatic(IdGenerator.class)) { mockedStatic.when(IdGenerator::generate).thenReturn(fixed-id-123); OrderService service new OrderService(); Order order service.createOrder(); assertThat(order.getId()).isEqualTo(fixed-id-123); } }值得注意的一个坑是mockStatic必须在try-with-resources块里用否则mock会污染其他测试方法的静态方法调用。我见过有同事忘了关闭MockedStatic结果整个测试类里引用IdGenerator的测试全挂了而且报错信息特别隐晦排查起来很费劲。mockConstruction处理的是构造方法。比如被测代码内部有一行new EmailSender()你希望给它注入mock对象而不改动被测代码可以这样做try (MockedConstructionEmailSender mocked mockConstruction(EmailSender.class)) { EmailSender mockSender mocked.constructed().get(0); given(mockSender.send(anyString())).willReturn(true); NotifyService notifyService new NotifyService(); assertThat(notifyService.sendWelcome(zhangsanexample.com)).isTrue(); }这两个功能合在一起让我几乎不需要再引入第三方mock库就能把绝大多数写得很烂但不敢动的历史代码测试覆盖起来。3.3 常用mock场景void方法、参数匹配、Answer回调Mockito的使用里参数匹配器是很多人理解不到位的一块。比如你mock了一个void方法userDao.deleteById(Long id)想验证它有没有被调用、以什么参数调用需要这样写// given willDoNothing().given(userDao).deleteById(1L); // when userService.deleteUser(1L); // then then(userDao).should().deleteById(1L);再复杂一点的场景是同一个方法被调用了多次但每次传入不同参数时返回不同结果。这时候可以用thenReturn叠加或者用willAnswer做动态响应given(userDao.findById(1L)).willReturn(new User(1L, 张三)); given(userDao.findById(2L)).willReturn(new User(2L, 李四)); // 更动态的写法 given(userDao.findById(anyLong())).willAnswer(invocation - { Long id invocation.getArgument(0); return id 1L ? new User(1L, 张三) : new User(2L, 李四); });any()、anyLong()、eq()这类参数匹配器也是高频使用的。需要提醒的是mock的方法如果既有when配置又有真实调用需求别忘记doCallRealMethod()——这个在测试私有方法或模板方法时偶尔会用到但使用时需要特别小心同时也说明你很可能该进行代码重构了不该mock真实方法。4. AssertJ让断言变成可读的业务描述4.1 流畅断言语法的实际优势有同事问过我AssertJ和JUnit自带的Assertions类到底差在哪里我会直接让他看两段代码的区别。JUnit自带的断言就像考试里的填空题你知道哪错了但通常不知道为什么assertEquals(张三, actual.getName());如果这个断言失败了JUnit只会告诉你expected: 张三 but was: 李四好的信息也有但对于复杂对象和集合场景这个信息量远远不够。AssertJ的写法是这样的assertThat(actual.getName()) .isEqualTo(张三) .isNotBlank() .startsWith(张);它的核心特点是三点第一每个断言可以链式调用一个对象可以一次验证多个维度第二失败信息默认比较详细对于字符串会告诉你有多少个字符不同、差在哪一位对于集合会直接告诉你缺少了哪个元素、多了哪个元素第三语义贴近自然语言assertThat后面跟着的是一串读起来就是断言的英文短句。4.2 集合、异常、异步场景的断言技巧集合断言是AssertJ的拿手好戏。比如你要验证一个分页查询返回的结果是按价格降序排列的并且每页数量不超过10条用AssertJ一行搞定assertThat(page.getContent()) .hasSizeLessThanOrEqualTo(10) .extracting(Product::getPrice) .isSortedAccordingTo(Comparator.reverseOrder());如果要验证列表里至少包含一个满足条件的对象可以用anyMatch或者filteredOnassertThat(orderList) .filteredOn(order - order.getStatus().equals(PAID)) .hasSize(3);异常断言AssertJ也做得很顺手。以前在JUnit 4里验证异常要先写Test(expected XxxException.class)或者用try-catch手动接管难用得很。AssertJ提供了assertThatThrownBy和assertThatCode两种方式assertThatThrownBy(() - orderService.createOrder(null)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining(订单) .hasNoCause(); // 如果只是验证这段代码不抛异常可以用assertThatCode assertThatCode(() - orderService.cancelOrder(1L)) .doesNotThrowAnyException();异步场景现在越来越多了AssertJ从3.x开始有assertThat(...).await()相关的断言不过实际项目中我更多是用Awaitility专门做异步测试。这块可以在后面单独展开。4.3 自定义断言与describeAs的进阶使用AssertJ还有一个容易被低估的能力自定义断言。如果你的项目里Order对象在十来个测试类里都要验证它的状态写一堆重复的assertThat(order.getStatus())...就太丑了。可以给Order写一个自定义断言类OrderAssert继承AbstractAssert把业务校验逻辑收敛起来。这个能力在项目后期维护阶段的价值比想象中的大。我记得有一次业务上要求订单取消后必须记录取消原因如果只靠普通断言每个测试都要重新写一行assertThat(order.getCancelReason()).isNotBlank()抽出自定义断言后一行assertThat(order).hasValidCancelInfo()就能搞定而且后续改规则只改一个地方。describeAs是一个容易被忽略的小功能。默认断言失败时显示的文本是Java对象的toString()。对于没有重写toString()的对象你会看到一坨内存地址完全没法排查。用describeAs可以把业务描述传进去assertThat(actualUser) .describedAs(根据ID查询到的用户对象ID%d期望姓名%s, id, expectedName) .extracting(User::getName) .isEqualTo(expectedName);这样失败时你就能立刻知道是哪个用例、哪条数据出了问题不用再去翻源码。5. 完整示例一个用户服务测试从零到落地5.1 被测代码结构为了把上面的知识点串起来我拿一个典型的用户服务来演示一套完整的测试代码。先看被测代码长什么样它涉及到外部Dao、邮件服务、一个静态方法public class UserService { private final UserDao userDao; private final EmailService emailService; public UserService(UserDao userDao, EmailService emailService) { this.userDao userDao; this.emailService emailService; } public User register(String username, String email) { if (StringUtils.isBlank(username)) { throw new IllegalArgumentException(用户名不能为空); } if (userDao.existsByUsername(username)) { throw new DuplicateUsernameException(用户名已存在: username); } User user new User(); user.setId(IdGenerator.generate()); user.setUsername(username); user.setEmail(email); user.setStatus(ACTIVE); userDao.save(user); emailService.sendWelcome(email); return user; } public void disableUser(Long userId) { User user userDao.findById(userId); if (user null) { throw new UserNotFoundException(用户不存在: userId); } user.setStatus(DISABLED); userDao.update(user); } }5.2 完整的测试类解析下面这套测试覆盖了参数化、mock、BDD风格和AssertJ你直接拿去改成自己项目里的类名就能用DisplayName(UserService 单元测试) class UserServiceTest { Mock private UserDao userDao; Mock private EmailService emailService; InjectMocks private UserService userService; BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } Nested DisplayName(注册场景) class RegisterTest { ParameterizedTest(name 用户名含非法值时应抛出异常 - {0}) ValueSource(strings {, , null}) void register_shouldRejectInvalidUsername(String invalidUsername) { assertThatThrownBy(() - userService.register(invalidUsername, testmail.com)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining(用户名); } Test DisplayName(用户名已存在时应抛出DuplicateUsernameException) void register_shouldThrowWhenUsernameExists() { given(userDao.existsByUsername(zhangsan)).willReturn(true); assertThatThrownBy(() - userService.register(zhangsan, zsmail.com)) .isInstanceOf(DuplicateUsernameException.class) .hasMessageContaining(zhangsan); then(userDao).should(never()).save(any(User.class)); } Test DisplayName(注册成功时应保存用户并发送欢迎邮件) void register_shouldSaveUserAndSendEmail() { // given given(userDao.existsByUsername(lisi)).willReturn(false); try (MockedStaticIdGenerator mockedStatic mockStatic(IdGenerator.class)) { mockedStatic.when(IdGenerator::generate).thenReturn(10001L); // when User result userService.register(lisi, lisimail.com); // then assertThat(result) .isNotNull() .extracting(User::getId, User::getUsername, User::getStatus) .containsExactly(10001L, lisi, ACTIVE); then(userDao).should().save(argThat(user - user.getId() 10001L user.getUsername().equals(lisi) )); then(emailService).should().sendWelcome(lisimail.com); } } Test DisplayName(注册成功后发送邮件失败不应影响主流程) void register_shouldTolerateEmailFailure() { // given given(userDao.existsByUsername(wangwu)).willReturn(false); willThrow(new RuntimeException(SMTP连接超时)) .given(emailService).sendWelcome(anyString()); // 这里可以根据业务规则选择继续抛出或者吞掉异常 assertThatThrownBy(() - userService.register(wangwu, wwmail.com)) .isInstanceOf(RuntimeException.class); } } Nested DisplayName(禁用用户场景) class DisableUserTest { Test DisplayName(用户不存在时应抛出UserNotFoundException) void disableUser_shouldThrowWhenUserNotFound() { given(userDao.findById(999L)).willReturn(null); assertThatThrownBy(() - userService.disableUser(999L)) .isInstanceOf(UserNotFoundException.class) .hasMessageContaining(999); then(userDao).should(never()).update(any(User.class)); } Test DisplayName(禁用成功时状态应更新为DISABLED) void disableUser_shouldUpdateStatus() { User existingUser new User(); existingUser.setId(1L); existingUser.setStatus(ACTIVE); given(userDao.findById(1L)).willReturn(existingUser); userService.disableUser(1L); assertThat(existingUser.getStatus()).isEqualTo(DISABLED); then(userDao).should().update(existingUser); } Test DisplayName(重复禁用同一用户时应保持幂等) void disableUser_shouldBeIdempotent() { User disabledUser new User(); disabledUser.setId(2L); disabledUser.setStatus(DISABLED); given(userDao.findById(2L)).willReturn(disabledUser); userService.disableUser(2L); assertThat(disabledUser.getStatus()).isEqualTo(DISABLED); // 确保即使状态已禁用仍然保存了更新根据业务需要决定 then(userDao).should().update(disabledUser); } } }5.3 这套代码踩过的坑和我总结的注意事项第一InjectMocks在构造器注入和setter注入场景下基本没问题但如果是Resource字段注入或者Spring代理对象它的mock不一定能注入进去。遇到这种情况我会直接用构造器手动创建被测对象更可控UserService userService new UserService(userDao, emailService);第二mock静态方法时一定要用try-with-resources。我看到过不下三次把MockedStatic写在了方法外的BeforeEach里结果其他测试类也被污染了报错你根本想不到是静态mock没关闭的锅。第三JUnit 5的TempDir注解非常好用。如果是测试文件上传、临时文件读取这类功能用TempDir注入一个临时目录测试结束后JUnit会自动清理能少踩不少测试跑到一半磁盘满了的坑。第四断言尽量别用assertThat(actual expected)要断言具体值再配合字段提取。一个对象用extracting提取多个字段配合containsExactly来比较能非常精确地定位差异在哪一个字段。6. 项目真实落地时最容易出的问题与排查手册6.1 Mockito和JUnit 5集成不上的问题常见报错MockitoAnnotations.openMocks(this)无法解析、ExtendWith(MockitoExtension.class)不生效。原因一般是项目里依赖版本不搭。JUnit 5必须使用mockito-junit-jupiter依赖只引了mockito-core是不行的。我常用的版本组合是JUnit 5.10.x Mockito 5.x AssertJ 3.24.x实测兼容性最稳。还有一种场景是项目里同时混着JUnit 4和JUnit 5依赖。由于历史遗留问题有些老项目既引了junit:junit:4.12又引入了JUnit 5的依赖这时候测试执行器经常混淆。我的建议是逐步移除JUnit 4的依赖JUnit 5是向后兼容的在Test注解上JUnit 4和JUnit 5包名不同别导错就行。6.2 参数化测试数据量大、可读性差怎么办MethodSource生成数据时如果是一大堆代码拼数据后面维护的人看得头皮发麻。我建议数据量小用CsvSource数据量中等放到CsvFileSource数据量大需要逻辑注入时也尽量把数据准备和数据消费分离。另外参数化测试里如果数据包含特殊字符逗号、中文逗号、换行CsvSource需要加引号或用单引号包裹这些细节可以参考官方文档不过实际项目我很少用到特别特殊的数据。6.3 mock静态方法、私有方法、构造器遇到的坑私有方法无法直接mock因为Mockito默认基于继承生成代理它对私有方法是看不见的。要处理私有方法现实的方案是重构把私有逻辑抽到单独类或者包级私有方法里再测。硬要测私有方法也可以用反射工具但我不建议这么干一旦你测私有方法测试就和实现细节绑死了重构代价极高。mock静态方法时最尴尬的是忘了关闭刚才说过不再赘述。另外如果静态方法是框架内部的核心方法比如Integer.parseInt也尽量不要mock这种mock会影响JVM级别的行为容易让测试出现莫名其妙的连锁反应。6.4 测试代码和业务代码写在一起的分层问题有读者会遇到在同一个模块下业务代码和测试代码混在一起的情况。我建议用Maven/Gradle的标准目录结构src/main/java放业务src/test/java放测试。如果你用多模块项目可以单独建一个test-common模块把测试公共父类、Mock工厂、对象构造工具都放在里面这样各个业务模块的测试代码可以复用同一套公共基建不会重复造轮子。6.5 测试运行时出现Provider not found之类的报错有时候运行测试控制台报Provider org.junit.jupiter.engine.JupiterTestEngine not found。这类问题基本是junit-platform-launcher相关依赖缺失导致的。如果用Maven确保maven-surefire-plugin版本在2.22.0以上JUnit 5的engine依赖是dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter-engine/artifactId scopetest/scope /dependency还有一个容易忽略的依赖中如果存在junit-vintage-engine它会把JUnit 4的老测试也拉进来执行如果老测试的写法跟新JUnit有冲突测试可能突然失败。建议在没有历史包袱的新项目里不要引入junit-vintage-engine。6.6 单元测试跑得慢的排查思路单元测试慢绝大多数不是JUnit的问题而是不小心启动了Spring上下文。记住一个原则如果是纯单元测试不要用SpringBootTest。一个中型项目的Spring上下文启动一次要三五秒几百个测试类全启动一遍妥妥奔着十分钟去了。如果你确实有需要Spring容器协助的场景那是集成测试单独放到一个maven profile或标签里不要让它在每次提交时跟纯单测一起跑。排查方法也简单查看测试日志里有没有Starting ApplicationContext、Root bean creation exception之类的字样。一旦发现有SpringBootTest在跑全改成纯mock方案。6.7 覆盖率报告虚高的问题我遇到过覆盖率报告到了90%但核心业务分支完全没有测到的情况。因为很多团队用JaCoCo默认的行覆盖率来卡指标这很容易被测试类写了很多简单方法骗过去。建议关注分支覆盖率和变异测试。引入pitest做变异测试它能自动修改你的业务代码比如把if (a 0)改成if (a 0)然后跑测试如果测试没有挂说明这个分支没有被真正保护。这招对那些测试覆盖率达标但bug不断的项目特别有效虽然首次接入时测试失败率会很高但它能把测试体系的漏洞暴露得明明白白。7. 这套测试体系后续可以怎么扩展单说JUnit 5 Mockito AssertJ已经是个非常能打的组合。但我个人经验里测试体系一旦跑顺下一步一定会聊到几个扩展方向测试数据工厂与其在一个测试类里new一堆不同状态的对象不如做一个TestDataFactory统一生成User、Order这些实体既保证构造数据的一致性又能在频繁升级时只改一处。契约测试如果你在微服务架构里消费者和提供者之间的接口契约测试可以用Spring Cloud Contract或Pact来做这比单测更往上一层保证不同服务之间的数据结构匹配。性能回归测试JUnit 5的Timeout可以粗粒度地防止方法性能严重劣化但如果要做更细的基准测试建议引入JMH不过那是另一个话题了。在团队落地时我经常给初学同学一个词先动手维护一个测试类再动手搭建整个测试工程。不要一上来就追求完美测试体系很多团队死在第一步——定了宏大目标却没执行。从我经验看把一套像UserServiceTest这样完整可运行的测试代码交给小伙伴让他先模仿、再改业务、最后自己从零搭建这种渐进式学习比看十遍文档管用得多。最后分享一个我一直在用的习惯每次接到新需求我先写测试再写实现。不是因为我是TDD布道者而是我吃过太多代码写完了才发现需求理解错了的亏。测试代码是第一个帮你检验这个需求到底意味着什么的工具。JUnit 5、Mockito、AssertJ只是承载这个习惯的最好支点真正核心的是养成从行为出发思考的习惯。