☰
SpringBoot单元测试实战:从@Test注解到Mockito的完整指南
2026/10/8 2:39:26 网站建设 项目流程

昨天整理博客摘录,翻到一段关于SpringBoot项目中单元测试的笔记,正好和团队最近讨论的问题撞上了。SpringBoot、单元测试、@Test这几个词放在一起,几乎是每个后端项目都绕不开的话题,但说实话,很多项目里的测试代码也就是“能跑”的程度,离“有用”还差得远。

这篇笔记想聊的,不是教科书式的JUnit教程,而是我在SpringBoot项目里实操单元测试时,怎么理解@Test、怎么把测试写成真正能保护代码的东西,以及中间踩过的一堆坑。适合正在写SpringBoot项目、想给核心逻辑补测试,或者刚把测试跑起来但总感觉哪里不对的开发者。内容会尽量掰开揉碎讲清楚,能直接照着操作。

1. 为什么SpringBoot项目里,单元测试不是可选项

1.1 从“能跑”到“敢改”:单测解决的真实痛点

说句大实话,在SpringBoot项目里真正让我意识到单元测试价值的时候,不是赶工写代码那会儿,而是接手一个老项目准备大改的那几天。Controller里塞了好几千行逻辑,Service互相调用,Repository层换了个数据源,结果没人敢碰那段代码。为什么?因为没有人知道改完了之后,原本能用的功能是不是被碰坏了。手动启动项目、调接口、看数据库,这一趟流程走下来,快则几分钟,慢则半天,还不能保证覆盖到所有的分支。

单元测试解决的就是这个“敢不敢改”的问题。一个@Test方法就是一个小型验证器,它不启动整条业务链路,不去连真实的数据库和第三方服务,只验证某一个类、某一个方法在给定输入下的输出是否符合预期。你改了A方法,跑一下A对应的测试,绿了,基本就能说明这次改动没有把A原有的行为弄坏。这种反馈速度,是手动验证完全给不了的。

SpringBoot项目里写单元测试,看起来只是加几个注解和断言,但背后的核心思路是“把验证从人肉回归变成自动化回归”。项目越大,依赖越多,这个价值就越明显。你想想,一个服务里十几个类,每个类又有十几个公共方法,手动把所有分支都点一遍得花多久?测试跑一遍可能就几秒钟。

1.2 测试金字塔与SpringBoot项目的实际取舍

业界常说的测试金字塔,底层是大量的单元测试,中间是集成测试,顶层是端到端测试。金字塔的意思是:越底层,数量越多,执行越快,维护成本越低;越顶层,数量越少,执行越慢,维护成本越高。

SpringBoot项目落地的时候,这条金字塔会被稍微改一改。因为SpringBoot本身是一个容器化的框架,你很难完全脱离Spring上下文去测一个Controller或者一个Service,如果硬要全部用纯单元测试的玩法,Mock东西会非常痛苦。所以实际项目里常见的组合是:核心业务逻辑用纯单元测试覆盖,Controller层用SpringBoot提供的切片测试(比如@WebMvcTest),跨服务的流程用集成测试去验证,端到端测试只留少数几条关键链路。

很多人容易走进一个误区,觉得SpringBoot的单元测试就是务必启动完整的ApplicationContext,于是每写一个测试就丢一个@SpringBootTest。结果呢?整个测试套件跑一次要十几分钟,越来越多的人不愿意跑测试,测试就慢慢烂掉了。正确的做法是尽量缩小测试范围,能用切片解决的就不要启动整个项目。

1.3 常见误区:把“启动整个项目”当成单元测试

我见过一种特别典型的代码,测试类里几乎什么都没干,就一个空的@Test方法,加上@SpringBootTest注解。问为什么要这样写?回答说“我想验证项目能正常启动”。听起来有道理,但这个测试真正保护了什么?它能发现Bean缺少、配置写错之类的问题,不过一旦项目里引入新的中间件依赖(比如消息队列、流程引擎、或者一个Flink任务),这种测试就会变得特别重,甚至成为团队CI的负担。

