Testcontainers学习

Testcontainers for Java简介

Java的Testcontainers是一个支持JUnit测试的Java库,提供通用数据库,Selenium Web浏览器或任何其他可以在Docker容器中运行的轻量级一次性实例。

Testcontainers 能够让你实现通过编程语言去启动Docker容器,并在程序测试结束后,自动关闭容器。这基本上能解决我们大部分的需求。

使用 TestContainers 这种解决方案还有以下几个优点:

  • 每个Test Group都能像写单元测试那样细粒度地写集成测试,保证每个集成单元的高测试覆盖率。
  • Test Group间是做到依赖隔离的,也就是说它们不共享任何一个Docker容器;假如两个Test Group都要用到Mongo 4.0,会创建两个容器供它们单独使用 。
  • 保证了生产环境和测试环境的一致性,代码部署到线上时不会遇到因为依赖服务接口不兼容而导致的bug 。
  • Test Group可以并行化运行,减少整体测试运行时间。相比较有些 in-memory 的依赖服务实现没有实现很好的资源隔离,比如端口,一旦并行化运行就会出现端口冲突 。
  • 得益于Docker,所有测试都可以在本地环境和 CI/CD环境中运行,测试代码调试和编写就如同写单元测试。

测试容器使以下类型的测试更容易:

  • 数据访问层集成测试:使用 MySQL、PostgreSQL 或 Oracle 数据库的容器化实例来测试数据访问层代码的完全兼容性,但不需要在开发人员的计算机上进行复杂的设置,并且知道您的测试将始终从已知的数据库状态开始。也可以使用任何其他可以容器化的数据库类型。
  • 应用程序集成测试:用于在具有依赖项(如数据库、消息队列或 Web 服务器)的短期测试模式下运行应用程序。
  • UI/验收测试:使用与Selenium兼容的容器化Web浏览器进行自动化UI测试。每个测试都可以获得浏览器的新实例,无需担心浏览器状态、插件变体或自动浏览器升级。您可以获得每个测试会话的视频记录,或者只是测试失败的每个会话。

先决条件

Maven 依赖项

对于核心库,最新的 Maven/Gradle 依赖项如下:

1
2
3
4
5
6
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.18.3</version>
<scope>test</scope>
</dependency>

管理多个测试容器依赖项的版本

若要避免指定每个依赖项的版本,可以使用BOMBill Of Materials

使用 Maven,您可以将以下dependencyManagement添加到您的pom.xml

1
2
3
4
5
6
7
8
9
10
11
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>1.18.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

然后在不指定版本的情况下使用依赖项:

1
2
3
4
5
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<scope>test</scope>
</dependency>

JUnit 4 快速入门

假设我们有一个简单的程序依赖于 Redis,我们想为它添加一些测试。 在我们的虚构程序中,有一个类在 Redis 中存储数据。

RedisBackedCache

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public interface Cache {

void put(String key, Object value);

<T> Optional<T> get(String key, Class<T> expectedClass);
}

public class RedisBackedCache implements Cache {

private final Jedis jedis;

private final String cacheName;

private final Gson gson;

public RedisBackedCache(Jedis jedis, String cacheName) {
this.jedis = jedis;
this.cacheName = cacheName;
this.gson = new Gson();
}

@Override
public void put(String key, Object value) {
String jsonValue = gson.toJson(value);
this.jedis.hset(this.cacheName, key, jsonValue);
}

@Override
public <T> Optional<T> get(String key, Class<T> expectedClass) {
String foundJson = this.jedis.hget(this.cacheName, key);

if (foundJson == null) {
return Optional.empty();
}

return Optional.of(gson.fromJson(foundJson, expectedClass));
}
}

Maven依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<dependencies>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>4.3.1</version>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.18.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.24.2</version>
<scope>test</scope>
</dependency>
</dependencies>

您可以看到一个可能为其编写的示例测试(不使用 Testcontainers):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class RedisBackedCacheIntTestStep0 {

private RedisBackedCache underTest;

@Before
public void setUp() throws Exception {
Jedis jedis = new Jedis("localhost", 6379);

underTest = new RedisBackedCache(jedis, "test");
}

@Test
public void testSimplePutAndGet() {
underTest.put("test", "example");

String retrieved = underTest.get("test");
assertThat(retrieved).isEqualTo("example");
}
}

请注意,现有测试存在问题 - 它依赖于 Redis 的本地安装,这是测试可靠性的危险信号。 如果我们确定每个开发人员和 CI 计算机都安装了 Redis,这可能会起作用,否则会失败。 如果我们尝试并行运行测试,我们也可能会遇到问题,例如测试之间的状态出错或端口冲突。

使用 Testcontainers 改进测试:

让测试容器在我们的测试期间运行 Redis 容器

JUnit 4 规则

1
2
3
4
@Rule
public GenericContainer redis = new GenericContainer(DockerImageName.parse("redis:7.0.5"))
.withExposedPorts(6379);

@Rule注释告诉 JUnit 通知此字段有关测试生命周期中的各种事件。 在这种情况下,我们的规则对象是一个 测试容器GenericContainer ,配置为使用 Docker Hub 中的特定 Redis 映像,并配置为公开端口。

如果我们按原样运行测试,那么无论实际测试结果如何,我们都会看到日志,显示 Testcontainers:

  • 在我们的测试方法运行之前被激活
  • 发现并快速测试了我们的本地 Docker 设置
  • 如有必要,拉取图像
  • 启动一个新容器并等待它准备就绪
  • 测试后关闭并删除容器

确保我们的代码可以与容器通信

在 Testcontainers 之前,我们可能已经将一个地址硬编码到我们的测试中。localhost:6379

Testcontainers 对其启动的每个容器使用随机端口,但可以轻松地在运行时获取实际端口。 我们可以在测试方法中执行此操作,以设置被测试组件:setUp

获取映射端口

1
2
3
4
5
6
String address = redis.getHost();
Integer port = redis.getFirstMappedPort();

// Now we have an address and port for Redis, no matter where it is running
underTest = new RedisBackedCache(address, port);

运行测试!

RedisBackedCacheIntTest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class RedisBackedCacheTest {

@Rule
public GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.0.5"))
.withExposedPorts(6379);
private Cache cache;

@Before
public void setUp() throws Exception {
Jedis jedis = new Jedis(redis.getHost(), redis.getMappedPort(6379));
cache = new RedisBackedCache(jedis, "test");
}
@Test
public void testFindingAnInsertedValue() {
cache.put("foo", "FOO");
Optional<String> foundObject = cache.get("foo", String.class);

assertThat(foundObject.isPresent()).as("When an object in the cache is retrieved, it can be found").isTrue();
assertThat(foundObject.get())
.as("When we put a String in to the cache and retrieve it, the value is the same")
.isEqualTo("FOO");
}
}