springboot项目测试

测试必备

配置信息

Spring Boot 提供了 spring-boot-starter-test 起步依赖,它集成了测试所需的主流框架。

xml 复制代码
<dependencies>
    <!-- Spring Boot Test 起步依赖(包含 JUnit 5、Mockito、AssertJ、Hamcrest 等) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    
    <!-- Testcontainers(用于集成测试中的真实数据库/中间件) -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

核心库介绍

测试流程

一个健康的测试套件遵循测试金字塔原则:

单元测试(约占 70%) :快速、隔离,不启动 Spring 上下文

集成/切片测试(约占 20%) :启动部分 Spring 上下文

端到端测试(约占 10%) :启动完整应用

核心原则:能用纯单元测试就不用切片测试,能用切片测试就不用 @SpringBootTest。

单元测试

单元测试不启动 Spring 上下文,只测试单个类的业务逻辑,依赖通过 Mockito 模拟。

案例

被测试代码

java 复制代码
// UserService.java
@Service
public class UserService {
    private final UserRepository userRepository;
    
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
    
    public User getUserById(Long id) {
        return userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException("User not found: " + id));
    }
    
    public User createUser(User user) {
        if (user.getEmail() == null || user.getEmail().isEmpty()) {
            throw new IllegalArgumentException("Email is required");
        }
        return userRepository.save(user);
    }
}

单测代码

java 复制代码
// UserServiceTest.java

// junit5
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
// mock
import static org.mockito.Mockito.*;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)  // 启用 Mockito 支持
// MockitoExtension 是 JUnit 5 与 Mockito 的桥接扩展:
// 1. 自动初始化 @Mock 和 @InjectMocks 注解的字段,无需手动调用 MockitoAnnotations.openMocks()
// 2. 在测试方法执行前自动完成 Mock 对象的创建与依赖注入
// 3. 测试结束后自动校验 Mock 对象的调用(配合 verify 使用)
// 4. 默认启用严格模式(STRICT_STUBS),避免无用的打桩(unnecessary stubbing)导致测试误判
@DisplayName("UserService 单元测试")
class UserServiceTest {

    @Mock  // 创建 Mock 对象
    private UserRepository userRepository;

    @InjectMocks  // 将 Mock 对象注入到被测 Service
    private UserService userService;

    @Test
    @DisplayName("根据 ID 查询用户 - 用户存在时应返回用户")
    void getUserById_WhenUserExists_ShouldReturnUser() {
        // Arrange:准备测试数据
        Long userId = 1L;
        User expectedUser = new User(1L, "张三", "zhangsan@example.com");
        when(userRepository.findById(userId)).thenReturn(Optional.of(expectedUser));

        // Act:执行被测方法
        User actualUser = userService.getUserById(userId);

        // Assert:验证结果
        assertNotNull(actualUser);
        assertEquals("张三", actualUser.getName());
        // 验证 repository.findById 被调用了一次[reference:12]
        verify(userRepository, times(1)).findById(userId);
    }

    @Test
    @DisplayName("根据 ID 查询用户 - 用户不存在时应抛异常")
    void getUserById_WhenUserNotExists_ShouldThrowException() {
        // Arrange
        Long userId = 99L;
        when(userRepository.findById(userId)).thenReturn(Optional.empty());

        // Act & Assert
        assertThrows(UserNotFoundException.class, 
            () -> userService.getUserById(userId));
        verify(userRepository).findById(userId);
    }

    @Test
    @DisplayName("创建用户 - 邮箱为空时应抛异常")
    void createUser_WhenEmailEmpty_ShouldThrowException() {
        // Arrange
        User invalidUser = new User(null, "李四", "");

        // Act & Assert
        assertThrows(IllegalArgumentException.class, 
            () -> userService.createUser(invalidUser));
        
        // 验证 repository.save 从未被调用
        verify(userRepository, never()).save(any());
    }
}

切片测试(特定层)