真正有价值的单元测试,关注的是“某一个行为在给定输入下是否正确”,而不是“整个项目能不能起来”。SpringBoot项目启动问题的验证,交给CI的构建流程就足够了,没必要用单元测试来承担这个职责。所以我的建议是:写测试之前先问自己一句,这个测试要是失败了,能告诉我哪段逻辑出了问题吗?如果答案很含糊,那这个测试大概率设计得有问题。

2. @Test背后的骨架:注解机制、断言与Mock

2.1 @Test到底做了什么,为什么它如此轻量

在JUnit 5的体系里,@Test标注的方法就是一个测试用例。JUnit 5本身分了三块:JUnit Platform负责在IDE和构建工具里发现并执行测试;JUnit Jupiter提供了@Test这些注解和具体的执行引擎;还有JUnit Vintage是为了兼容老项目的JUnit 4。

@Tenst注解标注的方法有几个硬性规则:不能有返回值,不能有参数,在Java里可以是public或者包级私有。JUnit发现测试类的时候,会自动找到所有标注了@Test的方法,逐个执行,每个方法独立成一次“执行单元”。测试方法抛了异常或者断言失败,那这个方法就标记为失败,不影响其他测试方法继续跑。

这种设计背后其实有个很实用的理念:每个@Test方法都应该足够小、足够自包含。我以前写测试的时候总喜欢一个方法里塞七八个断言,后来发现一旦失败,得花时间分析究竟是哪一步出了岔子。JUnit 5支持了@DisplayName,可以给测试方法起一个人类可读的中文或英文描述,团队协作的时候,点开测试报告就能一眼看出哪个行为没有通过,比看一堆testMethodName舒服多了。

2.2 断言与Mock:让单测既完整又不依赖外部环境

有了@Test注解,只是告诉JUnit“这是一个要执行的方法”。真正判断“对不对”,靠的是断言。JUnit自带的断言有assertEquals、assertTrue、assertNotNull这几种基础款,但后来我越来越偏向用AssertJ。AssertJ的断言是流式的,比如assertThat(result).isEqualTo(...).isNotNull(),写起来更像是自然语言,而且失败时的错误信息比JUnit原生断言详细得多,定位问题的时候省了不少力气。

单测里另一个绕不开的环节是Mock。SpringBoot项目里的Service往往要操作Repository、调用外部HTTP接口、发送消息到队列。如果没有Mock,你要让单元测试跑起来,要么起一个MySQL,要么起一个Redis,甚至还要连一个消息中间件。这哪儿还是单元测试?已经是集成测试了。Mockito就是来解决这个问题的,它能生成一个假的对象,你在测试里定义好“当调用某个方法时,返回什么结果”,然后被测代码拿到的就是这样一个受你控制的对象。

Mockito常用的有三板斧:mock()用来创建假对象,when(...).thenReturn(...)用来设置行为,verify(...)用来验证某个方法有没有被调用、调用了几次。配合Mockito的注解@Mock和@InjectMocks,可以自动地把假对象注入到被测类里,省去手动new和set的代码。这套组合用下来,测试才真正做到了“只测当前类的逻辑,不掺和别人的行为”。

2.3 Spring Boot测试中的几个关键注解,怎么选

Spring Boot在spring-boot-starter-test里打包好了JUnit、Mockito、AssertJ这些常用的测试库,不需要你一个个去引。你还需要理解几个和Spring测试相关的注解,用错了会把测试写得很重。

@SpringBootTest会启动完整的ApplicationContext,适合做集成测试或者跨层的冒烟测试。@WebMvcTest只加载Web层相关的Bean,Controller、过滤器、拦截器这些会加载,但Service、Repository这些不会,需要你用Mock去补。@DataJpaTest只加载JPA相关的配置和Repository,适合测数据访问层。@MybatisTest类似,专门服务MyBatis。

选型原则很简单:测哪一层,就用哪一层的切片注解。全量启动的@SpringBootTest是最后的兜底方案,不是首选。把这句话刻脑子里,你的测试跑起来会快一个数量级,同事也会感谢你的。

3. 实操一个完整的SpringBoot单测:从依赖到三层用例

3.1 环境准备:pom依赖与测试目录结构

