Skip to content

H2内存数据库和Testcontainers

本文介绍如何使用H2内存数据库和Testcontainers来进行数据库模拟。

1. 环境搭建

先搭建一个多数据源的Spring Boot测试环境。

1.1 数据库初始化

首先在Docker中启动两个数据库:

bash
docker run -d -p 3307:3306 -e MYSQL_ROOT_PASSWORD=abc123 --name stu-ds mysql:8.4
bash
docker run -d -p 3308:3306 -e MYSQL_ROOT_PASSWORD=abc123 --name user-ds mysql:8.4

分别初始化数据库:

sql
create database if not exists stu;
USE `stu`;

CREATE TABLE IF NOT EXISTS `stu` (
    `id`          BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键',
    `name`        VARCHAR(64)  NOT NULL DEFAULT '' COMMENT '姓名',
    `age`         INT          NULL DEFAULT NULL COMMENT '年龄',
    `create_time` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `update_time` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='学生';

insert into stu.stu(name,age) values('张三',18),('李四',20);
sql
create database if not exists user;
USE `user`;

CREATE TABLE IF NOT EXISTS `user` (
    `id`          BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键',
    `username`    VARCHAR(64)  NOT NULL COMMENT '登录名',
    `nickname`    VARCHAR(64)  NOT NULL DEFAULT '' COMMENT '昵称',
    `create_time` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `update_time` DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户';

INSERT INTO user.user(username, nickname) values('张三','zs'),('李四','ls');

1.2 项目初始化

新建Spring Boot项目,引入以下依赖:

image-20260405120655904

项目配置文件application-ds.yml内容如下:

yaml
spring:
  datasource:
    dynamic:
      primary: stu
      strict: true
      datasource:
        stu:
          url: jdbc:mysql://127.0.0.1:3307/stu?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
          username: root
          password: abc123
          driver-class-name: com.mysql.cj.jdbc.Driver
        user:
          url: jdbc:mysql://127.0.0.1:3308/user?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
          username: root
          password: abc123
          driver-class-name: com.mysql.cj.jdbc.Driver

application.yml内容如下:

yaml
spring:
  application:
    name: demo
  profiles:
    active: ds

server:
  port: 8888

项目结构如下:

image-20260405120818780

注意在UserMapper中切换数据源:

java
@Mapper
@DS("user")
public interface UserMapper extends BaseMapper<User> {
}

1.3 项目演示

image-20260405122055201

image-20260405122108395

2. H2

H2 是一个用 Java 编写的轻量级、开源的嵌入式关系型数据库管理系统 (RDBMS),支持内存和文件存储模式。它以高性能、支持标准 SQL、JDBC API 和内置 Web 控制台著称。常用于 Java 应用程序的单元测试、开发环境或作为小型应用的嵌入式数据库。

2.1 环境配置

本小节介绍如何使用H2作为内存数据库,进行单元测试。

首先引入依赖:

xml
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>

然后在src/test/resource下新建application-test-ds.yml文件,内容如下:

yaml
spring:
  datasource:
    dynamic:
      primary: stu
      strict: true
      datasource:
        stu:
          url: jdbc:h2:mem:stu;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;NON_KEYWORDS=USER
          username: sa
          password:
          driver-class-name: org.h2.Driver
          init:
            schema: classpath:sql/stu-schema.sql
            data: classpath:sql/stu-data.sql
        user:
          url: jdbc:h2:mem:user;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;NON_KEYWORDS=USER
          username: sa
          password:
          driver-class-name: org.h2.Driver
          init:
            schema: classpath:sql/user-schema.sql
            data: classpath:sql/user-data.sql

在数据源配置中,我们使用H2内存数据库代替了MySQL,连接串如下:

txt
jdbc:h2:mem:stu;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;NON_KEYWORDS=USER
  • jdbc:h2:mem:stu;:表示在内存中创建一个名为stu的数据库;
  • MODE=MySQL:开启 MySQL 兼容模式,允许使用反引号 ,识别 UNSIGNEDAUTO_INCREMENT 等 MySQL 特有语法;
  • DB_CLOSE_DELAY=-1:禁止数据库随连接关闭而销毁。在内存模式下,如果连接池回收了最后一个连接,数据会消失。-1 确保数据库在整个 JVM 运行期间持续存在;
  • DATABASE_TO_LOWER=TRUE:强制库名/表名小写。H2 默认会将 user 转为 USER。开启此项后,它能匹配 MySQL 默认的小写习惯,避免 Table not found
  • CASE_INSENSITIVE_IDENTIFIERS=TRUE:标识符大小写不敏感;
  • NON_KEYWORDS=USER:排除关键字冲突;

之后,在配置中,使用init.schemainit.data来初始化数据库:

  • init.schema:初始化数据库表结构;
  • init.data:初始化数据;

例如,stu-schema.sql内容如下:

sql
create schema if not exists stu;
USE `stu`;

-- H2 内存模式通常通过连接 URL 指定数据库,不需要显式 CREATE DATABASE 和 USE
-- 如果脚本必须包含,请确保 H2 版本支持,或在连接 URL 中开启 MySQL 模式

CREATE TABLE IF NOT EXISTS `stu` (
     `id`          BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键',
     `name`        VARCHAR(64)  NOT NULL DEFAULT '' COMMENT '姓名',
     `age`         INT          DEFAULT NULL COMMENT '年龄',
     `create_time` TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    -- H2 不直接支持 MySQL 的 ON UPDATE 语法,通常在代码层面或通过触发器实现
     `update_time` TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
     PRIMARY KEY (`id`)
);

WARNING

注意,H2数据库和MySQL数据库语法不同,还是需要转换一下的。

stu-data.sql内容如下:

sql
insert into stu.stu(name,age) values('张三',18),('李四',20);

src/test/resources下新建application.yml文件,内容如下:

yaml
spring:
  profiles:
    active: test
  config:
    import: application-test-db.yml

logging:
  level:
    # 核心:显示 SQL 脚本执行过程
    org.springframework.jdbc.datasource.init: DEBUG
    # 如果想看到数据库连接的细节
    org.springframework.jdbc.core.JdbcTemplate: DEBUG

2.2 单元测试

环境配好后,我们就可以进行单元测试了:

java
@SpringBootTest
@ActiveProfiles("test")
public class StuServiceTest {

    @Autowired
    private StuService stuService;

    @Test
    void test_list(){
        List<Stu> stuList = stuService.list();

        assertThat(stuList).hasSize(2);
        
    }
}

当然,也可以结合@Sql注解,执行额外的sql:

java
@SpringBootTest
@ActiveProfiles("test")
@Sql(value = {"classpath:test-sql/stu.sql"})
@Transactional
public class StuServiceTest {

    @Autowired
    private StuService stuService;

    @Test
    void test_list(){
        List<Stu> stuList = stuService.list();

        assertThat(stuList)
                .hasSize(3)
                .extracting("name")
                .contains("张三","李四","王五");
    }
}

test-sql/stu.sql内容如下:

sql
insert into stu.stu(name,age) values('王五',18);

2.3 非主数据源事务回滚

如果我们要在单元测试中,对非主数据源执行DML操作,并且希望在测试方法后进行回滚,需要编程式事务手动回滚(不能简单使用@Transactional+@Sql方式执行sql与回滚事务),按照以下方式实现:

java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@ActiveProfiles("test")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class UserServiceTest {

    @Autowired
    private UserService userService;

    @Autowired
    private PlatformTransactionManager transactionManager;

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Test
    @Order(1)
    void test_1() {
        // 1. 强制切换到 user 数据源 (必须在开启事务前执行)
        DynamicDataSourceContextHolder.push("user");

        // 2. 开启事务
        DefaultTransactionDefinition def = new DefaultTransactionDefinition();
        TransactionStatus status = transactionManager.getTransaction(def);

        try {

            // 3. 执行 SQL 脚本 (此时 jdbcTemplate 会处于 user 数据源的当前事务中)
            Resource resource = new ClassPathResource("test-sql/user.sql");
            jdbcTemplate.execute((ConnectionCallback<Void>) conn -> {
                ScriptUtils.executeSqlScript(conn, resource);
                return null;
            });

            // 4. 测试与断言
            List<User> userList = userService.list();

            assertThat(userList)
                    .hasSize(3)
                    .extracting("nickname")
                    .contains("zs","ls","ww");
        }finally {
            // 5. 测试完成后,回滚事务
            transactionManager.rollback(status);

            // 6. 清理数据源上下文,防止污染其他测试
            DynamicDataSourceContextHolder.poll();
        }
    }

    @Test
    @Order(2)
    void test_2(){
        List<User> userList = userService.list();
        assertThat(userList).hasSize(2);
    }

}

2.4 H2控制台

我们也可以在浏览器中查看内存数据库的内容,首先开启控制台:

yaml
spring:
  h2:
    console:
      enabled: true

然后在测试中,需要开启真实WEB环境:

java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)

假设定义的端口为8080

之后,在浏览器中访问http://localhost:8080/h2-console

image-20260405162716459

在JDBC URL中填入YAML配置文件中的URL即可,点击Connect即可连接到控制台:

image-20260405162748131

3. Testcontainers

Testcontainers允许在测试运行时,通过 Docker 自动启动真实的数据库(如 PostgreSQL, MySQL)、消息队列(如 Redis, RabbitMQ)或任何其他服务,从而替代内存数据库(如 H2),确保测试环境与生产环境高度一致。

3.1 简单使用

Testcontainers可以脱离Spring Boot环境,直接与测试框架集成使用。

TIP

注意,如果要使用Testcontainers,需要Docker-API compatible container runtime,也就是说本地要安装有Docker,或者使用Testcontainers Cloud。

下面的例子以 JUnit Jupiter 测试框架为例子,使用Testcontainers启动一个Redis容器,在程序中使用Jedis访问Redis。

首先引入以下依赖(org.junit.jupiter:junit-jupiter已引入):

xml
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers</artifactId>
    <version>2.0.4</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers-junit-jupiter</artifactId>
    <version>2.0.4</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>6.0.0</version>
</dependency>

然后,测试类如下:

java
@Testcontainers
public class RedisInContainerTest {

    @Container
    GenericContainer redis = new GenericContainer(DockerImageName.parse("redis:8.0.2"))
            .withExposedPorts(6379);

    @Test
    void test_01(){
        // 获取容器中的redis的地址
        String host = redis.getHost();
        // 获取容器中的redis的端口
        Integer port = redis.getFirstMappedPort();

        // 通过jedis访问容器中的redis
        JedisPool jedisPool = new JedisPool(new JedisPoolConfig(), host, port);
        try (Jedis jedis = jedisPool.getResource()) {
            jedis.set("hello", "redis");
            System.out.println(jedis.get("hello"));
        }
    }

}

@Testcontainers注解:这个注解是Testcontainers提供的,是JUnit Jupiter扩展,用于在测试的生命周期中管理容器的启停的。

java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(TestcontainersExtension.class)
@Inherited
public @interface Testcontainers {

  	// 如果Docker环境不满足,那么是报测试禁用(扩展中的ExecutionCondition接口作用)还是报测试失败,默认是报告失败
    boolean disabledWithoutDocker() default false;

  	// 如果在测试中定义了多个容器,是否并发启动容器,默认串行
    boolean parallel() default false;
}

TestcontainersExtension就是扩展类,定义如下:

java
public class TestcontainersExtension
    implements BeforeEachCallback, BeforeAllCallback, AfterEachCallback, AfterAllCallback, ExecutionCondition {
}

用于管理标注了@Container注解的字段:

  • 如果@Container字段是静态(static)的,那么在所有测试方法前启动容器,并且在所有测试方法后关闭容器,即实现BeforeAllCallbackAfterAllCallback钩子的作用;
  • 如果@Container字段不是静态的(即实例字段),那么容器会在每个测试方法前启动,在每个测试方法后关闭,即实现BeforeEachCallbackAfterEachCallback钩子的作用;

因此,有了@Testcontainers@Container,测试流程大致如下:

  • 在测试方法运行前激活Testcontainers;
  • 发现并快速检测本地Docker是否可用;
  • 拉回镜像image;
  • 启动容器,并等待容器启动完成;
  • 运行测试方法;
  • 在测试方法运行完成后,关闭并删除容器;

3.2 其他设置

3.2.1 创建容器

Testcontainers的通用容器提供了最大的便利,我们可以使用GenericContainer来创建任何容器,例如:

java
new GenericContainer(DockerImageName.parse("name:tag"))

Testcontainers推荐使用DockerImageName.parse()来创建容器,传参有以下形式:

格式类型示例字符串适用场景
标准(Name:Tag)redis:7-alpine最常用,指定具体版本以保证测试幂等性。
私有仓库my.registry.com/infra/redis:7公司内部镜像源,需确保 Docker 已配置登录权限。
摘要(Digest)redis@sha256:45b...最安全,通过哈希值锁定镜像,防止镜像被覆盖。
本地镜像my-local-image:latest测试自己刚构建好的本地镜像。

3.2.2 网络设置

很多时候,我们需要将容器端口暴露出来,可以使用withExposedPorts()方法:

java
public GenericContainer<?> container = new GenericContainer<>(
    DockerImageName.parse("testcontainers/helloworld:1.1.0")
)
    .withExposedPorts(8080, 8081);

注意暴露的端口是容器的端口,而不是本机映射的端口。

为了避免端口冲突,Testcontainers会将暴露出来的容器端口随机映射到本机空闲端口上,因此,在容器启动后,需要使用以下方式获取本机映射的端口:

java
Integer firstMappedPort = container.getMappedPort(8080);
Integer secondMappedPort = container.getMappedPort(8081);

如果一个容器只暴露一个端口,那么可以使用以下方式获取映射端口:

java
Integer firstMappedPort = container.getFirstMappedPort();

获取容器IP地址可以使用以下方法(如果Docker是在本机,返回localhost):

java
String ipAddress = container.getHost();

Testcontainers也支持新建网络,多个容器在同一个网络中启动,之后,这些容器的网络请求,就可以更简单了:

java
try (
    Network network = Network.newNetwork();
    GenericContainer<?> foo = new GenericContainer<>(TestImages.TINY_IMAGE)
        .withNetwork(network)
        .withNetworkAliases("foo")
        .withCommand(
            "/bin/sh",
            "-c",
            "while true ; do printf 'HTTP/1.1 200 OK\\n\\nyay' | nc -l -p 8080; done"
        );
    GenericContainer<?> bar = new GenericContainer<>(TestImages.TINY_IMAGE)
        .withNetwork(network)
        .withCommand("top")
) {
    foo.start();
    bar.start();

    String response = bar.execInContainer("wget", "-O", "-", "http://foo:8080").getStdout();
    assertThat(response).as("received response").isEqualTo("yay");
}

3.2.3 执行命令

默认情况下,Testcontainers 会执行 Docker 镜像中 Dockerfile 定义的 CMD 指令。如果想在启动时覆盖它,可以使用 .withCommand()

java
public GenericContainer<?> redisWithCustomPort = new GenericContainer<>(DockerImageName.parse("redis:6-alpine"))
        .withCommand("redis-server --port 7777");

相当于 docker run &lt;image&gt; &lt;command&gt;。一旦使用了 withCommand,镜像原本默认的启动逻辑会被完全替换。

如果要在启动了的容器中执行命令,我们可以使用方法execInContainer(),例如:

java
// 在容器内创建一个文件
container.execInContainer("touch", "/somefile.txt");

// 执行 ls 命令并获取结果
Container.ExecResult lsResult = container.execInContainer("ls", "-al", "/");

// 获取输出信息
String stdout = lsResult.getStdout();   // 标准输出
String stderr = lsResult.getStderr();   // 错误输出
int exitCode = lsResult.getExitCode();  // 退出状态码(0 通常表示成功)

类似于在终端使用的 docker exec 命令。

3.2.4 设置环境变量

许多镜像(如 MySQL, Postgres, Kafka)通过环境变量来控制初始化行为(如设置密码、创建初始数据库等)。

可以在启动容器时,通过withEnv()方法设置:

java
new GenericContainer(...)
    .withEnv("API_TOKEN", "foo")
    .withEnv("DB_PASSWORD", "secret");

相当于 docker run -e API_TOKEN=foo

3.2.5 文件复制

可以在容器启动时,将本机的文件复制到容器中:

java
GenericContainer<?> container = new GenericContainer<>(TestImages.TINY_IMAGE)
    .withCommand("sleep", "3000")
    .withCopyFileToContainer(
        MountableFile.forClasspathResource("/mappable-resource/"),
        "directoryInContainer"
    )

也可以通过Transferable,直接写入文件内容,设置文件权限:

java
GenericContainer<?> container = new GenericContainer<>(TestImages.TINY_IMAGE)
    .withStartupCheckStrategy(new NoopStartupCheckStrategy())
    .withCopyToContainer(Transferable.of("test", 0777), "/tmp/test")
    .waitingFor(new WaitForExitedState(state -> state.getExitCodeLong() > 0))
    .withCommand("sh", "-c", "ls -ll /tmp | grep '\\-rwxrwxrwx\\|test' && exit 100")

在容器启动后,我们也可以从容器中复制文件到主机:

java
container.copyFileFromContainer(directoryInContainer + fileName, destinationOnHost);

3.3 模块

Testcontainers模块是预先定义好的容器,使用更方便,模块列表参考:https://testcontainers.com/modules/

例如,如果我们想直接使用Redis容器,可以添加以下依赖:

xml
<dependency>
    <groupId>com.redis</groupId>
    <artifactId>testcontainers-redis</artifactId>
    <version>2.2.2</version>
    <scope>test</scope>
</dependency>

然后直接使用以下容器:

java
RedisContainer redis = new RedisContainer(DockerImageName.parse("redis:8.0.2"));
redis.start();

3.4 SpringBoot中使用

在Spring Boot项目中,我们也可以使用Testcontainers来进行测试。

首先引入以下依赖:

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
  	<version>${latest}</version>
    <scope>test</scope>
</dependency>

我们可以把容器当作Bean来对待:

java
@TestConfiguration(proxyBeanMethods = false)
public class MyTestConfiguration {

	@Bean
	@ServiceConnection("redis")
	public GenericContainer<?> redisContainer() {
		GenericContainer<?> container = new GenericContainer<>(DockerImageName.parse("redis:8.0.2"))
				.withExposedPorts(6379);
		return container;
	}

}

@ServiceConnection就是由spring-boot-testcontainers依赖提供的,主要作用是容器生命周期管理与自动配置:

  • 容器生命周期管理:当把容器定义为Bean后,通过@ServiceConnection表明该Bean是容器,此时,Spring会管理容器Bean的生命周期:

    • 容器Bean会在其他Bean之前创建和启动;
    • 容器Bean会在其他Bean销毁之后停止;
  • 自动配置:通常情况下,在Spring Boot中使用Redis等服务,我们要在配置文件application.yml中配置Redis的地址、密码等信息,现在使用了Testcontainers,Redis服务是在测试运行前启动的,端口号是随机的,应该如何配置呢?@ServiceConnection提供了自动配置机制。

    在Spring Boot 3.1中,引入了ConnectionDetails接口:

    java
    public interface ConnectionDetails {
    
    }

    这个接口用来提供有关容器的信息,例如主机名、动态端口、用户名、密码等。

    对于不同的容器,有不同的实现,如果是Redis容器,那么Spring提供的实现是RedisContainerConnectionDetails

    Spring Boot 3.1 重构了配置查找底层逻辑。以前,Spring 总是直接读取 application.yml 中的属性(如 spring.datasource.url)。现在,Spring 会先检查 IoC 容器中是否存在 ConnectionDetails 的 Bean,如果存在,会先从 ConnectionDetails中获取信息进行自动配置。

默认情况下,Container.getDockerImageName().getRepository()的返回值用来决定创建哪个ConnectionDetails,如下,@ServiceConnection用在静态字段上时:

java
@Testcontainers
@SpringBootTest
public class RedisInContainerTest {

    @Container
    @ServiceConnection
    static GenericContainer redis = new GenericContainer(DockerImageName.parse("redis:8.0.2"))
            .withExposedPorts(6379);

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Test
    void test_02(){
        stringRedisTemplate.opsForValue().set("hello", "spring redis");
        String s = stringRedisTemplate.opsForValue().get("hello");
        System.out.println(s);

    }
}

当在静态字段上时,可以通过反射获取到容器对象,然后调用Container.getDockerImageName().getRepository()决定创建哪个ConnectionDetails,最终自动配置过程就能获取到容器信息,完成相关组件的配置。

如果@ServiceConnection@Bean一起在方法上时,并且返回值不是强类型容器(例如RedisContainer),必须指定name属性:

java
@TestConfiguration(proxyBeanMethods = false)
public class MyTestConfiguration {

	@Bean
	@ServiceConnection("redis")
	public GenericContainer<?> redisContainer() {
		GenericContainer<?> container = new GenericContainer<>(DockerImageName.parse("redis:8.0.2"))
				.withExposedPorts(6379);
		return container;
	}

}

如果返回值是强类型容器RedisContainer,Spring可以从返回值类型推断出要创建哪个ConnectionDetails

在有的情况下,@ServiceConnection也不能完全生效,例如RedisContainerConnectionDetails没有实现getPassword()方法,如果我们启动的Redis容器需要密码,那么默认的@ServiceConnection就会失效:

java
@Testcontainers
@SpringBootTest
public class RedisInContainerTest {

    @Container
    @ServiceConnection
    static GenericContainer redis = new GenericContainer(DockerImageName.parse("redis:8.0.2"))
            .withExposedPorts(6379)
            .withCommand("redis-server --requirepass 123");

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Test
    void test_02(){
        stringRedisTemplate.opsForValue().set("hello", "spring redis");
        String s = stringRedisTemplate.opsForValue().get("hello");
        System.out.println(s);

    }
}

以上结果:

Caused by: io.lettuce.core.RedisCommandExecutionException: NOAUTH HELLO must be called with the client already authenticated, otherwise the HELLO <proto> AUTH <user> <pass> option can be used to authenticate the client and select the RESP protocol version at the same time

此时,我们可以使用@DynamicPropertySourcehttps://docs.spring.io/spring-framework/reference/testing/testcontext-framework/ctx-management/dynamic-property-sources.html

java
@Testcontainers
@SpringBootTest
public class RedisInContainerTest {

    @Container
    static GenericContainer redis = new GenericContainer(DockerImageName.parse("redis:8.0.2"))
            .withExposedPorts(6379)
            .withCommand("redis-server --requirepass 123");

    @DynamicPropertySource
    static void redisProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.data.redis.host", redis::getHost);
        registry.add("spring.data.redis.port", redis::getFirstMappedPort);
        registry.add("spring.data.redis.password", () -> "123");
    }

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Test
    void test_02(){
        stringRedisTemplate.opsForValue().set("hello", "spring redis");
        String s = stringRedisTemplate.opsForValue().get("hello");
        System.out.println(s);

    }
}