切片测试只加载应用的一部分 Bean,比 @SpringBootTest 快得多。

controller层

只加载 MVC 相关组件(Controller、Jackson、安全配置等),Service 层用 @MockitoBean 模拟。

案例-@WebMvcTest

待测controller

java 复制代码
// UserController.java

@RestController
// 告诉 Spring "这是一个专门用来写后端 API 接口的类,返回的数据直接变成 JSON 给前端"。
// 相当于 @Controller + @ResponseBody。
@RequestMapping("/api/users")
// 定义这个控制器下所有接口的公共基础路径(URL 前缀)。
// 表示该类中所有具体的接口方法,其访问 URL 都会拼接上这个前缀
@RequiredArgsConstructor
// Lombok 库
// 自动生成包含 final 字段的构造函数,完美配合 Spring 实现构造器依赖注入。
public class UserController {
    private final UserService userService;
    
    @GetMapping("/{id}")
    public ResponseEntity<User> getUser(@PathVariable Long id) {
        return ResponseEntity.ok(userService.getUserById(id));
    }
    // @PahVariable
    // 从 URL 路径中提取占位符(变量)的值。
    // @PathVariable 获取的是路径里的值:/users/100 -> 获取 100
		// @RequestParam 获取的是问号后的值:/users?id=100 -> 获取 100
    
    @PostMapping
    public ResponseEntity<User> createUser(@Valid @RequestBody User user) {
        return ResponseEntity.status(HttpStatus.CREATED)
            .body(userService.createUser(user));
    }
    // @Valid
    // 触发 Bean Validation(数据校验),自动检查传入的数据是否符合规则
    // @RequesBody 
    // 将 HTTP 请求体(Body)中的 JSON 数据 反序列化(转换)为 Java 对象。
}

// User对象
public class User {
    @NotBlank(message = "用户名不能为空")
    @Size(min = 2, max = 10, message = "用户名长度必须在2-10之间")
    private String username;

    @Email(message = "邮箱格式不正确")
    private String email;
}

测试类

java 复制代码
// UserControllerTest.java
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockitoBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;

@WebMvcTest(UserController.class)  
// 只加载 UserController 相关 Bean
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;  // 用于模拟 HTTP 请求

    @Autowired
    private ObjectMapper objectMapper;

    @MockitoBean  // 模拟 Service 层 Bean
    private UserService userService;

    @Test
    @DisplayName("GET /api/users/1 - 应返回用户 JSON")
    void getUser_ShouldReturnUser() throws Exception {
        // Arrange
        User mockUser = new User(1L, "张三", "zhangsan@example.com");
        when(userService.getUserById(1L)).thenReturn(mockUser);

        // Act & Assert
        mockMvc.perform(get("/api/users/1"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.name").value("张三"));
            // perform 执行这个构建好的请求,并将请求发送到 Spring MVC 的 DispatcherServlet 进行处理
            // .andExpect 表示"期望/断言"返回的结果满足某个条件
    }

    @Test
    @DisplayName("POST /api/users - 应创建用户并返回 201")
    void createUser_ShouldReturnCreated() throws Exception {
        // Arrange
        User requestUser = new User(null, "李四", "lisi@example.com");
        User savedUser = new User(2L, "李四", "lisi@example.com");
        when(userService.createUser(any(User.class))).thenReturn(savedUser);

        // Act & Assert
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(requestUser)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").value(2))
            .andExpect(jsonPath("$.name").value("李四"));
    }
}

Repository 层

只加载 JPA 相关组件(Repository、EntityManager 等),默认使用内嵌内存数据库。

案例 H2内置数据库-@DataJpaTest

待测试类

java 复制代码
// UserRepository.java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
    List<User> findByNameContaining(String keyword);
}

测试类

java 复制代码
// UserRepositoryTest.java
import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;

@DataJpaTest  // 只加载 JPA 相关组件
class UserRepositoryTest {

    @Autowired
    private TestEntityManager entityManager;  // 用于操作测试数据