先看一下Maven项目的pom.xml。现在大多数SpringBoot项目只要引入了spring-boot-starter-parent,再加上一个spring-boot-starter-test就行了。Spring Boot 3.x的写法如下:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>

scope是test,意思就是这个依赖只在编写和运行测试的时候生效,不会打进最终的jar包。spring-boot-starter-test里面包含了JUnit Jupiter、Mockito、AssertJ、JsonPath等一系列测试需要用到的库。如果你在工程里还用到Spring Security这类框架,往往还需要引入spring-security-test,用来构造认证信息、做安全相关的断言。

测试代码放在src/test/java目录下,包名建议和被测代码保持一致,这样同一个包内就可以访问那些包级私有的方法和字段,测试写起来更顺手。测试资源文件放在src/test/resources目录,它和src/main/resources是隔离的,你可以放专门的application-test.yml,而不会干扰生产环境的配置。

3.2 Service层测试:@BeforeEach与事务回滚

接下来写一个实际的例子。假设有一个UserService,依赖UserRepository,核心方法是从数据库按ID查用户,再对用户状态做判断,然后返回一个处理结果。

我先展示一段原始版本的代码:

@Service public class UserService { private final UserRepository userRepository; public UserService(UserRepository userRepository) { this.userRepository = userRepository; } public UserDetail getUserDetailById(Long id) { User user = userRepository.findById(id) .orElseThrow(() -> new RuntimeException("用户不存在")); if ("VIP".equals(user.getLevel())) { return new UserDetail(user.getNickname(), true); } return new UserDetail(user.getNickname(), false); } }

我要测的是UserService里的getUserDetailById方法,我不希望真的去连一个MySQL数据库,所以UserRepository就用Mockito来模拟。

class UserServiceTest { @Mock private UserRepository userRepository; @InjectMocks private UserService userService; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } @Test @DisplayName("VIP用户返回VIP标识") void shouldReturnVipFlagWhenUserIsVip() { User user = new User(); user.setId(1L); user.setNickname("张三"); user.setLevel("VIP"); when(userRepository.findById(1L)).thenReturn(Optional.of(user)); UserDetail detail = userService.getUserDetailById(1L); assertThat(detail.isVip()).isTrue(); assertThat(detail.getNickname()).isEqualTo("张三"); } @Test @DisplayName("普通用户不返回VIP标识") void shouldReturnNotVipWhenUserIsNormal() { User user = new User(); user.setId(2L); user.setNickname("李四"); user.setLevel("NORMAL"); when(userRepository.findById(2L)).thenReturn(Optional.of(user)); UserDetail detail = userService.getUserDetailById(2L); assertThat(detail.isVip()).isFalse(); } }

这里有几个细节值得展开说。

@BeforeEach标注的方法会在每个@Test方法执行前先跑一遍,适合用来做通用的初始化工作。早期版本里,我见过有人用@Before这个方法,那是JUnit 4的写法,JUnit 5换了名字叫@BeforeEach,功能不同但语义更清晰。

@Mock创建的假对象是空的,方法的返回值默认是null。如果被测代码里调了mock对象的方法,但你没有用when去定义返回值,调用结果就是null,接下来就容易抛出NullPointerException,然后测试莫名其妙就挂了。所以每次写测试的时候,要顺着被测代码的执行路径,把所有依赖分支的mock行为都定义清楚。

@Transactional和单元测试的关系经常被误解。很多人在@SpringBootTest里加上@Transactional,希望测试结束后数据自动回滚,不污染开发库。这个做法在一定范围内是有效的,但需要明确两点:第一,它只对真实数据库的连接有效,你mock掉的对象不走事务;第二,如果你测的是异步方法、或者方法内部自己开了新线程,事务回滚就未必生效了。所以,能用Mock解决的别连数据库,必须要连数据库的场景再考虑事务回滚。

3.3 Controller层测试:@WebMvcTest与MockMvc

Service测完之后,接下来测Controller。Controller的职责本身不复杂:接收请求参数,调Service,包装成响应。但Controller牵涉HTTP协议、参数校验、异常处理,手动验证起来特别麻烦,MockMvc就是专门干这个的。

