把数据库拉进单元测试的那一刻,你的测试就不再“单元”了。但现实是,Service里几句JPA调用,Controller上几个注解,背后牵扯的表关联、事务边界、JSON序列化,哪一个都不能靠纯Mock凭空造出安全感。SpringBoot给了你一套极其丰富的测试脚手架,可真正落地时,大多数人只用了@SpringBootTest和@MockBean这两板斧,然后对着慢吞吞的启动和偶发的数据污染叹气。
这篇长文不打算重复官方文档的配方。我们直接从最让人头疼的数据库测试开始,一路走到Controller层,讨论每一层该测什么、不该测什么,以及怎么用最小成本获得最大置信度。单元测试的核心不是“快”,而是“确定性”——同样一段代码,无论跑多少次,结果都应当一致。数据库一旦参与,这种确定性就被连接状态、事务边界和脏数据撕碎了。
我见过某个团队给Repository写测试,直接在开发库上执行,跑完还要手工清理。结果测试结果取决于上次谁没删干净数据。真正的单元测试应当像无菌实验室,每一例实验都从干净状态开始,结束后不留痕迹。测试的隔离性比测试的速度更值得优先保证,因为一次偶然通过、偶然失败的测试,比没有测试更危险。
数据库层测试:从H2到Testcontainers的选择
SpringBoot对数据层的测试推荐@DataJpaTest,它会自动配置一个嵌入式内存数据库,默认情况下用H2替换你配置的MySQL或PostgreSQL。这个启动速度极快,而且每个测试方法默认事务回滚,执行完毕后数据自动还原。听起来完美,但坑往往出在“方言差异”上。
H2的MySQL兼容模式能解决大部分语法问题,可一旦你的项目里用了JSON列、窗口函数、GIS类型,或者依赖数据库特定的生成列,H2就会露出马脚。用H2测试通过,不代表MySQL上能跑——这类假阳性的测试会给你一种错误的安全感。解决思路有两种:要么坚持H2并严格控制SQL方言,要么改用Testcontainers启动真实的数据库容器。
Testcontainers在SpringBoot 3.1之后有了原生支持,可以通过@ServiceConnection自动配置。它的代价是每次启动测试都要拉起一个Docker容器,哪怕镜像已经缓存,首次初始化也需要几秒。但换来的是与生产环境几乎一致的方言行为、索引行为、约束行为。数据库层的测试价值,恰恰在于验证SQL与真实数据库的对话能力,如果你用内存库糊弄过去,这部分价值就归零了。
我建议按阶段选择:团队刚起步、SQL简单,用H2没问题;一旦遇到复杂查询或生产环境版本升级,立刻切换到Testcontainers。要注意,@DataJpaTest默认只扫描@Entity和Spring Data仓库,不会加载你的Service和Controller。这意味着测试中无法注入自定义的Repository实现,除非你用@Import显式引入。很多人在这一步卡住,然后干脆换成@SpringBootTest,把整个上下文全拉起来,数据库层测试就变成了一次“全量启动+慢速运行”的灾难。
测试数据准备:不要在生产代码里写if
写数据库测试时,最常遇到的灵魂拷问是:测试数据怎么构造?是用repository.save()手工插入,还是执行SQL脚本?我见过有人写了一个TestDataUtil工具类,里面几百行构造实体对象的代码,测试用例光准备数据就占了80%的行数,断言反而被埋没。测试的可读性比测试的覆盖率更重要,一段谁都看不懂的测试数据准备逻辑,本质上是在给未来的维护埋雷。
更糟糕的是,有些测试为了绕过数据准备,直接在生产Repository里添加“如果当前处于测试环境则初始化数据”的代码。这种开关一旦出现,测试和生产环境的逻辑就开始分叉,你有何信心能保证分支表达式两边的行为完全一致?测试环境与生产环境的分裂程度,决定了测试结果的可信度。宁可多写几行显式的保存语句,也不要在业务代码里测试特判。
对于需要大量基础数据的场景,用@Sql注解加载脚本是个好选择。SpringBoot会在测试前执行指定SQL文件,测试后自动回滚(如果事务在场)。但脚本本身也是代码,要维护好版本。另外,注意@Sql的执行顺序——默认在@BeforeEach之前,但如果你有多个@Sql需要按依赖层次执行,可以指定executionPhase和order属性。数据准备的原则应该是:让测试用例自己完全掌控它需要的数据,绝不依赖外部现存数据或测试工具的隐式初始化。
Service层测试:Mock还是真实?
Service层是单元测试的经典区域。SpringBoot提供了@ExtendWith(MockitoExtension.class),配合@Mock和@InjectMocks可以快速构建一个脱离Spring容器的测试单元。这种方法非常适合验证Service中的业务逻辑分支、异常处理、事务边界上的方法调用顺序。比如一个下单方法,你需要验证“库存不足时抛出业务异常”或“调用支付网关后更新订单状态”,这时把外部依赖全部Mock掉,逻辑就会变得直白。
但Mock有一个致命伤:Mock会掩盖参数传递和返回值之间的契约变化。当Repository的返回类型从Optional<User>改成User时,Service测试里写好的when(repo.findById(id)).thenReturn(Optional.of(user))会在编译期就报错,这反而是一件好事。可如果你Mock的是MyBatis的Mapper接口,方法签名不变但SQL映射变了,测试照样通过。Service层测试的粒度应该控制在“仅验证当前类自己的逻辑”,任何跨层的真实调用都意味着你从单元测试滑向了集成测试。
在SpringBoot项目中还有一种折衷方案:对Service使用@SpringBootTest加@MockBean来Mock掉Repository,而保留真实的事务管理器和一些基础设施。这种做法可以测试@Transactional的回滚行为,但代价是启动完整上下文。如果你的服务启动要五秒,一百个测试用例就是五百秒。我觉得合理的做法是:纯逻辑用纯Mockito,需要验证事务或AOP代理时才启动Spring上下文。不要让“省启动时间”变成“逃避写测试”的借口。
事务是Service层最容易被测漏的部分。比如一个方法调用了两次save,第二次失败时第一次是否回滚?在纯Mockito测试里,你看到的只是方法执行抛出异常,却看不到数据库的最终状态。要真正验证事务边界,要么用真实数据库加上@Transactional测试,要么用TransactionTemplate手动控制。我更倾向于把事务边界单独抽出来,用少量集成测试覆盖,而不是在每个Service测试里都启动Spring。
Controller层测试:MockMvc的三重境界
Controller层是SpringBoot测试中最热闹的地方。@WebMvcTest加载Controller层相关的Bean,不加载Service和Repository,主要用来验证MVC映射、参数绑定、数据校验和响应序列化。第一重境界:用@MockBean把Service层全部Mock掉,用MockMvc发送请求,断言状态码和JSON结构。这种测试运行快、focus明确,适合开发者在写Controller时快速自测。
第二重境界:把Service层和Repository层也用真实Bean加载,变成一套完整的“迷你端到端”测试。用@SpringBootTest加@AutoConfigureMockMvc,浏览器发什么请求,测试就发什么请求,一路打到数据库。这种测试的价值在于验证各层之间的胶水代码是否正确无漏——比如Jackson序列化时循环引用、日期格式、字段null的遗漏处理。但代价是慢、耦合高,一个地方出错,所有Controller测试都可能挂。
第三重境界:使用MockMvc的standaloneSetup,只加载你指定的Controller实例,手动注入Mock的ServiceBean。这种方式最快,但需要你自己配置异常处理器、过滤器等。它适合对单个Controller做非常细节的测试,比如校验分组、参数转换错误时返回的字段。说实话,我很少用standaloneSetup,因为SpringBoot的@WebMvcTest已经能帮你处理大部分基础设施,除非你要测试的Controller与全局配置有冲突。
Controller层核心要验证的是HTTP协议与Java对象之间的映射,而不是业务逻辑。所以你在断言时应当多关注响应JSON的字段名、层级、类型,少去断言“service被调用了几次”这种内部细节。MockMvc的jsonPath断言几乎是标配,它让你能用选择器表达式直接验证响应内容。但别陷入过度断言——把每个响应字段都逐个检查,只会让测试变得脆弱,当你有一天给响应加了一个非关键字段时,所有断言都得跟着改。
分层测试本质上是权衡取舍
从数据库到Controller,每一层测试都有自己独特的关注点。但很多项目把这三层混在一起,用@SpringBootTest把整个上下文拉起,然后在同一个测试方法里既查数据库又调HTTP接口,最后断言一个页面的内容。这种“全栈测试”表面上覆盖广,实际上出了问题很难定位——是数据库SQL错了,还是Service逻辑错了,还是Controller映射错了?一个失败的测试如果不能快速指出失败的层,它就没有起到“设计文档”的作用。
更好的实践是:对Repository层,用真实的数据库方言验证SQL正确性;对Service层,用纯Mock验证业务规则和异常分支;对Controller层,用@WebMvcTest验证HTTP契约。这三层测试各自独立、各自快速,组合起来才是完整的信心。测试金字塔的每一层都要保持其应有的“原子性”,一层测坏不会导致另一层的灾难性连锁反应。
同时,别忘了测试命名。别用testAdd()、testDelete()这种毫无信息量的方法名。测试名应当描述“条件+行为+结果”,比如givenUserNotFound_whenGetUser_thenReturn404。测试是活文档,方法名就是文档标题。很多人代码写得规范,测试名却随意,结果半年后没人敢改测试,因为根本不知道每个测试在保护什么行为。
测试之间的依赖:单元测试的大忌
在我看过的大量SpringBoot项目中,一种常见的反模式是测试方法之间有隐式顺序依赖。比如第一个测试方法往数据库插入了一条用户,第二个测试方法假定这条用户还存在,第三个测试方法再把它删掉。这种做法省事,但违背了单元测试的“可独立执行”原则。当你单独运行第二个测试方法时,它失败;当你按特定顺序运行所有测试时,它通过。这种充满“时序魔法”的测试套件,会让每个新接手的人都提心吊胆。
每个测试方法都应当是一个自包含的故事,有自己的前提、自己的动作、自己的验证。如果你需要“已存在的用户”,就在该测试方法里显式地插入。如果你用事务回滚,那么每个方法看到的都是完全干净的表。如果你用Testcontainers,那么每个测试类共享同一个容器,但数据要清空或使用事务边界隔离。永远不要依赖测试执行顺序来保证结果正确,因为在JUnit 5中,方法的默认执行顺序本身就不是确定的。
还有一点,清理数据时要注意外键关联。如果你用@Sql脚本去执行DELETE FROM table,可能因外键约束而失败。推荐的方式是让测试方法运行在事务中并且不提交,这样数据库操作自动回滚,无需手动清理。对于必须提交数据的场景(比如测试异步任务或非事务行为),则要设计好清理顺序。回滚是测试隔离的免费午餐,但如果你的被测代码自己开启新事务,买单就会翻倍。
编写可维护测试的几条心法
第一,避免在测试代码中出现魔术数字。比如断言status().isOk()中的200没问题,但断言jsonPath("$.code").value(1)中的1最好先定义为常量或枚举。数字含义不清晰,将来业务含义变化,你很难知道该改哪里。第二,使用assertThat而非JUnit原生断言,结合Hamcrest或AssertJ能让失败信息更容易理解。第三,对于时间相关的测试,不要依赖真实时钟,而是注入一个可控制的Clock,让测试在任意时间点运行都能得到相同的结果。可重复性是测试的底线,任何一次偶然通过、偶然失败的测试都是技术债。
第四,测试数据构造器要简洁。可以考虑用Builder模式或单独的一个数据组装模块,尽量避免在业务测试里看到二十行长链式的set调用。但也要小心,数据构造器一旦变得复杂,就会出现“测试数据工厂”黑洞——所有测试都用同一个工厂,工厂里塞满了各种条件分支,最终你改一个字段要影响几十个测试。不如让每个测试自己用几行代码构造数据,虽然重复,但清晰。重复是聪明的代价,而在测试里,重复往往比耦合更安全。
最后,注意测试命名时要体现“行为”而非“方法”。代码写的是“userService.getUser”,测试写的是“shouldReturnUserWhenIdExists”。两者集合在一起,才真正表达了系统的行为规格。
性能陷阱:慢启动、慢IO、慢构建
SpringBoot测试最大的痛点是慢。@SpringBootTest每次启动要加载整个ApplicationContext,缓存策略虽然能减少重复加载(同名上下文只加载一次),但只要你改了配置或新增了一个Bean,缓存就失效,下一次测试启动照样全量初始化。因此,一个中型项目的完整测试套件跑个十分钟毫不稀奇。测试速度直接影响开发者的写测试意愿,如果每次跑测试要等五分钟,没人愿意频繁运行。
对于数据库相关的测试,优化手段有很多:使用Testcontainers时尽量让多个测试类复用同一个容器,用@Testcontainers(parallel = true)开启并行;使用H2时关闭不必要的能力如内嵌服务器模式;为测试单独配置连接池,避免等待。但更根本的策略是分层压缩测试规模——把大部分测试做成纯Mock的单测,仅对关键路径保留少量集成测试。这样既保证了覆盖面,又把平均运行时间控制在几十秒内。
我见过最极端的案例是一个项目把所有测试都写成@SpringBootTest,包括一个测试Controller里是否返回正确视图名的用例。启动整个应用去验证一个字符串是否相等,这既浪费又误导。测试金字塔的顶端是少数端到端场景,底部才是大量的细粒度单元测试。如果你发现自己的测试大头是“启动整个应用、连接真实数据库、调用HTTP接口”,那说明你正在用攻城锤钉钉子。
从数据库到控制器:一条完整的自信链
当你在数据库层用Testcontainers验证了原生SQL,在Service层用Mockito锁死了所有可能的异常分支,在Controller层用MockMvc确保了HTTP契约的稳定,你才真正拥有了一条从存储到接口的自信链。这条链上的每一环都能独立失效,也都能独立修复。你不需要在一个测试里同时模拟“数据库满了”“用户不存在”“请求参数非法”这种叠加态。
当然,每层测试都会有自己的盲区。数据库层测试不知道Service怎么拼装查询条件;Service层测试不知道Controller会返回什么状态码;Controller层测试不知道真实数据库里的数据长什么样。但正因为盲区存在,你才会更需要一套清晰的自动化测试策略,而不是把希望寄托在某个“全能测试”上。分层测试的本质,就是让每一层的错误在靠近它的那一层就被捕获,而不是等到最顶层测试失败时,再开始大海捞针。
单元测试的实践,从来不是工具清单的堆砌。它是一连串关于“验证什么、不验证什么、怎样验证最快”的判断。SpringBoot提供了丰富的测试切片,@DataJpaTest、@WebMvcTest、@JsonTest、@RestClientTest,每个切片都在告诉你“这一层测试应该长什么样”。你只需要放下“全量启动”的执念,认真思考每一层业务行为的独特性,就能设计出既快又准的测试套件。
测试不是为了证明你写完了代码,而是为了证明代码没有背叛你。从一开始就把数据库、事务、控制器、JSON这些角落用清晰的测试一一照亮,你的项目才敢在每次修改后快速交付。这个过程没有捷径,只有一层一层地打磨,才能让你的代码在重构的浪潮中依然站得稳。