Java后端测试

发布于:2025-09-13 ⋅ 阅读:(21) ⋅ 点赞:(0)

一、单元测试

1.1 单元测试基础概念

单元测试是针对软件中最小可测试单元(通常是方法或类)进行检查和验证的过程。在Java后端开发中,我们主要测试Service层的业务逻辑。

为什么需要单元测试?

  • 早期发现代码缺陷

  • 确保代码修改不会破坏现有功能

  • 作为代码文档,展示如何使用被测试代码

  • 促进更好的代码设计(可测试的代码通常结构更好)

常见测试类结构是怎样的?

是的,标准做法是每个业务类(如 UserService),对应一个测试类:

src/
├─ main/
│  └─ java/com/example/service/UserService.java
└─ test/
   └─ java/com/example/service/UserServiceTest.java

在测试类中,你:

  • 创建 @Mock 依赖(如 Mapper)

  • 创建 @InjectMocks 的 Service

  • 编写 @Test 方法,使用断言验证行为

1.2 单元测试完整依赖

如果你使用的是 Spring Boot,可以选择直接引入:

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

这个 starter 已经集成了 JUnit5 + Mockito + AssertJ 等测试依赖,适合大部分项目。

<!-- JUnit5 -->
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.9.3</version>
    <scope>test</scope>
</dependency>

<!-- Mockito 核心 -->
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>5.8.0</version>
    <scope>test</scope>
</dependency>

<!-- Mockito + JUnit5 集成支持(必须加上) -->
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <version>5.8.0</version>
    <scope>test</scope>
</dependency>

mockito-junit-jupiter 这个依赖是为了让你可以使用@ExtendWith(MockitoExtension.class) 与 JUnit5 配合。 如果不使用这种方式就要通过下面这种方式告诉 Mockito“请扫描当前类中的 @Mock、@InjectMocks 注解,创建 Mock 对象并注入”。

@BeforeEach
void setUp() {
    MockitoAnnotations.openMocks(this);
}

1.3 JUnit5 基本用法

1.3.1 基本注解

  • @Test: 标记一个方法为测试方法

  • @BeforeEach: 在每个测试方法前执行

  • @AfterEach: 在每个测试方法后执行

  • @BeforeAll: 在所有测试方法前执行(静态方法)

  • @AfterAll: 在所有测试方法后执行(静态方法)

  • @DisplayName: 为测试类或方法指定显示名称

(1)@BeforeAll 和 @AfterAll

执行时机

  • @BeforeAll:在整个测试类的所有测试方法执行之前运行一次

  • @AfterAll:在整个测试类的所有测试方法执行之后运行一次

静态方法要求:这两个注解标记的方法必须是static,因为它们在类级别执行,不依赖于任何测试实例

典型用途

class DatabaseTest {
    static Connection connection;
    
    @BeforeAll
    static void initDatabase() {
        connection = Database.connect(); // 所有测试共享的昂贵资源
    }
    
    @AfterAll
    static void closeDatabase() {
        connection.close(); // 所有测试完成后清理资源
    }
    
    @Test void testQuery1() { /* 使用connection */ }
    @Test void testQuery2() { /* 使用connection */ }
}

(2)@BeforeEach 和 @AfterEach

执行时机

  • @BeforeEach:在每个测试方法执行之前运行

  • @AfterEach:在每个测试方法执行之后运行

非静态方法:不需要static修饰

典型用途

class CalculatorTest {
    Calculator calculator;
    
    @BeforeEach
    void init() {
        calculator = new Calculator(); // 每个测试前创建新实例
    }
    
    @AfterEach
    void cleanup() {
        calculator.reset(); // 每个测试后清理状态
    }
    
    @Test void testAdd() { /* 使用新实例 */ }
    @Test void testSubtract() { /* 使用新实例 */ }
}

1.3.2 常用断言方法

定义:

断言(assert)是用于判断实际结果是否符合预期结果的“测试判断语句”。

  • 如果断言成功:测试通过 ✅

  • 如果断言失败:测试失败 ❌(会抛出 AssertionFailedError)

为什么断言很重要?

  • 没有断言,只是“运行代码”;

  • 有了断言,才能“验证结果是否正确”。