也可以如下配置:

java
@TestConfiguration(proxyBeanMethods = false)
public class MyTestConfiguration {

	@Bean
	public GenericContainer<?> redisContainer() {
		GenericContainer<?> container = new GenericContainer<>(DockerImageName.parse("redis:8.0.2"))
				 .withCommand("redis-server --requirepass abc")
				.withExposedPorts(6379);
		return container;
	}

	@Bean
	DynamicPropertyRegistrar apiPropertiesRegistrar(GenericContainer redisContainer) {
		return registry -> {
			registry.add("spring.data.redis.host", redisContainer::getHost);
			registry.add("spring.data.redis.port", redisContainer::getFirstMappedPort);
			registry.add("spring.data.redis.password", () -> "abc");
		};
	}
}
java
@SpringBootTest
@Import(MyTestConfiguration.class)
public class RedisInContainer2Test {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Test
    void test_02(){
        stringRedisTemplate.opsForValue().set("hello", "spring redis");
        String s = stringRedisTemplate.opsForValue().get("hello");
        System.out.println(s);
    }

}

因此,使用@DynamicPropertySource可以配置多数据源MySQL。

3.5 多数据源

使用Testcontainers模拟多个MySQL数据库,引入依赖:

xml
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>testcontainers-mysql</artifactId>
  <version>2.0.1</version>
  <scope>test</scope>
