1. 什么是单元测试
1.1 基本定义
- 单元测试(Unit Test) 是对软件开发中最小可测单位(例如一个方法或者一个类)进行验证的一种测试方式。
- 在 Java 后端的 Spring Boot 项目中,单元测试通常会借助 JUnit、Mockito 等框架对代码中核心逻辑进行快速且隔离的验证,保证功能正确性。
目的:及早发现并修复 BUG,使后续迭代功能或重构时能迅速验证不会破坏已实现的功能。
1.2 单元测试在 Spring Boot 中的地位
- Spring Boot 提供了非常方便的测试支持,如
@SpringBootTest
、@TestConfiguration
等注解,让开发者可以快速地在带有 Spring 容器上下文的环境中执行测试。 - Spring Boot 本身也对 JUnit、Mockito、AssertJ 等常用测试框架或库提供了开箱即用的整合或依赖。
1.3 单元测试与其他测试的区别
- 单元测试:聚焦在一个方法或者一个类层面,不涉及过多外部依赖,能极快地发现逻辑错误。
- 集成测试:多个模块或组件交互时的测试,通常依赖真实数据库、消息队列等外部资源。
- 端到端测试(E2E):关注的是整个系统的完整流程,包括前端、后端、数据库、外部接口等。
- 在 Spring Boot 环境中,可以使用
@SpringBootTest
搭配 Mock 或者内存数据库来实现集成测试,但这通常已经不只是“单元”级别了。
2. 为什么要写单元测试?
- 快速发现 Bug:写完代码马上测,不用等到上线才被发现问题。
- 减少回归成本:以后代码改动或升级,只要一键跑测试,就能知道改动有没有影响其他功能。
- 保证代码质量:养成单元测试的习惯,会促使你把代码设计得更简洁和更容易测试。
简单说:花小时间写单元测试,能为你省下大时间修 Bug。
3. 环境准备
3.1 依赖
在一个常规的 Spring Boot 项目中,只要在 pom.xml
(Maven)或 build.gradle
(Gradle) 里加上:
<!-- 如果是 Maven -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope>
</dependency>
- JUnit 5:最常用的Java测试框架(写
@Test
方法) - Mockito:常用的“模拟”库(用来Mock其他依赖)
- AssertJ / Hamcrest:更好用的断言库
- Spring Test / Spring Boot Test:Spring官方提供的测试辅助
这也就够了,一般不需要额外安装别的。
3.2 项目结构
Spring Boot常见的目录结构(Maven示例):
src├─ main│ └─ java│ └─ com.example.demo│ ├─ DemoApplication.java│ └─ service│ └─ MyService.java└─ test└─ java└─ com.example.demo├─ DemoApplicationTests.java└─ service└─ MyServiceTest.java
src/main/java
放你的业务代码。src/test/java
放你的测试代码。- 通常测试类的包路径要和被测类一致,这样在IDE里能很快对上号,也方便管理。
4. 最最简单的单元测试示例(不依赖Spring)
先从“纯JUnit”说起,最简单的情况就是:
- 我有一个普通的工具类/方法
- 我就想测试它的输入输出对不对
- 不用装载Spring,也不用什么复杂注解
代码示例
假设我们有一个简单的工具类:
public class MathUtil {public static int add(int a, int b) {return a + b;}
}
那我们写一个测试类(路径:src/test/java/.../MathUtilTest.java
):
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;public class MathUtilTest {@Testvoid testAdd() {int result = MathUtil.add(2, 3);Assertions.assertEquals(5, result, "2 + 3 应该等于 5");}
}
@Test
表示这是一个测试方法。Assertions.assertEquals(期望值, 实际值, "提示信息")
用来断言。- 如果断言不通过,测试就失败;通过则测试成功。
运行方法:
- 在 IDE(如 IntelliJ/ Eclipse)里,右键这个
MathUtilTest
类 -> Run 'MathUtilTest' - 或者在命令行里运行
mvn test
(Maven) /gradle test
(Gradle)。
这就是最最基础的单元测试。
5. 在 Spring Boot 里测试 - Service层
当你要测试一个 Service(业务逻辑类) 时,它可能依赖其他Bean(例如 Repository、Dao 等)或者需要 Autowired。在 Spring Boot 里,有两种主要方法:
方法1:纯Mock(不启动Spring Context)
适合只想测试这个Service逻辑本身,不需要真的连数据库,也不需要整个Spring环境。速度最快。
- 用 Mockito 来创建一个假的(Mock)依赖。
- 注入到要测的Service里,这样你可以控制依赖的行为。
示例
UserRepository.java (假设它是个接口,用来访问数据库):
public interface UserRepository {User findByName(String name);// ... 其他方法
}
UserService.java (我们要测这个类):
public class UserService {private UserRepository userRepository;// 通过构造注入依赖public UserService(UserRepository userRepository) {this.userRepository = userRepository;}public String getUserNickname(String name) {User user = userRepository.findByName(name);if (user == null) {return "UNKNOWN";}return user.getNickname();}
}
UserServiceTest.java (测试类,不依赖 Spring):
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Assertions;
import org.mockito.Mockito;
import org.mockito.Mock;
import org.mockito.InjectMocks;
import org.mockito.junit.jupiter.MockitoExtension;
import org.junit.jupiter.api.extension.ExtendWith;@ExtendWith(MockitoExtension.class) // JUnit5 启用Mockito
public class UserServiceTest {@Mockprivate UserRepository userRepository; // Mock出来的依赖@InjectMocksprivate UserService userService; // 要测试的对象,会把上面这个Mock自动注入进来@Testvoid testGetUserNickname_found() {// 1. 假设我们模拟一个“数据库中查到的用户”:User mockUser = new User();mockUser.setName("alice");mockUser.setNickname("AliceWonder");// 2. 定义假数据的返回行为Mockito.when(userRepository.findByName("alice")).thenReturn(mockUser);// 3. 调用被测方法String nickname = userService.getUserNickname("alice");// 4. 断言结果Assertions.assertEquals("AliceWonder", nickname);}@Testvoid testGetUserNickname_notFound() {// 没有设置when,则默认返回nullString nickname = userService.getUserNickname("bob");Assertions.assertEquals("UNKNOWN", nickname);}
}
- 使用了
@Mock
注解声明要模拟的依赖userRepository
。 - 使用了
@InjectMocks
注解告诉 Mockito,要把所有标记@Mock
的对象注入进UserService
。 - 这样就能让
UserService
这个对象在执行时使用模拟过的userRepository
而不访问真实数据库。 - 然后通过
Mockito.when(...)
来定义依赖方法的返回值,用于测试用例的前提条件设置。 - 通过
Assertions
来验证执行结果是否符合预期。
这样就只测 UserService
的逻辑,不会真的访问数据库,也不需要启动Spring,执行很快。
方法2:使用 @SpringBootTest
(集成上下文)
适合你想在测试时使用Spring管理Bean,比如自动注入
@Autowired
,或想测试和别的Bean的连接配置是否正常。
- 在测试类上加
@SpringBootTest
。 - 这样Spring容器会启动,你也能
@Autowired
你的Service或者别的Bean。
示例
UserService.java (类似前面,只不过换成了 Spring注入):
@Service
public class UserService {@Autowiredprivate UserRepository userRepository;public String getUserNickname(String name) {User user = userRepository.findByName(name);if (user == null) {return "UNKNOWN";}return user.getNickname();}
}
UserServiceSpringTest.java (测试类,使用Spring上下文):
@SpringBootTest
public class UserServiceSpringTest {@Autowiredprivate UserService userService;@MockBeanprivate UserRepository userRepository; // @MockBean的意思:Spring 启动时,// 把真正的UserRepository替换成一个Mock对象,// 我们就可以定义它的返回值,而不会真的连数据库@Testvoid testGetUserNickname_found() {User mockUser = new User();mockUser.setName("alice");mockUser.setNickname("AliceWonder");Mockito.when(userRepository.findByName("alice")).thenReturn(mockUser);String result = userService.getUserNickname("alice");Assertions.assertEquals("AliceWonder", result);}@Testvoid testGetUserNickname_notFound() {// 不设置when就会返回nullString result = userService.getUserNickname("unknown");Assertions.assertEquals("UNKNOWN", result);}
}
@SpringBootTest
会启动一个小型Spring环境,让@Autowired
能起作用。@MockBean
可以让你把某个Bean(比如UserRepository
)变成一个模拟对象。- 整体执行依然比较快,但比纯Mock稍微慢一点,因为要先启动Spring容器。
6. 测试 Controller 层
在 Spring Boot 里,Controller 是对外的 HTTP 接口。最常见的两种测试方式:
- 用
@WebMvcTest
+MockMvc
:不启动整个应用,只启动Web层,速度较快;- 用
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+TestRestTemplate
:会真正启动一个内嵌服务器,发起真实HTTP请求,更贴近实际环境。
6.1 @WebMvcTest
示例
@WebMvcTest(UserController.class) // 表示只测 UserController 相关
public class UserControllerTest {@Autowiredprivate MockMvc mockMvc; // 用来模拟HTTP请求@MockBeanprivate UserService userService; // Mock掉Service层@Testvoid testGetUser() throws Exception {// 假设Service返回一个User对象User mockUser = new User();mockUser.setName("test");mockUser.setNickname("TestNick");// 定义service行为Mockito.when(userService.getUserNickname("test")).thenReturn("TestNick");// 用MockMvc发起GET请求,对应Controller的 /user/{name} 路径mockMvc.perform(MockMvcRequestBuilders.get("/user/test")).andExpect(MockMvcResultMatchers.status().isOk()).andExpect(MockMvcResultMatchers.content().string("TestNick"));}
}
@WebMvcTest
只会扫描和加载 Web 层相关的组件,不会启动整个 Spring Boot 应用,测试速度更快。mockMvc.perform(get("/users/1"))
可以模拟一次 GET 请求到/users/1
,并断言返回的 JSON 结构和内容。
6.2 @SpringBootTest
+ TestRestTemplate
如果你想做一个更真实的集成测试(包括 Controller、Service、Repository 等所有层),可以使用 @SpringBootTest
并设置 webEnvironment = RANDOM_PORT
或 DEFINED_PORT
来启动内嵌服务器,然后注入 TestRestTemplate
来请求:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class UserControllerIntegrationTest {@Autowiredprivate TestRestTemplate restTemplate; // 可以真的发请求@Testvoid testGetUser() {// 假设数据库里已经有对应数据,或者你用 @MockBean 替换依赖String result = restTemplate.getForObject("/user/test", String.class);Assertions.assertEquals("TestNick", result);}
}
- 这里会真正启动一个随机端口的Tomcat,然后
TestRestTemplate
真的去请求本地这个/user/test
接口。 - 非常贴近真实部署,只是适合做集成测试,比前面的MockMvc测试稍慢一点。
7. 常见的断言与技巧
7.1 断言
Assertions.assertEquals(期望, 实际)
:断言二者相等。Assertions.assertTrue(条件)
:断言条件为真。Assertions.assertThrows(异常类型, 代码块)
:断言执行代码块会抛出指定异常。
例如:
@Test
void testThrowException() {Assertions.assertThrows(IllegalArgumentException.class, () -> {// 假设调用了一个会抛出异常的方法someMethod(null);});
}
7.2 Mock时常用的 Mockito 方法
Mockito.when( mockObj.方法(...) ).thenReturn(返回值);
Mockito.when( mockObj.方法(...) ).thenThrow(异常);
Mockito.verify( mockObj, Mockito.times(1) ).某方法(...)
; // 验证是否调用了某方法
8. 测试运行与整合
8.1 在本地IDE里运行
- 右键单个测试类或测试方法 -> Run
- 或者在项目主目录运行
mvn test
/gradle test
8.2 与持续集成(CI)整合
- 在 Jenkins、GitLab CI、GitHub Actions 等环境里,一般只要执行
mvn test
或gradle test
就可以跑所有测试用例。 - 如果测试全部通过,就说明代码基本没问题;如果测试挂了,说明你这次提交的改动有Bug或者破坏了原有逻辑。
9. 流程小结(简版“使用指南”)
-
新手首次写单元测试:
- 在
src/test/java
下创建和源代码同包路径的测试类:XXXTest.java
。 - 在类里加
@Test
注解的方法,里面写Assertions.assertXXX(...)
。 - 右键运行,看输出是否通过。
- 在
-
要测Service逻辑,但不想连数据库:
- 在测试类上写:
@ExtendWith(MockitoExtension.class) public class MyServiceTest {@Mockprivate MyRepository myRepository;@InjectMocksprivate MyService myService;... }
- 用
Mockito.when(...)
来模拟依赖。 - 用
assertEquals(...)
来判断结果。
- 在测试类上写:
-
要测Service逻辑,并用Spring上下文:
- 在测试类上加
@SpringBootTest
。 - 注入 Service:
@Autowired private MyService myService;
- 如果你不想真的连数据库,那就用
@MockBean MyRepository myRepository;
- 在测试类上加
-
要测Controller:
- 用
@WebMvcTest(MyController.class)
+@MockBean MyService myService;
+MockMvc
做单元测试,速度较快; - 或者用
@SpringBootTest(webEnvironment = ... )
+TestRestTemplate
做近似真实的集成测试。
- 用
10. 其他常见问题
- 测试和生产环境的配置冲突了怎么办?
- 可以在
application-test.yml
里放测试专用配置,然后在测试时用spring.profiles.active=test
。
- 可以在
- 需要数据库的测试怎么办?
- 可以用
@DataJpaTest
+内存数据库(比如 H2),只测JPA相关逻辑,不影响真数据库。
- 可以用
- 想看覆盖率怎么办?
- 可以集成 Jacoco 插件,跑
mvn test
后生成覆盖率报告,看你的测试是不是覆盖到了主要逻辑。
- 可以集成 Jacoco 插件,跑
- 测试很慢怎么办?
- 如果你的逻辑不是必须要Spring,就尽量用纯Mock,不用
@SpringBootTest
。 - 如果只是测Controller,就用
@WebMvcTest
,不要启动全部。
- 如果你的逻辑不是必须要Spring,就尽量用纯Mock,不用