SpringBoot单元测试实战:分层测试方案与坑点全解析 📅 发布时间:2026/9/8 15:35:41 👁 浏览次数: 先说说我自己最常遇到的一个场景接口改完项目跑起来浏览器里或者Postman上点一下看返回结果正常就准备提交。结果上线没两天某个边界条件被触发逻辑直接炸了最后定位半天发现是Service层一个分支没处理。这种靠“手动点一遍”来验证的方式覆盖的路径太有限而且每次改动都要重复来一遍时间全花在启动项目上了。SpringBoot单元测试解决的正是这个问题——在不需要启动完整应用的情况下把Controller、Service、Repository各个分层的行为验证清楚让每次代码改动都有即时反馈。这次我把一个真实的SpringBoot项目里写单元测试的完整过程、方案取舍和踩过的坑整理出来包含可直接复制改用的代码示例、覆盖率配置和常见问题的排查思路。适合刚开始给项目补测试的同学也适合已经在写但觉得测试“又慢又脆”的人参考。内容围绕JUnit 5、Mockito、Spring Boot Test等主流组件展开尽量把每个选择背后的原因也讲清楚。1. 项目概述SpringBoot单元测试到底在测什么1.1 这次演示项目的结构与范围为了不让例子飘在空中我准备了一个典型的用户管理模块包含Controller、Service、Repository三层结构如下src/main/java/com/example/demo ├── DemoApplication.java ├── controller │ └── UserController.java ├── service │ ├── UserService.java │ └── UserNotFoundException.java ├── repository │ └── UserRepository.java └── entity └── User.java业务规则比较简单支持按ID查询用户、按邮箱查询用户、创建新用户时校验邮箱是否重复以及删除不存在的用户时抛出明确异常。这个体量刚好能把分层测试的核心问题都覆盖到又不至于被复杂业务干扰。实际项目里无论代码再多测试思路是相通的——先搞清楚每一层到底负责什么再决定对它测什么。我选择这个模块还有一个原因它包含了典型的“有外部依赖”场景。Controller依赖ServiceService依赖RepositoryRepository依赖数据库。如果用一个 SpringBootTest 把这些Bean全部加载起来再测不仅启动慢而且某个环节出问题时会很难判断到底是哪一层坏了。更合理的做法是按层拆开测哪一层就尽量隔离其他层。1.2 单元测试解决什么问题又解决不了什么问题单元测试能解决的核心问题是“逻辑回归”。比如你改了一个金额计算规则、一个状态流转判断如果没有测试你只能手工去凑各种输入验证有了测试一个mvn test就能把所有历史场景重新跑一遍。尤其是Service层里那种复杂的if-else嵌套、状态机、边界条件判断单元测试几乎是唯一能长期守住正确性的手段。但单元测试也不是万能的。它验证的是“代码按照你写的逻辑运行”而不是“你的逻辑是否正确满足了业务”。比如某个返佣比例本身就写错了测试也会跟着错。所以说单元测试测的是实现与预期的一致真正的业务正确性还需要集成测试、验收测试和人工评审来兜底。另外单元测试对外部系统的真实连通性是无能为力的比如第三方HTTP接口、数据库特殊语法、Redis集群状态这些都要靠测试环境的集成测试去覆盖。认清这一点很重要能帮你合理分配精力。我见过不少团队把单元测试覆盖率当成唯一KPI结果大家专门写一堆不痛不痒的测试去凑行数真正的核心逻辑反而没人测。先想清楚“这段代码最重要的行为是什么”再动手写测试比盲目堆数量有价值得多。1.3 测试金字塔怎么落到SpringBoot项目里测试金字塔是一个经典的分层模型底层是大量快速、廉价的单元测试中间是数量适中的集成测试顶层是少量端到端测试。落到SpringBoot项目里我的习惯是单元测试针对Service、工具类、领域对象使用JUnit加Mockito不启动Spring容器毫秒级执行。切片测试Controller层用WebMvcTestRepository层用DataJpaTest只加载需要的Bean秒级执行。集成测试关键链路用SpringBootTest配合Testcontainers启动真实数据库或消息队列验证各层装配是否正确。这样分层的最大好处是绝大多数代码改动只需要跑底层测试几秒钟就能得到反馈不用每次都启动完整SpringBoot应用。只有涉及Bean装配、配置项变化时才需要跑集成测试。热词里经常看到有人问“SpringBoot测试太慢怎么办”多半是把所有测试都写成了SpringBootTest导致的结果。测试金字塔结构能在根上避免这个问题。2. 方案选型与基础概念先搞懂Spring Boot Test这一篮子东西2.1 spring-boot-starter-test 给你装好了什么SpringBoot项目里写测试第一步就是在pom.xml引入测试依赖通常长这样dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency这个starter并不是一个单独的库它是个“依赖集合”帮你把测试需要的主流组件一次性拉进来主要包括组件作用JUnit JupiterJUnit 5的核心提供Test、BeforeEach、AfterEach等注解Mockito创建Mock对象、打桩、验证调用关系AssertJ流式断言assertThat(...).isEqualTo(...)Hamcrest老牌匹配器库部分场景配合MockMvc使用JSONassert对JSON字符串做结构化断言JsonPath在MockMvc结果里用表达式提取JSON字段Spring Boot 3.x默认使用JUnit 5所以测试类上的注解是org.junit.jupiter.api.Test不是JUnit 4的org.junit.Test。如果项目里混着老代码注意别引错包否则测试方法根本不会执行。这里还容易踩一个坑有人做完基础配置后跑mvn test发现控制台提示“No tests found”排查半天才发现是import写成了JUnit 4的注解而项目里又没有引入JUnit Vintage兼容包测试自然不会被识别。关于版本我之前建议直接沿用spring-boot-starter-parent管理的版本不要手动指定JUnit或Mockito的版本号。Spring Boot版本升级时这些测试库往往会同步调整比如Spring Boot 3.4开始MockBean就被标记为废弃推荐用新的MockitoBean。如果你强行锁旧版本升级时容易出现各种莫名其妙的问题这部分我会在后面的避坑章节展开。2.2 三种测试写法的取舍纯Mock、切片测试、全量上下文SpringBoot的测试写法看着花样多本质上就是三种路线对应不同的验证粒度和启动成本。第一种是纯Mockito测试。测试类和Spring容器完全无关手写new对象用Mock创建依赖InjectMocks把Mock注入被测类。Service层测试我基本都用这种方式执行速度最快也不依赖环境。缺点是它验证不了注解是否生效、Bean是否装配正确比如你漏了Transactional导致事务没生效这种测试是发现不了的。第二种是切片测试。用WebMvcTest、DataJpaTest这类注解只加载Web层或数据访问层相关的Bean。Spring Boot Test提供了很多这种“切片注解”思路是把启动成本降到最低又能验证框架层的整合逻辑。比如WebMvcTest会帮你配置好MockMvc、消息转换器、异常处理器但不会加载Service和Repository。这个路线的性价比很高我强烈建议引入到日常开发里。第三种是全量集成测试。SpringBootTest会加载完整的ApplicationContext配合AutoConfigureMockMvc可以发起完整HTTP请求。这种测试最接近真实运行情况但启动速度慢且依赖的外部资源多适合验证关键链路不该当成所有测试的默认选择。很多团队测试耗时从几秒恶化到十几分钟就是因为在第三种路线上用力过猛。2.3 SpringBootTest 的分层启动与配置分离如果确实需要写SpringBootTest一定要学会给测试单独做配置。最常见的做法是在src/test/resources下放一个application-test.yml然后用ActiveProfiles(test)激活spring: datasource: url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY-1 driver-class-name: org.h2.Driver username: sa password: jpa: hibernate: ddl-auto: create-drop这样测试跑的时候用的是内存H2数据库不会碰到开发库或生产库的数据。有人会问能不能直接用application.yml里的MySQL配置跑测试能跑但非常危险测试数据可能会写进真实开发库而且一旦数据库连不上测试直接失败影响所有人。我的原则是测试环境永远用测试配置跟开发环境彻底分开。SpringBootTest还支持指定webEnvironment属性比如webEnvironment RANDOM_PORT会启动一个随机端口的真实Web服务器配合TestRestTemplate或WebTestClient使用。如果只是需要通过MockMvc模拟HTTP调用用默认的MOCK环境就可以不必真占端口。3. 实战演示一步步写完Controller/Service/Repository测试3.1 先看User模块代码结构与要测的点动手写测试之前我习惯先把“被测代码的关键行为”列出来。以UserService为例它的核心行为有四个按ID查到用户时返回用户对象查不到时抛UserNotFoundException按邮箱查用户时返回Optional创建用户时如果邮箱已存在则抛出IllegalArgumentException删除用户时若不存在同样抛异常。每一个行为都值得一个测试用例。被测代码大概长这样Service public class UserService { private final UserRepository userRepository; public UserService(UserRepository userRepository) { this.userRepository userRepository; } Transactional(readOnly true) public User findById(Long id) { return userRepository.findById(id) .orElseThrow(() - new UserNotFoundException(用户不存在, id id)); } Transactional(readOnly true) public OptionalUser findByEmail(String email) { return userRepository.findByEmail(email); } Transactional public User createUser(String name, String email) { if (userRepository.existsByEmail(email)) { throw new IllegalArgumentException(邮箱已被注册: email); } User user new User(name, email); return userRepository.save(user); } Transactional public void deleteUser(Long id) { if (!userRepository.existsById(id)) { throw new UserNotFoundException(用户不存在, id id); } userRepository.deleteById(id); } }可以看到UserService只依赖UserRepository没有其他乱七八糟的东西。这就是构造函数注入的好处后面测试时手动构造或者用InjectMocks都很干净。如果这里用字段注入测试时就得通过反射去塞Mock对象体验很差。这也是为什么我强烈推荐Spring团队现在主推的构造函数注入。3.2 Controller层用MockMvc写出接口行为验证Controller层的测试目标是“HTTP请求进来后参数绑定、路径映射、状态码、响应体结构是否正确”。这里用WebMvcTest只加载Web层UserService用一个Mock替身顶上。WebMvcTest(UserController.class) class UserControllerTest { Autowired private MockMvc mockMvc; MockitoBean private UserService userService; Test void getUserById_shouldReturnUser_whenUserExists() throws Exception { User user new User(1L, zhangsan, zhangsanexample.com); given(userService.findById(1L)).willReturn(user); mockMvc.perform(get(/users/{id}, 1L)) .andExpect(status().isOk()) .andExpect(jsonPath($.id).value(1L)) .andExpect(jsonPath($.name).value(zhangsan)) .andExpect(jsonPath($.email).value(zhangsanexample.com)); } Test void getUserById_shouldReturn404_whenUserNotFound() throws Exception { given(userService.findById(999L)) .willThrow(new UserNotFoundException(用户不存在, id999)); mockMvc.perform(get(/users/{id}, 999L)) .andExpect(status().isNotFound()); } }这个测试里有几个细节值得说。WebMvcTest会自动扫描所有ControllerAdvice所以UserNotFoundException经过全局异常处理器转换成404响应。如果没有写全局异常处理那么Service层抛出的异常会变成500这个问题在测试里一眼就能暴露出来。MockMvc的jsonPath支持用类似JSONPath的语法检查响应体字段比把整个JSON转成字符串去contains要可靠得多。注意WebMvcTest只加载Controller、ControllerAdvice、过滤器等Web相关Bean不会加载Service和Repository。如果你在测试里发现UserService为null说明你忘了声明MockitoBean或MockBean。3.3 Service层用Mockito把外部依赖全部打桩Service层的核心测试我几乎不用Spring容器直接用JUnit 5 Mockito。这样测试类没有容器启动负担跑起来是毫秒级。测试代码如下ExtendWith(MockitoExtension.class) class UserServiceTest { Mock private UserRepository userRepository; InjectMocks private UserService userService; Test void createUser_shouldSaveUser_whenEmailNotExists() { given(userRepository.existsByEmail(newexample.com)).willReturn(false); given(userRepository.save(any(User.class))) .willAnswer(invocation - invocation.getArgument(0)); User result userService.createUser(lisi, newexample.com); assertThat(result.getName()).isEqualTo(lisi); assertThat(result.getEmail()).isEqualTo(newexample.com); then(userRepository).should().save(any(User.class)); } Test void createUser_shouldThrow_whenEmailExists() { given(userRepository.existsByEmail(dupexample.com)).willReturn(true); assertThatThrownBy(() - userService.createUser(wangwu, dupexample.com)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining(邮箱已被注册); } Test void findById_shouldThrow_whenUserNotFound() { given(userRepository.findById(100L)).willReturn(Optional.empty()); assertThatThrownBy(() - userService.findById(100L)) .isInstanceOf(UserNotFoundException.class) .hasMessageContaining(100); } }注意几个关键点。ExtendWith(MockitoExtension.class)是Mockito集成JUnit 5的入口有了它Mock和InjectMocks才能生效。Mockito的when写法我用的是BDD风格的given读起来更像是“给定什么条件就期望什么结果”对中文团队来说语义更清晰。其实when和given底层是同一套API选一种风格并保持一致就好。这里还用到then(userRepository).should()它来自Mockito的BDD验证作用是确认某个方法确实被调用过。写它的时候要克制别把每个Mock方法都verify一遍。一般只在“这个调用是业务副作用核心”时才验证比如save、sendEmail、delete这些有对外影响的调用。像existsByEmail这种查询方法测了结果就够了再verify反而让测试变得啰嗦。3.4 Repository层用DataJpaTest验证SQL映射Repository层的测试需要真实数据库参与才能验证查询方法名和SQL映射是否正确。Spring Boot提供的DataJpaTest会启用一个基于H2内存数据库的JPA环境并且每个测试方法执行完自动回滚事务不会相互污染。DataJpaTest class UserRepositoryTest { Autowired private UserRepository userRepository; Test void findByEmail_shouldReturnUser_whenExists() { User user new User(test, findexample.com); userRepository.save(user); OptionalUser result userRepository.findByEmail(findexample.com); assertThat(result).isPresent(); assertThat(result.get().getEmail()).isEqualTo(findexample.com); } Test void existsByEmail_shouldReturnFalse_whenNotExists() { boolean exists userRepository.existsByEmail(not-existexample.com); assertThat(exists).isFalse(); } }DataJpaTest默认只会扫描Entity、Repository以及JPA相关配置不会加载Controller和Service。它默认使用内嵌数据库替换你配置的真实数据源前提是classpath下要有H2依赖。在pom.xml里加上这一段即可dependency groupIdcom.h2database/groupId artifactIdh2/artifactId scopetest/scope /dependency如果项目里用了MySQL特有的JSON字段、自定义函数或者复杂原生查询H2不一定完全兼容。这时候有两种选择一种是用Testcontainers启动真实MySQL容器测试最接近生产但环境依赖较重另一种是只对简单CRUD跑DataJpaTest复杂SQL留给集成环境。我的经验是大部分Spring Data JPA的派生查询在H2上都能验证项目初期可以先靠H2等SQL复杂到H2模拟不了再升级方案。3.5 覆盖特殊异常分支与边界输入很多人写测试只测“正常路径”和“一个异常路径”这远远不够。真正让测试体现价值的是那些最容易回归出问题的边界条件比如空字符串、null、超大ID、分页边界。以UserService的createUser为例至少还要补这几个场景Test void createUser_shouldRejectBlankName() { assertThatThrownBy(() - userService.createUser( , abcexample.com)) .isInstanceOf(IllegalArgumentException.class); } Test void createUser_shouldRejectNullEmail() { assertThatThrownBy(() - userService.createUser(zhangsan, null)) .isInstanceOf(IllegalArgumentException.class); }当然这些校验最好放在Bean Validation层或者Service入口但不管你放在哪一层测试都要跟上。我习惯用“分支覆盖”的思路来检查测试是否完整浏览一遍被测方法里所有if、else、catch、for确保每个分支至少有一个用例走到。不需要纠结覆盖率数字是不是100%但核心方法的每个分支必须走到这比盲目追求总覆盖率更重要。边界值测试还有一个额外好处它逼着你把代码里的“隐含假设”显式化。比如某个排序逻辑默认输入不为null一旦将来调用方传了null进来测试会第一时间告诉你“你的假设被打破了”。4. 覆盖率与质量门禁让测试结果变成可执行的标准4.1 JaCoCo用报表看哪些行没测到测试写没写够不能光凭感觉我一般用JaCoCo看覆盖率报告。在pom.xml里加入JaCoCo插件plugin groupIdorg.jacoco/groupId artifactIdjacoco-maven-plugin/artifactId version0.8.12/version executions execution goals goalprepare-agent/goal /goals /execution execution idreport/id phasetest/phase goals goalreport/goal /goals /execution /executions /plugin跑完mvn test之后在target/site/jacoco/index.html里可以打开报告看到每个类的行覆盖率和分支覆盖率。我一般重点关注Service层和工具类的分支覆盖率Controller层因为有大量模板代码可以放低要求Entity和DTO这类纯数据结构基本不要求覆盖。JaCoCo报告会告诉你哪一行没有被执行但不会告诉你“哪个分支的语义没测到”。比如一个if条件里的短路逻辑JaCoCo可能只标了一部分。把JaCoCo报表和人工走查结合起来才能对测试完整度有正确认知。覆盖率低说明肯定有盲区覆盖率“看起来高”也不代表万无一失尤其是那种为了凑覆盖率专门写一堆断言为空白的测试毫无意义。4.2 配置覆盖率规则不让低质量测试进入主干可执行的质量门禁需要把覆盖率阈值配置到构建里。JaCoCo支持在插件里配置rule比如要求行覆盖率最低80%核心包分支覆盖率最低70%不达标时执行mvn verify直接构建失败execution idcheck/id goals goalcheck/goal /goals configuration rules rule elementBUNDLE/element limits limit counterLINE/counter valueCOVEREDRATIO/value minimum0.80/minimum /limit /limits /rule /rules /configuration /execution把这个check挂在verify阶段之后等于在CI流程里设置了一道闸门新代码如果明显拉低覆盖率流水线会直接红。不过阈值不能拍脑袋定死我建议从一个相对保守的值开始比如70%到80%随着团队测试习惯建立再逐步提高。规则太严格会导致大家想方设法绕过覆盖比如把逻辑塞进private方法或者干脆写空测试反而破坏代码质量。4.3 单元测试的命名规范与维护习惯测试的命名直接决定你半年后再看它时能不能秒懂。我给团队定的规范是方法名_场景_期望结果。比如getUserById_shouldReturn404_whenUserNotFound一看就知道在测什么不用点开代码再想半天。中文团队也可以用中文方法名Java允许比如按ID查用户_用户不存在时_返回404实际效果更直观只要团队统一就好。测试代码同样是代码要像生产代码一样评审。我见过太多项目里测试代码写成一次性的条件写死、Mock一堆、断言全是isNotNull跑是能跑但什么都证明不了。维护的时候更痛苦的是测试跟着实现一起无限改动改一个方法签名可能要动十几个测试。要降低这种成本测试应该尽量面向“行为”而不是“实现细节”。比如createUser这个用例断言“保存成功且返回用户”就比“断言返回值里的id是多少”更稳定因为后者即使在没有意义的情况下也会暴露内部细节。另外还有一个实用习惯跑测试时优先跑增量测试而不是每次都全量回归。IDE里可以直接右键执行当前测试类mvn命令可以用-DtestUserServiceTest只跑指定类。个人开发阶段用这种方式省时间CI阶段再跑全量兼顾效率和安全性。5. 实操中踩过的坑与排查方法5.1 测试慢到没法日常跑先看看是不是没分层很多团队遇到的第一个问题是“测试跑太久”动辄几分钟起步。我排查过好几个案子原因高度一致核心测试类全都写着SpringBootTest甚至有的Service纯逻辑测试也把整个容器加载起来。一个测试类启动消耗三五秒几十个测试类叠在一起就是几分钟再加上有的测试还需要连数据库或消息队列慢上加慢。处理方式就是把第2章里讲到的三种写法铺开来纯Service逻辑用ExtendWith(MockitoExtension.class)不碰Spring容器Controller用WebMvcTest不用启动完整Web服务只有跨层链路才考虑SpringBootTest。改完后很多项目的测试执行时间能从十几分钟降到几十秒。如果测试里连JPA Repository都想绕过却又把SpringBootTest加上那几乎等于自己给自己上刑。5.2 版本太新引发的兼容问题MockBean怎么就废弃了Spring Boot 3.4发布后很多人升级完发现控制台打印了MockBean的废弃警告跑得通但心里发毛。实际上这是Spring官方在推动新注解MockitoBean和MockitoSpyBean作用类似MockBean但底层整合的是Mockito的MockitoSession生命周期管理更规范。升级到3.4以上的项目新测试代码可以直接使用MockitoBeanWebMvcTest(UserController.class) class UserControllerTest { Autowired private MockMvc mockMvc; MockitoBean private UserService userService; // ... }Spring Boot版本太高时还可能遇到另一个问题项目里用的旧版Maven Surefire Plugin不兼容当前JUnit Platform版本导致测试既不报错也不执行控制台只显示构建成功。看到这种情况别犹豫先检查surefire版本是不是跟Spring Boot版本匹配或者直接用spring-boot-starter-parent管理的默认版本。版本升级时多看release notes尤其关注标记为deprecated的类和注解能少踩很多坑。5.3 Bean循环依赖导致测试无法启动Spring Boot 2.6之后默认禁止Bean循环依赖如果你的Service里出现A依赖B、B又依赖A的情况项目启动会直接抛异常。平时主应用可能勉强能起但到了测试阶段更容易暴露因为测试加载的Bean范围不同装配顺序可能跟启动时不一样。我实际遇到过不止一次开发环境Debug时项目能启动但一跑集成测试立刻报循环依赖。原因往往是某些Bean按需懒加载正常启动时没有触发到依赖闭合的那一环而测试上下文启动时会去实例化更多Bean循环依赖就现形了。解法不是去把spring.main.allow-circular-references设成true那是掩耳盗铃。正确做法是用构造函数注入并重新审视两个类之间的职责边界把循环依赖拆掉。比如A依赖B的查询结果B又需要A的回调通常可以抽出一个领域服务或者事件机制来解决。5.4 测试数据互相污染怎么办测试数据污染是最容易埋雷的问题之一。你写了一个“查询用户”的测试单独跑能过跟其他测试一起跑就挂多半是数据残留导致的。DataJpaTest每个用例默认回滚所以一般不会出问题但如果你写了SpringBootTest又在测试方法里调用了真实数据库操作框架不会自动回滚数据就留下了。处理方案按优先级排列能用事务回滚的尽量给测试类加Transactional让每个测试方法结束自动回滚不能回滚的场景比如真实调用了外部接口、JPA之外写了原生SQL导致事务边界不同就在BeforeEach里清理相关表再复杂一点可以用Testcontainers每次启动一个干净的数据库实例彻底隔离。清理时注意外键依赖顺序先删子表再删主表不然会卡在外键约束上。内存数据库DB_CLOSE_DELAY这个参数也值得关注它控制最后一个连接关闭后数据库是否立即销毁测试多线程场景下设置不当会导致不同测试类看到同一个数据库状态。5.5 测试里用了异步逻辑容易出现“假通过”现代项目里多少都会用Async、CompletableFuture、消息队列做异步处理。这种代码在测试里特别容易写出“假通过”的用例主体方法执行完直接断言异步逻辑可能还没跑完测试偶尔绿、偶尔红让人完全摸不着头脑。针对异步测试我有一个判断顺序。如果异步逻辑非常简单比如只是往线程池里提交了一个任务那可以用CountDownLatch或Awaitility等待异步结果就绪再断言。如果异步逻辑很复杂那更好的做法是把它拆出来单独测试主体流程测试时用Mock去替代异步执行部分。以Async方法为例测试里最好显式等待一段时间或者直接同步调用底层逻辑而不是赌线程调度一定够快。Awaitility是个好工具它支持类似“最多等5秒每100毫秒检查一次条件”的写法比Thread.sleep硬等要可靠得多。心得写测试最忌讳的就是“能跑就行”。一个测试如果偶尔会挂、断言不明确、或者需要通过调整sleep时间去碰运气那它就不是资产而是负债。遇到这种用例应该立刻修放任不管会让团队成员逐渐失去对测试的信任最后整个测试套件形同虚设。收尾我这几年的一个真实体会项目里的单元测试从零到有、从有到有效最难的其实不是技术而是转变把“写完代码去启动一遍”当作验证的习惯。我刚入行那几年也觉得测试麻烦直到一个上线事故让我彻底改了思路——那次是一个状态判断的分支漏了处理线上数据被改错排查花了一整夜。后来我把当时的高危场景补成测试用例从那以后每次改动只需要跑一下相关测试心里就有底多了。所以最后一个建议是不用一上来就追求全局覆盖率数字先把核心Service的异常分支、边界条件和Controller的状态码这些最容易被回归破坏的点写出来。等团队跑测试变成习惯再慢慢扩展。你最后会发现写测试省下的调试时间远比写测试本身花掉的时间多得多。