TestContainers库在单元测试和集成测试下的应用
使用 Testcontainers 进行单元测试
缘起
在开发的一个项目中,启动比较耗时,而且不想写接口进行集成测试。为了提高测试效率,我发现了 Testcontainers 这个小工具。
什么是 Testcontainers
Testcontainers 是一个开源库,用于提供可丢弃的、轻量级的数据库、消息代理、Web 浏览器或其他任何可以在 Docker 容器中运行的服务的实例。地址:https://testcontainers.com/
前置条件
- 安装 Docker,我使用的是 Windows 下的 Docker Desktop。
依赖配置
这个小工具主要是通过容器启动对中间件的依赖,比如 MySQL。因此,在测试代码里需要初始化一些表和语句。
我这次的测试主要是基于 MySQL 的,因此依赖如下:
testImplementation 'org.testcontainers:testcontainers:1.20.6'
testImplementation 'org.testcontainers:junit-jupiter:1.20.6'
testImplementation 'org.mockito:mockito-core:4.0.0'
testImplementation 'org.testcontainers:mysql:1.20.6'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
测试代码示例
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.junit.jupiter.MockitoExtension;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.impl.NutDao;
import org.nutz.dao.impl.SimpleDataSource;
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 java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.*;
@Testcontainers
@ExtendWith(MockitoExtension.class)
public class UserProductServiceTest {
@Container
public static MySQLContainer<?> mysqlContainer = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb")
.withUsername("testuser")
.withPassword("testpass");
@DynamicPropertySource
static void mysqlDataSource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysqlContainer::getJdbcUrl);
registry.add("spring.datasource.username", mysqlContainer::getUsername);
registry.add("spring.datasource.password", mysqlContainer::getPassword);
}
private Dao dao;
@Mock
private PermissionChecker permissionChecker;
@InjectMocks
private UserProductService userProductService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
SimpleDataSource dataSource = new SimpleDataSource();
dataSource.setJdbcUrl(mysqlContainer.getJdbcUrl());
dataSource.setUsername(mysqlContainer.getUsername());
dataSource.setPassword(mysqlContainer.getPassword());
dao = new NutDao(dataSource);
userProductService.setDao(dao);
userProductService.setPermissionChecker(permissionChecker);
}
@Test
void testGetUserIdsByProductId() {
// 创建表
String createUserTableSql = "CREATE TABLE auth_user (userid BIGINT PRIMARY KEY, name VARCHAR(255))";
String createUserRoleRelationTableSql = "CREATE TABLE auth_userrole_relation (userId BIGINT, roleId BIGINT)";
String createProductTableSql = "CREATE TABLE product_info (id BIGINT PRIMARY KEY)";
dao.execute(Sqls.create(createUserTableSql));
dao.execute(Sqls.create(createUserRoleRelationTableSql));
dao.execute(Sqls.create(createProductTableSql));
// 插入测试数据
String insertUserSql = "INSERT INTO auth_user (userid, name) VALUES (1, 'user1'), (2, 'user2')";
String insertUserRoleRelationSql = "INSERT INTO auth_userrole_relation (userId, roleId) VALUES (1, 15), (2, 16)";
String insertProductSql = "INSERT INTO product_info (id) VALUES (101), (102)";
dao.execute(Sqls.create(insertUserSql));
dao.execute(Sqls.create(insertUserRoleRelationSql));
dao.execute(Sqls.create(insertProductSql));
// 模拟权限检查
Map<String, Boolean> permissionMap = new HashMap<>();
permissionMap.put("101", true);
permissionMap.put("102", false);
when(permissionChecker.checkPermission(anyString(), eq(PermissionEnums.ResourceType.PRODUCT), anyList()))
.thenReturn(permissionMap);
// 调用方法
List<Long> userIds = userProductService.getUserIdsByProductId(101L);
// 验证结果
assertEquals(Arrays.asList(1L, 2L), userIds);
List<Long> userIdsNoPermission = userProductService.getUserIdsByProductId(102L);
assertTrue(userIdsNoPermission.isEmpty());
}
}
遇到的坑
在使用 Testcontainers 库拉取镜像时,总是回退到官方源 https://registry-1.docker.io/v2/
,主要是配置的一些国内源不可用。下面是从网上找到的可用的源:
"registry-mirrors": [
"https://docker.m.daocloud.io/",
"https://huecker.io/",
"https://dockerhub.timeweb.cloud",
"https://noohub.ru/",
"https://dockerproxy.com",
"https://docker.mirrors.ustc.edu.cn",
"https://docker.nju.edu.cn",
"https://xx4bwyg2.mirror.aliyuncs.com",
"http://f1361db2.m.daocloud.io",
"https://registry.docker-cn.com",
"http://hub-mirror.c.163.com"
]
Spring 依赖注入的最佳实践
建议使用 setter 或构造函数注入,字段注入不利于单元测试。
单元测试启动的一些日志记录
2025-03-31 15:00:25 org.testcontainers.images.PullPolicy defaultPolicy
INFO: Image pull policy will be performed by: DefaultPullPolicy()
2025-03-31 15:00:25 org.testcontainers.utility.ImageNameSubstitutor instance
INFO: Image name substitution will be performed by: DefaultImageNameSubstitutor (composite of 'ConfigurationFileImageNameSubstitutor' and 'PrefixingImageNameSubstitutor')
2025-03-31 15:00:25 org.testcontainers.DockerClientFactory getOrInitializeStrategy
INFO: Testcontainers version: 1.20.6
2025-03-31 15:00:25 org.testcontainers.dockerclient.DockerClientProviderStrategy tryOutStrategy
INFO: Found Docker environment with local Npipe socket (npipe:////./pipe/docker_engine)
2025-03-31 15:00:25 org.testcontainers.DockerClientFactory client
INFO: Docker host IP address is localhost
2025-03-31 15:00:25 org.testcontainers.DockerClientFactory client
INFO: Connected to docker:
Server Version: 28.0.1
API Version: 1.48
Operating System: Docker Desktop
Total Memory: 15866 MB
Labels:
com.docker.desktop.address=npipe://\\.\pipe\docker_cli
2025-03-31 15:00:25 org.testcontainers.images.RemoteDockerImage resolve
INFO: Pulling docker image: testcontainers/ryuk:0.11.0. Please be patient; this may take some time but only needs to be done once.
2025-03-31 15:00:25 org.testcontainers.utility.RegistryAuthLocator runCredentialProvider
INFO: Credential helper/store (docker-credential-desktop) does not have credentials for https://index.docker.io/v1/
2025-03-31 15:00:41 com.github.dockerjava.api.async.ResultCallbackTemplate onError
ERROR: Error during callback
com.github.dockerjava.api.exception.InternalServerErrorException: Status 500: {"message":"Get \"https://registry-1.docker.io/v2/\": net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)"}
at org.testcontainers.shaded.com.github.dockerjava.core.DefaultInvocationBuilder.execute(DefaultInvocationBuilder.java:247) ~[testcontainers-1.20.6.jar:1.20.6]
at org.testcontainers.shaded.com.github.dockerjava.core.DefaultInvocationBuilder.lambda$executeAndStream$1(DefaultInvocationBuilder.java:269) ~[testcontainers-1.20.6.jar:1.20.6]
at java.lang.Thread.run(Thread.java:750) ~[?:1.8.0_382]
2025-03-31 15:00:41 org.testcontainers.images.RemoteDockerImage lambda$tryImagePullCommand$1
WARN: Retrying pull for image: testcontainers/ryuk:0.11.0 (104s remaining)