方法 用途 示例
assertEquals 验证预期值=实际值 assertEquals(10, result)
assertTrue 验证条件为真 assertTrue(list.isEmpty())
assertFalse 验证条件为假 assertFalse(user.isActive())
assertNull 验证对象为null assertNull(error)
assertNotNull 验证对象非null assertNotNull(response)
assertThrows 验证是否抛出指定异常 assertThrows(IllegalArgumentException.class, () → service.method(null))
assertAll 分组执行多个断言 assertAll("用户属性", () → assertEquals("John", user.name), () → assertEquals(30, user.age))

1.3.3 示例:测试工具类方法

class DateUtilsTest {

    @Test
    @DisplayName("测试日期格式化")
    void testFormatDate() {
        LocalDate date = LocalDate.of(2023, 5, 15);
        String expected = "2023-05-15";
        
        assertEquals(expected, DateUtils.formatDate(date));
    }

    @Test
    @DisplayName("测试解析日期")
    void testParseDate() {
        String dateStr = "2023-05-15";
        LocalDate expected = LocalDate.of(2023, 5, 15);
        
        assertEquals(expected, DateUtils.parseDate(dateStr));
    }

    @Test
    @DisplayName("测试非法日期格式")
    void testInvalidDateFormat() {
        assertThrows(DateTimeParseException.class, () -> {
            DateUtils.parseDate("2023/05/15");
        });
    }

    @Test
    @DisplayName("测试计算日期差")
    void testDaysBetween() {
        LocalDate start = LocalDate.of(2023, 5, 10);
        LocalDate end = LocalDate.of(2023, 5, 15);
        
        assertEquals(5, DateUtils.daysBetween(start, end));
    }
}

class ValidationUtilsTest {

    @Test
    @DisplayName("测试邮箱验证")
    void testEmailValidation() {
        assertAll("邮箱验证测试",
            () -> assertTrue(ValidationUtils.isValidEmail("test@example.com")),
            () -> assertTrue(ValidationUtils.isValidEmail("user.name+tag@domain.co")),
            () -> assertFalse(ValidationUtils.isValidEmail("invalid.email")),
            () -> assertFalse(ValidationUtils.isValidEmail("user@.com")),
            () -> assertFalse(ValidationUtils.isValidEmail(null))
        );
    }

    @Test
    @DisplayName("测试手机号验证")
    void testPhoneValidation() {
        assertAll("手机号验证测试",
            () -> assertTrue(ValidationUtils.isValidPhone("13812345678")),
            () -> assertFalse(ValidationUtils.isValidPhone("12345678")),
            () -> assertFalse(ValidationUtils.isValidPhone("138123456789")),
            () -> assertFalse(ValidationUtils.isValidPhone("abc12345678"))
        );
    }
}

1.4 Mockito 使用

Mockito是一个流行的Mock框架,用于创建和配置模拟对象,特别适合测试Service层时模拟Repository/DAO层。

核心概念

  • Mock对象:模拟真实对象的替代品,可以预设行为和返回值

  • Spy对象:部分模拟,对未存根的方法调用真实方法

1.4.1 常用注解

  • @Mock: 创建模拟对象

  • @InjectMocks: 创建实例并自动注入@Mock或@Spy字段

  • @Spy: 创建spy对象

一个测试类中是否只能有一个 @InjectMocks

不是只能有一个,但你必须明确每个要注入的对象,并且不能存在注入冲突。

🟡 多个 @InjectMocks 是可以的,只要不冲突!

@InjectMocks
private UserServiceImpl userService;

@InjectMocks
private OrderServiceImpl orderService;

这种写法是允许的,只要这些 service 所依赖的 mock 字段(@Mock)都能被唯一地识别出来。

❌ 什么时候会冲突?

如果两个 @InjectMocks 修饰的类依赖的是同一个 @Mock,但你没有明确区分,那么 Mockito 就无法判断该把 mock 注入给哪个对象,会报错或行为混乱。

1.4.2 常用方法

表示 when(...) 里的内容 必须是“对 mock 对象的方法调用”而不能是静态方法。