</dependency>

配置文件:

yaml
spring:
  datasource:
    dynamic:
      primary: stu
      strict: false

3.5.1 案例一

java
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;

@Testcontainers
public class ContainerBase {

    @Container
    static MySQLContainer stuMysql = new MySQLContainer<>(DockerImageName.parse("mysql:8.4"))
            .withDatabaseName("stu")
            .withUsername("root")
            .withPassword("abc123");

    @Container
    static MySQLContainer userMysql =new MySQLContainer<>(DockerImageName.parse("mysql:8.4"))
            .withDatabaseName("user")
                .withUsername("root")
                .withPassword("abc123");

    @DynamicPropertySource
    static void bind(DynamicPropertyRegistry registry){
        registry.add("spring.datasource.dynamic.datasource.stu.url",
                () -> String.format("jdbc:mysql://%s:%d/stu?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true",
                        stuMysql.getHost(), stuMysql.getMappedPort(3306)));
        registry.add("spring.datasource.dynamic.datasource.stu.username", stuMysql::getUsername);
        registry.add("spring.datasource.dynamic.datasource.stu.password", stuMysql::getPassword);
        registry.add("spring.datasource.dynamic.datasource.stu.driver-class-name", () -> "com.mysql.cj.jdbc.Driver");
        registry.add("spring.datasource.dynamic.datasource.stu.init.schema", () -> "classpath:sql/stu-schema.sql");
        registry.add("spring.datasource.dynamic.datasource.stu.init.data", () -> "classpath:sql/stu-data.sql");

        // 绑定 user 数据源
        registry.add("spring.datasource.dynamic.datasource.user.url",
                () -> String.format("jdbc:mysql://%s:%d/user?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true",
                        userMysql.getHost(), userMysql.getMappedPort(3306)));
        registry.add("spring.datasource.dynamic.datasource.user.username", userMysql::getUsername);
        registry.add("spring.datasource.dynamic.datasource.user.password", userMysql::getPassword);
        registry.add("spring.datasource.dynamic.datasource.user.driver-class-name", () -> "com.mysql.cj.jdbc.Driver");
        registry.add("spring.datasource.dynamic.datasource.user.init.schema", () -> "classpath:sql/user-schema.sql");
        registry.add("spring.datasource.dynamic.datasource.user.init.data", () -> "classpath:sql/user-data.sql");

    }
}
java
@SpringBootTest
@ActiveProfiles("test")
@Sql(value = {"classpath:test-sql/stu.sql"})
@Transactional
public class StuServiceTest extends ContainerBase {