    @Autowired
    private UserRepository userRepository;

    @Test
    @DisplayName("根据邮箱查询用户 - 应返回匹配的用户")
    void findByEmail_ShouldReturnUser() {
        // Arrange
        User user = new User(null, "王五", "wangwu@example.com");
        entityManager.persist(user);
        entityManager.flush();

        // Act
        Optional<User> found = userRepository.findByEmail("wangwu@example.com");

        // Assert
        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("王五");
    }

    @Test
    @DisplayName("根据关键字模糊查询用户名 - 应返回匹配列表")
    void findByNameContaining_ShouldReturnMatchingUsers() {
        // Arrange
        entityManager.persist(new User(null, "张三丰", "zhang@example.com"));
        entityManager.persist(new User(null, "张无忌", "zhangwj@example.com"));
        entityManager.persist(new User(null, "李四", "lisi@example.com"));
        entityManager.flush();

        // Act
        List<User> users = userRepository.findByNameContaining("张");

        // Assert
        assertThat(users).hasSize(2);
        assertThat(users).extracting(User::getName)
            .containsExactlyInAnyOrder("张三丰", "张无忌");
    }
}

案例 真实数据库-@AutoConfigureTestDatabase

java 复制代码
application-test.yml + @AutoConfigureTestDatabase

测试配置文件

yml 复制代码
# 在 src/test/resources 目录下创建 application-test.yml(或 .properties),配置真实的数据库连接
# src/test/resources/application-test.yml
spring:
  datasource:
    # 真实数据库驱动(以 MySQL 为例)
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/test_db?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
    username: root
    password: 123456
  jpa:
    # 为了让测试SQL看得清楚,开启SQL日志
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        dialect: org.hibernate.dialect.MySQL8Dialect
    hibernate:
      # 测试环境建议 create-drop,每次启动建表,结束删表,保持干净
      ddl-auto: create-drop

测试类

java 复制代码
import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.test.context.ActiveProfiles;


// ★★★★★ 关键点1:禁用内嵌数据库替换,使用配置文件中的真实数据源 内嵌式数据库 @DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
// ★★★★★ 关键点2:激活 test 配置文件,加载 application-test.yml
@ActiveProfiles("test")
@DisplayName("真实MySQL环境下的Repository测试")
class UserRepositoryRealDbTest {

    @Autowired
    private TestEntityManager entityManager; // 用于操作持久化上下文

    @Autowired
    private UserRepository userRepository;

    @Test
    @DisplayName("真实DB测试:保存用户并成功查询")
    void shouldSaveAndFindUserInRealDatabase() {
        // Arrange:创建测试实体
        User user = new User();
        user.setName("真实数据库测试");
        user.setEmail("realdb@example.com");

        // Act:通过 EntityManager 持久化(这里会真实插入到你的 MySQL 中)
        entityManager.persist(user);
        entityManager.flush(); // 强制刷新,触发SQL执行

        // Act:调用 Repository 查询
        User found = userRepository.findByEmail("realdb@example.com").orElse(null);

        // Assert:验证数据确实从真实数据库中查出来了
        assertThat(found).isNotNull();
        assertThat(found.getId()).isNotNull(); // 验证自增ID已回填
        assertThat(found.getName()).isEqualTo("真实数据库测试");
        
        // 注意:由于 ddl-auto 配置为 create-drop,测试结束后表会被删除
        // 如果配置为 update,数据会残留,记得要清理(但测试不建议用 update)
    }
}

集成测试

核心

@SpringBootTest 启动完整的 Spring 应用上下文,用于测试多层之间的集成。

@MockBean 与 @SpyBean 区别

案例-mockmvc

测试类

java 复制代码
@SpringBootTest  // 默认 webEnvironment = MOCK
class UserServiceIntegrationTest {

    @Autowired
    private UserService userService;  // 注入真实的 Service

    @MockitoBean
    private UserRepository userRepository;  // 模拟 Repository