如果想在中调用一个静态方法,必须按照以下方式:

你需要用 try (MockedStatic<...> ignored = ...) 的方式包装调用:

✅ 示例:mock SecurityContextUtil.getUserId()

import org.mockito.MockedStatic;
import org.mockito.Mockito;

@Test
public void testAddFavorite() {
    Long userId = 1L;
    Long resourceId = 1L;

    // Mock 静态方法
    try (MockedStatic<SecurityContextUtil> mockedStatic = Mockito.mockStatic(SecurityContextUtil.class)) {
        mockedStatic.when(SecurityContextUtil::getUserId).thenReturn(userId);

        when(resourceService.checkResourceExist(resourceId)).thenReturn(true);
        when(redisTemplate.execute(any(), any(), any(), any())).thenReturn(1L);

        Boolean result = resourceFavoriteService.addFavorite(resourceId);
        assertTrue(result);
    }
}

⚠ 注意事项

  • mockStatic(...) 返回的是 MockedStatic<T> 类型,必须用 try-with-resources 包裹,否则 mock 不生效;

  • 静态方法的 mock 是线程隔离的,只在 try 块中有效;

  • 不支持 mock final 类或 native 方法;

  • IDE(如 IntelliJ IDEA)有时不能正确识别静态 mock,需要你加 mockito-inline 依赖;

(1)when(...).thenReturn(...) —— 模拟方法返回值

作用:告诉 Mockito:“当这个 mock 对象调用某个方法时,请返回我指定的值”。

默认情况下,mock 对象的方法如果没有用 when(...).thenReturn(...) 指定行为,会返回该方法返回类型的默认值

方法返回类型 默认返回值
boolean false
int 0
Object null
List 空列表/null

示例:

when(userMapper.selectById(1L)).thenReturn(new User(1L, "张三"));

✅ 表示:如果测试代码中调用 userMapper.selectById(1L),就返回一个张三对象,而不会真的访问数据库。

在你自定义的被@Test注解的方法中,在调用包含userMapper.selectById(1L)的service层的方法之前使用这行代码,它会在你调用这个service层的方法中的userMapper.selectById(1L)时进行拦截返回对应的值。

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserMapper userMapper;

    @InjectMocks
    private UserService userService;

    @Test
    void testGetUserName() {
        // 1. 准备阶段:告诉 mock 对象该如何响应
        when(userMapper.selectById(1L))
            .thenReturn(new User(1L, "张三"));

        // 2. 执行阶段:调用你要测试的业务方法
        String name = userService.getUserName(1L);

        // 3. 断言阶段:验证返回值
        assertEquals("张三", name);

        // 4.(可选)交互验证:确认底层 mapper 方法被调用
        verify(userMapper).selectById(1L);
    }
}

你在 when() 中指定的参数,要与被测代码调用 mock 方法时传入的参数值完全一致,否则不会命中,返回默认值(如 null、false、0)。

想匹配“任意参数”?用参数匹配器:anyXXX()

Mockito 提供了参数匹配器,比如:

匹配器方法 说明
any() 匹配任意类型对象
anyLong() 匹配任意 long 值
anyInt() 匹配任意 int 值
anyString() 匹配任意字符串

🔁 多次调用返回不同结果:

when(service.getValue()).thenReturn("A").thenReturn("B");

第一次调用返回 A,第二次返回 B。


(2)verify(mock).method() —— 验证方法是否被调用

作用:测试代码是否“真的”调用了某个方法。

通常用于验证逻辑流程是否正确,如删除用户时是否调用了数据库删除方法。

示例:

userService.deleteUser(1L);
verify(userMapper).deleteById(1L); // 检查是否调用了 deleteById

(3)verify(mock, times(n)).method() —— 验证方法被调用 n 次

作用:在一些场景中我们期望某个方法被调用多次或指定次数,这时用 times(n)

示例:

userService.batchDelete(Arrays.asList(1L, 2L, 3L));
verify(userMapper, times(3)).deleteById(anyLong());

检查 userMapper.deleteById 是否被调用了 3 次。


(4)any(), anyString(), anyInt() 等参数匹配器