    @Autowired
    private StuService stuService;

    @Test
    void test_list(){
        List<Stu> stuList = stuService.list();

        assertThat(stuList)
                .hasSize(3)
                .extracting("name")
                .contains("张三","李四","王五");
    }
}

3.5.2 案例二

java
import com.baomidou.dynamic.datasource.creator.DataSourceProperty;
import com.baomidou.dynamic.datasource.creator.DatasourceInitProperties;
import com.baomidou.dynamic.datasource.creator.DefaultDataSourceCreator;
import com.baomidou.dynamic.datasource.provider.DynamicDataSourceProvider;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.testcontainers.mysql.MySQLContainer;
import org.testcontainers.utility.DockerImageName;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@TestConfiguration(proxyBeanMethods = false)
public class MySqlContainersConfiguration {

    // 定义第一个数据源:stu
    @Bean
    public MySQLContainer stuMysql() {
        MySQLContainer container = new MySQLContainer(DockerImageName.parse("mysql:8.4"))
                .withDatabaseName("stu")
                .withUsername("root")
                .withPassword("abc123");
        return container;
    }

    // 定义第二个数据源:user
    @Bean
    public MySQLContainer userMysql() {
        MySQLContainer container =new MySQLContainer(DockerImageName.parse("mysql:8.4"))
                .withDatabaseName("user")
                .withUsername("root")
                .withPassword("abc123");
        return container;
    }