@WebMvcTest只会加载Controller层的东西,不会加载Service和Repository。所以需要把Controller依赖的Service给mock掉。Spring Boot 3.x里,旧版的@MockBean已经标记为过时,官方推荐用@MockitoBean,但很多存量项目里还是能见到@MockBean,能看懂就行。

@WebMvcTest(UserController.class) class UserControllerTest { @Autowired private MockMvc mockMvc; @MockitoBean private UserService userService; @Test @DisplayName("GET请求返回用户详情") void shouldReturnUserDetailByGetRequest() throws Exception { when(userService.getUserDetailById(1L)) .thenReturn(new UserDetail("张三", true)); mockMvc.perform(get("/api/users/1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.nickname").value("张三")) .andExpect(jsonPath("$.vip").value(true)); } }

MockMvc的思路是模拟一个HTTP请求进来,经过DispatcherServlet分发到对应的Controller方法,再把返回结果拿到,做状态码、响应体这些断言。它不需要真的起一个Tomcat端口,所以测试的启动速度比@SpringBootTest快很多。

写Controller测试的时候,有个容易忽略的点:如果你的Controller里用了参数校验,比如@Validated配合@RequestBody,那测试里就得多考虑校验失败的场景,构造一个非法请求,断言返回的HTTP状态码是400而不是200。这些都是先有需求再补测试时容易漏掉的分支。

3.4 测试中如何组织多环境配置

SpringBoot项目一般会有多套环境,dev、test、prod,对应的配置文件是application-dev.yml、application-test.yml、application-prod.yml。单元测试执行的时候用哪一套配置?默认情况下,测试加载的是application.yml或application.properties,配合application-test.yml需要在测试类上加@ActiveProfiles("test"),或者通过test/resources目录下的application.yml来指定spring.profiles.active=test。

实际项目里,我通常会在src/test/resources下面放一个独立的application.yml,里面只保留测试需要的最小配置,比如内存数据库连接、日志级别。这样测试不会读取开发环境里的真实数据库地址,也不会误连到生产环境服务上。这一点在团队协作里特别重要,不然某天同事的本地配置和你的不一致,测试跑出来的结果千奇百怪。

4. 单元测试避坑实录:常见问题与排查技巧

4.1 数据被改、Mock不生效、启动太慢

单测跑多了,会遇到一些比较典型的坑。我把遇到最多的问题整理成一个速查表,供大家参考。

常见问题具体表现根本原因解决方案
测试跑完,数据库里多了条数据本地开发库被污染,第二次跑测试报了唯一键冲突测试方法里真实调用了Repository.save,但没有回滚如果是@SpringBootTest,看是否加了@Transactional;更推荐直接Mock掉Repository
Mock方法返回值一直是nullwhen条件明明写了,但被测代码拿到的还是nullMock对象传入的不是同一个实例,或者参数不匹配检查@InjectMocks注入是否成功;使用any()或者特定参数匹配器
测试启动要几分钟每个测试都启动完整Spring上下文@SpringBootTest用得太泛滥换成@WebMvcTest、@DataJpaTest等切片测试
测试方法间相互影响前面跑过的测试改了静态状态,后面测试行为异常静态变量、单例对象被共享用@AfterEach清理状态,或者用MockedStatic等方式处理静态资源
中文乱码出现在测试报告里断言中的中文和预期不一致控制台或文件编码不是UTF-8在pom.xml里设置project.build.sourceEncoding为UTF-8

Mock不生效那类问题,是大多数新手最容易扑街的地方。当你发现when定义的行为没有生效,第一步先确认被测类里真正调用的对象,是不是你mock的那个对象。Spring容器里如果已经有一个真实Bean,你又通过@MockitoBean替换了它,那确实没问题;但如果你自己new了一个service对象,mock注入的方向错了,自然就失效了。

4.2 上下文加载慢怎么办,以及端口冲突怎么处理

