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)

posted @ 2025-03-31 18:08  泛舟瓦尔登湖  阅读(145)  评论(0)    收藏  举报