Delightful integration tests in Rust
Delightful integration tests in Rust
Rust 中令人愉悦的集成测试
When I first learned about testing in Rust I was surprised by the lack of what I consider a basic feature: test setup and teardown. Many other popular testing frameworks have it: jest, pytest fixtures, and so on. 当我初次接触 Rust 测试时,我惊讶地发现它缺少一个我认为是基础的功能:测试的设置(setup)与清理(teardown)。许多其他流行的测试框架都具备此功能,例如 Jest、pytest fixtures 等。
Global setup and teardown are especially useful in integration tests. In integration tests, as well as in similar component tests or end-to-end tests, the code runs against a complete or partial set of the application infrastructure. In this important class of tests we don’t mock out database access or skip message brokers. Setup and teardown are good candidates for provisioning that infrastructure so we can reuse it across multiple tests. 全局设置与清理在集成测试中尤为重要。在集成测试以及类似的组件测试或端到端测试中,代码是在完整或部分的应用程序基础设施上运行的。在这类重要的测试中,我们不会模拟数据库访问或跳过消息代理。设置与清理是配置这些基础设施的理想选择,这样我们就可以在多个测试中复用它们。
But alas, Rust does not provide built-in setup and teardown, and what’s more, by default all tests run concurrently on different threads, so creating and reusing global state is a bit clunky. Seems that writing integration tests in Rust is a miserable experience. Or is it? Note: Some crates provide similar capabilities. For example test-context and rstest.
遗憾的是,Rust 并没有提供内置的设置与清理功能。更糟糕的是,默认情况下所有测试都在不同的线程中并发运行,因此创建和复用全局状态显得有些笨拙。看起来在 Rust 中编写集成测试是一段痛苦的经历。真的是这样吗?注:一些 crate 提供了类似的功能,例如 test-context 和 rstest。
Despite my initial skepticism I now think that integration tests in Rust are actually a delight. The delightful Rust concept i’m refering to is RAII, and the crate I now reach for is testcontainers-rs. The core idea is simple: make Docker container creation easy and ergonomic, and clean containers up automatically. This idea, leveraging RAII for automatic cleanup, removes the need for global setup and teardown and paves a direct path to completely isolated tests that can comfortably run in parallel.
尽管最初持怀疑态度,但我现在认为 Rust 中的集成测试实际上是一种享受。我所指的令人愉悦的 Rust 概念是 RAII(资源获取即初始化),而我现在首选的 crate 是 testcontainers-rs。其核心思想很简单:让 Docker 容器的创建变得简单且符合人体工程学,并自动清理容器。这一利用 RAII 进行自动清理的理念,消除了对全局设置与清理的需求,并为完全隔离且能舒适地并行运行的测试铺平了道路。
Resource Acquisition Is Infrastructure
资源获取即基础设施
Rust heavily uses the RAII (Resource Acquisition Is Initialization) pattern. RAII’s main goal is to automatically clean up memory and resources, removing the need for manual management in many common use cases. This useful pattern can be exploited a bit, and extended to automatically manage out-of-process resources. In our use case we wish to make a Docker container a resource and use Rust’s ownership rules and Drop trait to clean it up automatically.
Rust 大量使用 RAII(资源获取即初始化)模式。RAII 的主要目标是自动清理内存和资源,从而在许多常见用例中无需手动管理。这种有用的模式可以被巧妙地利用,并扩展到自动管理进程外资源。在我们的用例中,我们希望将 Docker 容器视为一种资源,并利用 Rust 的所有权规则和 Drop trait 来自动清理它。
To demonstrate RAII relevance, in this first example we’ll use the bollard crate to start a RabbitMQ Docker container. We’ll create a RabbitMqContainer struct that manages the running container for our test:
为了展示 RAII 的相关性,在第一个示例中,我们将使用 bollard crate 来启动一个 RabbitMQ Docker 容器。我们将创建一个 RabbitMqContainer 结构体来管理测试中运行的容器:
struct RabbitMqContainer {
docker: Docker,
container_id: String,
}
impl RabbitMqContainer {
async fn start() -> anyhow::Result<Self> {
let docker = Docker::connect_with_local_defaults()?;
let container = docker
.create_container(
Option::<CreateContainerOptions>::None,
ContainerCreateBody {
image: Some("rabbitmq:3.8.22-management".to_string()),
exposed_ports: Some(
HashMap::from([("5672/tcp".to_string(), HashMap::new())]),
),
..Default::default()
},
)
.await?;
docker
.start_container(&container.id, Some(StartContainerOptions::default()))
.await?;
Ok(Self {
docker,
container_id: container.id,
})
}
}
Using Drop to cleanup running containers
使用 Drop 清理运行中的容器
Our next step is to implement Drop to stop and remove the container automatically:
下一步是实现 Drop 以自动停止并移除容器:
impl Drop for RabbitMqContainer {
fn drop(&mut self) {
let docker = self.docker.clone();
let container_id = self.container_id.clone();
async_drop(async move {
docker
.remove_container(
&container_id,
Some(RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await
.expect("Failed to remove container");
});
}
}
The async drop caveat
关于异步 Drop 的注意事项
As you can see in the example, stopping a container is an async operation, but async drop is a nightly-only, WIP, feature. At first glance this puts a stick in the spokes of our plan, but we can hack our way out of this. Let’s borrow this helper from testcontainers-rs. The hack runs the cleanup in a different thread and blocks drop until the cleanup is done. Not ideal, but good enough for tests.
如示例所示,停止容器是一个异步操作,但 async drop 目前仅在 nightly 版本中可用,且尚在开发中。乍一看,这阻碍了我们的计划,但我们可以通过一些技巧来解决。让我们借用 testcontainers-rs 中的辅助工具。这个技巧是在另一个线程中运行清理工作,并阻塞 drop 直到清理完成。虽然不够完美,但对于测试来说已经足够了。
We can now write a test that utilizes RabbitMqContainer:
现在我们可以编写一个利用 RabbitMqContainer 的测试:
#[tokio::test]
async fn test_with_rabbitmq() -> anyhow::Result<()> {
let _rabbitmq = RabbitMqContainer::start().await?;
// run your tests over the resources defined in RabbitMqContainer
let rabbitmq_channel = get_rabbitmq_channel("amqp://localhost:5672").await?;
//...
// No need to cleanup manually. Sweet!
Ok(())
}
Drop caveat
Drop 的注意事项
Relying on Drop to clean up resources is pretty sweet, but we need to keep in mind that in some cases drop is not called. A relevant example is forceful program termination using SIGINT (ctrl+c), SIGTERM, SIGKILL, or OOM. Some of these can be caught and handled, some less so. testcontainers-rs Watchdog can mitigate some of these issue.
依赖 Drop 来清理资源非常棒,但我们需要记住,在某些情况下 drop 不会被调用。一个相关的例子是使用 SIGINT (ctrl+c)、SIGTERM、SIGKILL 或 OOM 强制终止程序。其中一些可以被捕获和处理,另一些则较难处理。testcontainers-rs 的 Watchdog 可以缓解其中的一些问题。
testcontainers-rs
testcontainers-rs
Now that we’re familiar with the usefulness of RAII in tests, let’s look at a crate that elevates the previous example and offers a utility I really enjoy: testcontainers-rs.
既然我们已经熟悉了 RAII 在测试中的用处,让我们看看一个将上述示例提升到新高度并提供我非常喜欢的工具的 crate:testcontainers-rs。
Toy example
玩具示例
Examples in posts such as this tend to be simplistic. I’ve tried to create something a bit more realistic than a tiny toy example. So I give you the “Toy analytics” app. This app includes two RabbitMQ message consumers, ToyOrder and ToyReview, and an HTTP server that provides analytical endpoints backed by a Postgres database and a Redis caching layer. You can find the full source code here. As you can see, even this small app requires several infrastructure servers to be created and managed. 这类文章中的示例往往过于简单。我尝试创建一个比微型玩具示例更现实一点的项目。所以我带来了“Toy analytics”应用。该应用包含两个 RabbitMQ 消息消费者(ToyOrder 和 ToyReview),以及一个提供分析端点的 HTTP 服务器,后端由 Postgres 数据库和 Redis 缓存层支持。你可以在此处找到完整源代码。如你所见,即使是这个小应用也需要创建和管理多个基础设施服务器。
Setting up the tests
设置测试
First order of business is to start a RabbitMQ node. To achieve this we will create a RabbitMqImage struct that implements the Image trait. The Image trait allows us to define container properties, such as tag, cmd, mounts and more.
首要任务是启动一个 RabbitMQ 节点。为此,我们将创建一个实现 Image trait 的 RabbitMqImage 结构体。Image trait 允许我们定义容器属性,例如标签(tag)、命令(cmd)、挂载(mounts)等。
pub struct RabbitMqImage;
impl Image for RabbitMqImage {
fn name(&self) -> &str { "rabbitmq" }
fn tag(&self) -> &str { "3.8.22-management" }
}
We can now implement a TestEnv struct. TestEnv will hold and manage all infrastructure needed for our tests. Just rabbitMq for now.
现在我们可以实现一个 TestEnv 结构体。TestEnv 将持有并管理我们测试所需的所有基础设施。目前仅包含 RabbitMQ。
pub struct TestEnv {
_rabbitmq_container: ContainerAsync<RabbitMqImage>,
// channel to our newly created RabbitmqNode. Will be useful in following tests
pub rabbitmq_channel: Channel,
}
impl TestEnv {
pub async fn start() -> anyhow::Result<Self> {
let rabbitmq_container = RabbitMqImage.start().await?;
// ...