    @Bean
    public DynamicDataSourceProvider testDynamicDataSourceProvider(
            MySQLContainer stuMysql,
            MySQLContainer userMysql,
            DefaultDataSourceCreator dataSourceCreator) {

        return new DynamicDataSourceProvider() {
            @Override
            public Map<String, DataSource> loadDataSources() {
                Map<String, DataSource> dataSourceMap = new HashMap<>();

                // ================= 配置 stu 数据源 =================
                DataSourceProperty stuProp = new DataSourceProperty();
                stuProp.setPoolName("stu");
                stuProp.setDriverClassName("com.mysql.cj.jdbc.Driver");
                stuProp.setUrl(String.format("jdbc:mysql://%s:%d/stu?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true",
                        stuMysql.getHost(), stuMysql.getMappedPort(3306)));
                stuProp.setUsername(stuMysql.getUsername());
                stuProp.setPassword(stuMysql.getPassword());
                DatasourceInitProperties stuInit = new DatasourceInitProperties();
                stuInit.setSchema("classpath:sql/stu-schema.sql");
                stuInit.setData("classpath:sql/stu-data.sql");
                stuProp.setInit(stuInit);

                // 使用 creator 创建并放入 Map
                dataSourceMap.put("stu", dataSourceCreator.createDataSource(stuProp));

                // ================= 配置 user 数据源 =================
                DataSourceProperty userProp = new DataSourceProperty();
                userProp.setPoolName("user");
                userProp.setDriverClassName("com.mysql.cj.jdbc.Driver");
                userProp.setUrl(String.format("jdbc:mysql://%s:%d/user?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true",
                        userMysql.getHost(), userMysql.getMappedPort(3306)));
                userProp.setUsername(userMysql.getUsername());
                userProp.setPassword(userMysql.getPassword());
                DatasourceInitProperties userInit = new DatasourceInitProperties();
                stuInit.setSchema("classpath:sql/user-schema.sql");
                stuInit.setData("classpath:sql/user-data.sql");
                userProp.setInit(userInit);

                // 使用 creator 创建并放入 Map
                dataSourceMap.put("user", dataSourceCreator.createDataSource(userProp));

                return dataSourceMap;
            }
        };
    }
    
}

TIP

注意,使用DynamicDataSourceProvider来加载数据源,如果DynamicPropertyRegistrar会导致DynamicRoutingDataSource拿不到配置,最终导致加载失败。

java
@SpringBootTest
@Import({MySqlContainersConfiguration.class})
@ActiveProfiles("test")
@Sql(value = {"classpath:test-sql/stu.sql"})
@Transactional
public class StuServiceTest {

    @Autowired
    private StuService stuService;

    @Test
    void test_list(){
        List<Stu> stuList = stuService.list();

        assertThat(stuList)
                .hasSize(3)
                .extracting("name")
                .contains("张三","李四","王五");
    }
}

参考资料

[1] https://www.h2database.com/

[2] https://java.testcontainers.org/

[3] https://docs.spring.io/spring-boot/reference/testing/testcontainers.html