多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

SpringBoot单元测试实践:从数据库到控制器

SpringBoot单元测试实践:从数据库到控制器 把数据库拉进单元测试的那一刻你的测试就不再“单元”了。但现实是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的返回类型从OptionalUser改成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这些角落用清晰的测试一一照亮你的项目才敢在每次修改后快速交付。这个过程没有捷径只有一层一层地打磨才能让你的代码在重构的浪潮中依然站得稳。
返回列表