前面提到@SpringBootTest越少越好,但有些跨模块联合逻辑绕不开它。这种时候可以优化一个方面:测试上下文缓存。Spring Test框架会把ApplicationContext缓存下来,如果多个测试类使用了完全相同的配置,后续测试直接复用同一个上下文,不需要重新加载。只要配置差异一多,缓存命中率就会下降,启动时间成倍上升。

另一个很隐蔽的坑,是端口冲突。@SpringBootTest默认的WebEnvironment是MOCK,不会真的启动Tomcat,所以一般不占端口。但有人为了让测试里可以发起真实的HTTP调用,会设置成RANDOM_PORT,随机分配端口,这个问题相对好一些;如果设置成DEFINED_PORT写死了一个端口,那和本地正在跑的应用撞车只是时间问题。真要在RANDOM_PORT下获取端口号,用@LocalServerPort注入即可。

还有一种情况,测试里同时引入了Redis、消息队列之类的中间件,@SpringBootTest会因为连接不上这些外部服务而直接失败。我的建议是,这种场景一律Mock掉中间件的客户端Bean,或者使用Testcontainers在Docker里起一个临时容器,测试结束自动销毁。Testcontainers看起来香,但也要求开发机器上有Docker环境,CI里也得有对应的配置,自己权衡一下团队条件再上。

4.3 测试命名与团队协作里的细节

单元测试写到后面,难的不是技术,而是可维护性。我见过太多测试方法的命名是test1、test2这种,半年之后重跑测试,连作者本人都说不清楚这个测试到底在验证什么。建议用英文或者中文描述都行,前提是必须说清楚“测试什么行为、输入是什么、期望什么结果”。

团队协作的时候,我一般建议约定这几条:核心业务方法必须有对应测试,修过的Bug必须补一条回归测试,定时任务和异步方法尽量抽纯逻辑再测。把压力放在“行为覆盖”而不是“行覆盖”,因为行覆盖率高不代表逻辑覆盖好,有些行重复执行一百次也不会暴露分支错误。

还有一个小印象:不要把多个断言塞满一个@Test,断言之间如果有关联,放在一起还行;如果只是顺手凑的“顺便验证一下”,分开写会更清晰。测试失败以后,你第一时间要看的是“哪一行断言失败”,而不是在一大堆断言里逐行排查。

4.4 边界条件与异常分支的测试补充

很多项目里测试覆盖率看起来不低,但测的全是“正确路径”,异常分支一测就露馅。单元测试真正值钱的地方,恰恰在于能把这些边界情况一个个钉死。比如刚才那个UserService,除开用存在的用户ID来测,还得思考几个问题:用户不存在时抛不抛异常?用户level字段是null时会不会NPE?ID传0或负数会怎样?

给一段异常分支的测试示例:

@Test @DisplayName("用户不存在时抛出异常") void shouldThrowExceptionWhenUserNotFound() { when(userRepository.findById(99L)).thenReturn(Optional.empty()); assertThatThrownBy(() -> userService.getUserDetailById(99L)) .isInstanceOf(RuntimeException.class) .hasMessageContaining("用户不存在"); }

隐患往往藏在那些“没写代码的分支”里。接手老项目时,与其盯着正常功能翻来覆去,不如先补异常分支的测试,性价比要高得多。等这些测试全部亮绿,再去调优化,心里就有底了。

5. 写在后面:我的一点实操体会

单元测试这件事,刚开头的时候总是最难的。你可能觉得“业务都忙不过来了,还写测试”,真正做了之后才知道,测试不是给老板看的指标,是给自己兜底的。我个人的习惯是,拿到一个SpringBoot需求,先花十分钟想清楚核心业务有哪几个行为分支,然后把每个分支对应到测试方法名上,再动手写实现代码。等代码写完,测试也跟着绿了,那种踏实感是纯写业务代码给不了的。

实测下来,另外一个值得养成的习惯,是每次修复线上Bug后第一反应补一条测试。要是没有对应测试,这个Bug的下一次出现只是时间问题。测试名字可以不讲究,但这条测试记录一定得留下,它会成为你后来重构和升级依赖时最有力的保障。最后再分享一个小技巧,给测试类加上@DisplayName之后,测试报告看起来一目了然,团队周报里放上去也是加分项。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询