作用:用于设置/验证时对“任意参数”的匹配,不用写死具体参数。

避免你写死具体值,测试更灵活、健壮。

示例1:模拟返回值

when(userMapper.selectById(anyLong())).thenReturn(new User(1L, "默认用户"));

表示无论你传什么 long 类型参数,都返回这个默认用户。

示例2:验证方法调用

verify(userMapper).selectById(anyLong());

表示只要这个方法被调用,不管参数是什么,就算验证通过。


小总结:这些方法的配合使用

场景 使用方法
设置 mock 返回值 when(...).thenReturn(...)
验证方法是否调用 verify(...)
验证调用次数 verify(..., times(n))
模拟任意参数调用 any(), anyString()

1.4.3 示例:UserService测试

在类上一定要使用@ExtendWith(MockitoExtension.class) 告诉 Mockito“请扫描当前类中的 @Mock、@InjectMocks 注解,创建 Mock 对象并注入”。

class UserServiceTest {

    @Mock
    private UserMapper userMapper;

    @InjectMocks
    private UserService userService;

    @Test
    @DisplayName("测试用户登录成功")
    void testLoginSuccess() {
        String username = "testUser";
        String password = "correctPassword";
        String encryptedPassword = PasswordUtil.encrypt(password);
        User mockUser = new User(1L, username, encryptedPassword);
        
        when(userMapper.findByUsername(username)).thenReturn(mockUser);
        
        User result = userService.login(username, password);
        
        assertNotNull(result);
        assertEquals(username, result.getUsername());
        verify(userMapper).findByUsername(username);
    }

    @Test
    @DisplayName("测试用户登录 - 密码错误")
    void testLoginWithWrongPassword() {
        String username = "testUser";
        String correctPassword = "correctPassword";
        String wrongPassword = "wrongPassword";
        User mockUser = new User(1L, username, PasswordUtil.encrypt(correctPassword));
        
        when(userMapper.findByUsername(username)).thenReturn(mockUser);
        
        assertThrows(AuthenticationException.class, () -> {
            userService.login(username, wrongPassword);
        });
    }

    @Test
    @DisplayName("测试用户登录 - 用户不存在")
    void testLoginWithNonExistentUser() {
        String username = "nonExistentUser";
        
        when(userMapper.findByUsername(username)).thenReturn(null);
        
        assertThrows(UserNotFoundException.class, () -> {
            userService.login(username, "anyPassword");
        });
    }
}

二、集成测试

2.1 集成测试 vs 单元测试的区别

维度 单元测试(Unit Test) 集成测试(Integration Test)
测试粒度 测一个类(如 Service) 测多个 Bean 的协作(如 Controller→Service→DB)
是否启动 Spring ❌ 不启动容器 ✅ 启动整个 Spring Boot 容器
依赖注入方式 @Mock + @InjectMocks @Autowired 注入真实 Bean
数据库连接 无数据库 / Mock Mapper 真实数据库(H2 或 MySQL)
测试类上注解 @ExtendWith(MockitoExtension.class) @SpringBootTest

2.2 Testcontainers 原理与依赖

Testcontainers只是在替代本地运行的MySQL和Redis,并非是真实的生产环境(云端)

Testcontainers 启动的 MySQL/Redis 完全是 Docker 容器里跑的实例,和你机器上手动安装的服务 毫无关系

  • Docker 容器里的服务

    • 镜像(例如 mysql:8.0redis:7.0)被拉下来,作为一个隔离的进程组运行在 Docker 守护进程下。

    • 容器文件系统、网络端口、存储卷都与本地安装的服务隔离开来。

  • 本地安装的服务

    • 是你通过系统包管理(apt、yum、brew)或官方安装包安装的,运行在系统的服务管理器(systemd、launchctl)中。

因此,使用 Testcontainers 不会影响不会使用你本地安装的那套 MySQL/Redis;它会新建一个临时、独立、干净的容器环境,测试结束后再把容器删掉。