    @Test
    void testGetUser() {
        when(userRepository.findById(1L))
            .thenReturn(Optional.of(new User(1L, "测试", "test@example.com")));
        
        User user = userService.getUserById(1L);
        assertThat(user.getName()).isEqualTo("测试");
    }
}

案例-启动真实容器(RANDOM_PORT)

java 复制代码
@SpringBootTest(classes = xxx.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
//  Spring Boot 测试的核心注解
// 启动真实的 Spring 应用上下文(加载所有 Bean),用于做集成测试。
// classes = 指定启动类,告诉 Spring 从哪个入口加载配置。
// webEnvironment = 启动一个内嵌的 Web 服务器(如 Tomcat),并分配一个随机的可用端口。
@Slf4j
// 这是一个编译时注解,作用是在编译后的 class 文件中自动生成一个名为 log 的日志对象(org.slf4j.Logger),方便后续直接通过log.info/error打印日志
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
// JUnit Jupiter 提供)
// 用于控制测试方法的执行顺序。指明测试类中的测试方法将按照 @Order 注解的值(数字越小越先执行)依次执行。
class xxxControllerTest {

    @LocalServerPort
    // 当 webEnvironment 配置为 RANDOM_PORT 或 DEFINED_PORT 时,Spring 会将内嵌 Web 服务器实际分配的端口号,动态注入到该 int port 字段中。
    private int port;

    @Autowired
    // Spring 的依赖注入标准注解
    private TestRestTemplate restTemplate;

    @Autowired
    @Qualifier("db0StringRedisTemplate")
    // 组合使用,是为了按名称指定注入特定的 RedisTemplate Bean。
    private RedisTemplate<String, String> db0StringRedisTemplate;

    @BeforeEach
    // junit jupiter提供,表示该方法在每个测试方法执行之前都会运行一次。
    void setUp() {}

    @AfterEach
    // junit jupiter提供,表示该方法在每个测试方法执行之后都会运行一次。
    void tearDown() {}

		@Test
    @Order(1)
    void testUpdateBlackAppid_close_success() {
        String url = baseUrl + "/updateBlackAppid?appid={appid}&type={type}";

        ResponseEntity<String> response = restTemplate.getForEntity(url, String.class, testAppid, "close");

        assertEquals(HttpStatus.OK, response.getStatusCode(), "close请求应返回200");
        String body = response.getBody();
        assertNotNull(body, "响应体不应为null");
        assertTrue(body.contains("\"code\":\"000\""),
                "close操作应返回成功code=000,实际响应: " + body);

        Boolean isMember = db0StringRedisTemplate.opsForSet().isMember(APPID_SET_KEY, testAppid);
        assertTrue(Boolean.TRUE.equals(isMember), "close后appid应在Redis中");
        log.info("testUpdateBlackAppid_close_success 通过");
    }
相关推荐
程序员雪球20 分钟前
本地IDEA打断点debug容器
java·开发语言
叫我:松哥22 分钟前
基于Flask的教师评教管理系统,支持学生和教师两种角色登录
数据库·后端·python·数据挖掘·flask
码匠许师傅28 分钟前
【设计模式精讲】2. 设计原则基石:SOLID 与几条关键原则
java·设计模式·log4j
yychen_java33 分钟前
二:Multi-Agent 协作架构与 MCP 协议实战:Java 企业级 AI 智能体进阶指南
java·人工智能·架构
老白干34 分钟前
基于枚举 + 注解的 Java 数据脱敏实践(fastjson 序列化场景)
java·开发语言
SKH.36 分钟前
Linux软件编程(5)线程
java·linux·jvm
JuiceFS39 分钟前
如何通过 S3 和 WebDAV 协议访问 JuiceFS?
运维·人工智能·后端
数据知道44 分钟前
Java 安全审计实战:SSRF、反序列化、SpEL 注入
java·开发语言·安全·网络安全
嗝屁小孩纸1 小时前
通用后端基础能力平台(多模块技术总结与避坑指南)
java