核心维度 本地安装服务 Testcontainers(推荐测试用)
✅ 测试隔离性 多测试共享服务,易数据污染 每次测试独立容器,自动清理
✅ 配置一致性 手动配置,版本可能不一致 配置写在测试代码中,团队一致
✅ 自动化/CI支持 需手动安装服务,CI不友好 自动拉镜像并启动,完美支持 CI 流程
✅ 启动与销毁 启动慢、需清理 启动快、测试后自动销毁
✅ 版本切换灵活性 安装/切换麻烦 一行代码换版本(如 mysql:8.0
  • CI = Continuous Integration(持续集成),指的是每次代码提交都自动触发构建和测试的流程。Testcontainers + CI 能保证在“无人值守”的环境里也能自动启动依赖服务并跑完测试。 
  • “代码即环境”:你在测试代码里声明要用 mysql:8.0redis:7.0 镜像,确保每个人本地和 CI 上用的都是同一个版本、同一套配置

  • Docker 化一致性:Testcontainers 启动的容器和你生产环境里跑的 Docker 容器环境极为相似,能更早地发现容器化部署时才会出现的问题;

  • 零运维负担:不需要在本机或 CI 节点预先安装 MySQL/Redis,只要 Docker 在,就能“一键启动、测试、销毁”。

2.2.1 原理

Testcontainers 是一个 Java 库,内部使用 Docker 启动临时容器,创建真实环境(MySQL、Redis、RabbitMQ等),用于测试。

  • 启动前自动拉取镜像并运行容器;

  • 容器启动后,通过 @DynamicPropertySource 将连接信息注入到 Spring;

  • 测试完成后,自动销毁容器,保持测试环境干净。

2.2.2 Testcontainers 不是 Spring Boot 的内置依赖,需要你单独添加。

组件 依赖坐标 必选
核心库 org.testcontainers:testcontainers
JUnit org.testcontainers:junit-jupiter
MySQL org.testcontainers:mysql 按需
Redis org.testcontainers:redis 按需
RabbitMQ org.testcontainers:rabbitmq 按需
Kafka org.testcontainers:kafka 按需
<!-- 基础测试依赖 -->
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers</artifactId>
    <version>1.16.3</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.16.3</version>
    <scope>test</scope>
</dependency>

<!-- 按需添加模块 -->
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>mysql</artifactId>
    <version>1.16.3</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>redis</artifactId>
    <version>1.16.3</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>rabbitmq</artifactId>
    <version>1.16.3</version>
    <scope>test</scope>
</dependency>

2.2.3 基础使用方式

@Testcontainers
@SpringBootTest
class MyIntegrationTest {

    // 共享容器(所有测试方法共用)
    @Container
    static final MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");

    // 动态注入配置
    @DynamicPropertySource
    static void registerProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mysql::getJdbcUrl);
        registry.add("spring.datasource.username", mysql::getUsername);
        registry.add("spring.datasource.password", mysql::getPassword);
    }

    @Test
    void testWithRealMySQL() {
        // 使用真实MySQL测试...
    }
}

2.2.4 复用代码结构方式

// 在src/test/java下创建
public abstract class BaseContainerTest {
    @Container
    static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.0")
        .withDatabaseName("test")
        .withUsername("test")
        .withPassword("test");
    
    @Container
    static final RedisContainer REDIS = new RedisContainer("redis:6-alpine");
    
    @DynamicPropertySource
    static void setupContainers(DynamicPropertyRegistry registry) {
        // 公共配置...
    }
}

// 测试类继承即可
class MyTest extends BaseContainerTest {
    // 直接使用已启动的容器
}
  • 容器共享:使用static容器变量让所有测试方法共享同一容器

  • 基类封装:将容器配置放在抽象基类中复用

  • 资源清理

    @AfterEach
    void cleanup() {
        // 清理Redis数据
        redisTemplate.getConnectionFactory().getConnection().flushAll();
    }

 2.2.5 容器复用方法

在全局配置文件中打开复用:

~/.testcontainers.properties(推荐)

testcontainers.reuse.enable=true

或 src/test/resources/testcontainers.properties(也可以)

testcontainers.reuse.enable=true

表示你允许 Testcontainers 启动的容器复用(reuse),即:

  • 不会每次测试都销毁并重新启动 Redis/MySQL/RabbitMQ 容器;

  • 启动速度显著加快(节省 Docker 资源);

  • 尤其适合进行多线程/并发/性能测试。


✅ 开启复用后如何使用:

只要在容器定义时加 .withReuse(true)

@Container
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.0")
    .withReuse(true); // ✅ 显式声明允许复用

@Container
static final RedisContainer REDIS = new RedisContainer("redis:6-alpine")
    .withReuse(true);

✅ 如果不启用复用,会怎样?

  • 每次测试都会启动新容器(30s+延迟),不适合压力测试;

  • Docker 容器过多还可能残留占用资源;

  • 性能测试时每轮初始化都太慢,无法模拟真实高并发场景。

2.3 集成MySQL 

📄 文件结构:

src/
├─ main/
│  └─ resources/
│     └─ application.yml           <-- 正式环境配置(MySQL、Redis)
└─ test/
   └─ resources/
      └─ application-test.yml      <-- 测试环境配置(H2、内存配置)

2.3.1 使用 H2 内存数据库测试

✅ 所需依赖(Maven)

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>2.2.224</version> <!-- 或使用稳定版 -->
    <scope>runtime</scope>
</dependency>

使用 application-test.yml 专用于测试环境

# src/main/resources/application.yml(主配置)
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/prod_db
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: prod_user
    password: ${DB_PASSWORD}

@SpringBootTest
@Transactional
@Rollback
@ActiveProfiles("test")
class UserMapperH2Test {

    @Autowired
    private UserMapper userMapper;

    @Test
    void testInsert() {
        User user = new User();
        user.setUsername("h2User");
        user.setEmail("h2@example.com");

        int result = userMapper.insert(user);
        assertEquals(1, result);
        assertNotNull(user.getId());
    }
}

2.3.2 使用Testcontainers MySQL 数据库测试

# src/test/resources/application-test.yml(测试配置)
spring:
  datasource:
    url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MySQL
    driver-class-name: org.h2.Driver
  sql:
    init:
      schema-locations: classpath:schema.sql
      data-locations: classpath:data.sql
  h2:
    console:
      enabled: true
      path: /h2-console

在集成测试类上统一加上:

@ActiveProfiles("test") // 明确使用测试环境配置 @SpringBootTest

如果不写默认加载 application-test.yml

@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
class UserMapperMySQLTest {

    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
            .withDatabaseName("test")
            .withUsername("test")
            .withPassword("test")
            .withReuse(true);

    @DynamicPropertySource
    static void configure(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mysql::getJdbcUrl);
        registry.add("spring.datasource.username", mysql::getUsername);
        registry.add("spring.datasource.password", mysql::getPassword);
        registry.add("spring.datasource.driver-class-name", mysql::getDriverClassName);
    }

    @Autowired
    private UserMapper userMapper;

    @Test
    void testInsert() {
        User user = new User();
        user.setUsername("tcUser");
        user.setEmail("tc@example.com");

        int result = userMapper.insert(user);
        assertEquals(1, result);
        assertNotNull(user.getId());
    }
}

@Rollback 注解作用:配合 @Transactional 使用,表示测试方法执行完成后回滚事务,不留任何数据痕迹

2.4 集成Redis

2.4.1 使用 Embedded Redis 测试

Embedded Redis依赖

<dependency>
    <groupId>it.ozimov</groupId>
    <artifactId>embedded-redis</artifactId>
    <version>0.7.3</version>
    <scope>test</scope>
</dependency>
特性 Embedded Redis 真实Redis/Testcontainers
启动速度 快(进程内) 较慢(需启动Docker)
功能完整性 有限(非全功能实现) 100%兼容
调试复杂度 简单 需要查看容器日志
适合场景 简单操作验证 需要完整Redis特性测试
@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class RedisEmbeddedTest {

    private RedisServer redisServer;

    @BeforeAll
    void startRedis() throws IOException {
        redisServer = new RedisServer(6379);
        redisServer.start();
    }

    @AfterAll
    void stopRedis() {
        redisServer.stop();
    }

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Test
    void testSetGet() {
        redisTemplate.opsForValue().set("key", "val");
        String value = redisTemplate.opsForValue().get("key");
        assertEquals("val", value);
    }
}

2.4.2 使用Testcontainers Redis 测试

@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
class RedisTestcontainersTest {

    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:7.0")
            .withExposedPorts(6379)
            .withReuse(true);

    @DynamicPropertySource
    static void configure(DynamicPropertyRegistry registry) {
        registry.add("spring.redis.host", redis::getHost);
        registry.add("spring.redis.port", () -> redis.getMappedPort(6379));
    }

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Test
    void testRedisSetGet() {
        redisTemplate.opsForValue().set("k1", "v1");
        String value = redisTemplate.opsForValue().get("k1");
        assertEquals("v1", value);
    }
}

2.5 集成RabbitMQ

 2.5.1 混用云端 RabbitMQ 和Testcontainers MySQL/Redis

spring:
  rabbitmq:
    host: your-cloud-host.aliyuncs.com
    port: 5672
    username: yourUser
    password: yourPass

@Testcontainers
@SpringBootTest
@ActiveProfiles("test")
class MixedIntegrationTest {

  // ---- 容器化 MySQL ----
  @Container
  static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
    .withReuse(true);

  // ---- 容器化 Redis ----
  @Container
  static GenericContainer<?> redis = new GenericContainer<>("redis:7.0")
    .withExposedPorts(6379)
    .withReuse(true);

  @DynamicPropertySource
  static void dynamicProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", mysql::getJdbcUrl);
    registry.add("spring.datasource.username", mysql::getUsername);
    registry.add("spring.datasource.password", mysql::getPassword);

    registry.add("spring.redis.host", redis::getHost);
    registry.add("spring.redis.port", () -> redis.getMappedPort(6379));
    // 注意:rabbitmq 不在这里重写,还是用 application-test.yml 里的云端配置
  }

  @Autowired
  private RabbitTemplate rabbitTemplate;    // 会连接到阿里云的 RabbitMQ

  @Test
  void testCloudRabbitAndLocalDb() {
    // 1)数据库操作走 Testcontainers 提供的 MySQL 容器
    // 2)缓存操作走 Testcontainers 提供的 Redis 容器
    // 3)消息操作走阿里云 RabbitMQ
    Object msg = rabbitTemplate.receiveAndConvert("cloud.test.queue");
    // ...
  }
}
  • Testcontainers 只管理那些你用 @Container 启动的服务

  • 对于没有容器化声明的组件(如上例的 RabbitMQ),Spring 还是会按配置文件(application-test.ymlapplication.yml)去连接阿里云。

  • 这样就可以既用本地 Docker 容器来做数据库/缓存的隔离测试,又用云端实例来做消息队列的功能联调。

 2.5.2 使用 Testcontainers 启动 RabbitMQ 容器

import org.junit.jupiter.api.Test;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.testcontainers.containers.RabbitMQContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;

import static org.junit.jupiter.api.Assertions.assertEquals;

@Testcontainers
@SpringBootTest
@ActiveProfiles("test")
class RabbitMQTestContainersTest {

    @Container
    static final RabbitMQContainer rabbitMQ =
        new RabbitMQContainer(DockerImageName.parse("rabbitmq:3.10-management"))
            .withExposedPorts(5672, 15672)
            .withReuse(true);

    @DynamicPropertySource
    static void rabbitProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.rabbitmq.host", rabbitMQ::getHost);
        registry.add("spring.rabbitmq.port", rabbitMQ::getAmqpPort);
        registry.add("spring.rabbitmq.username", rabbitMQ::getAdminUsername);
        registry.add("spring.rabbitmq.password", rabbitMQ::getAdminPassword);
    }

    @Autowired
    private RabbitTemplate rabbitTemplate;

    @Test
    void testSendAndReceive() {
        String queue = "tc.test.queue";
        // 在容器中声明队列
        rabbitTemplate.execute(channel -> {
            channel.queueDeclare(queue, false, false, false, null);
            return null;
        });

        // 发送并接收消息
        rabbitTemplate.convertAndSend(queue, "hello-tc");
        Object received = rabbitTemplate.receiveAndConvert(queue);

        assertEquals("hello-tc", received);
    }
}