RustでTestcontainers入門: テストコードから依存サービスを起動してテスト環境を作成する

この記事はRust Advent Calendar 2023 シリーズ1の4日目の記事です。

あるソフトウェアをテストするとき、そのソフトウェアがデータベースやメッセージブローカのような外部のサービスに依存する場合に、その依存をどのように扱うかという問題がつきまとう。この問題へのよくある解決策としては

  • 依存サービスを差し替えられるような設計にして、テスト実行時はモックに差し替える
  • Docker Composeなどであらかじめ依存サービスをテスト用のコンテナとして立ち上げておき、テスト実行時はそれらのサービスが動くコンテナにアクセスする

などが挙げられる。

今回はさらに別の方法として、テスト実行時にテストコードの中から依存サービスをコンテナとして立ち上げるためのフレームワークであるTestcontainersを紹介する。また、RustでTestcontainersを使うときの実例を紹介する。

Testcontainersとは

Testcontainersは、Dockerを利用して外部のサービスをテストコードからプログラマブルに作成するためのフレームワークである。

testcontainers.com

Technology Radarでもadoptedな技術である。

もともと、テスト用のコンテナを用意するときに次のような課題があった。

  • 各テストケースから共用のコンテナを使うと、アクセスが競合してテスト結果が不安定になりうる
  • コンテナの起動時に使用可能になるまで待機する必要がある
  • インメモリのサービス(e.g. MySQLではなくSQLiteを使う)で代替するのはdev/prod parityの観点で問題がある

これらの課題をTestcontainersで解決できる。Testcontainersには主に次の利点がある。

  • コンテナを作成、破棄するAPIを提供するので、テスト実行時にコンテナを使い捨てできる。テスト実行時の環境がつねに期待通りになる
  • コンテナが使用可能になるまでの待機方法をプログラマブルに設定できるので、確実にテスト実行可能なコンテナが得られる
  • テスト用コンテナのポートを自動でホストにマッピングしてくれるのでポート番号被りを気にしなくてよい

Docker Composeでも同じようなことができそうだが、コンテナ起動の待機やポート番号採番の仕組みを作り込まずとも、Testcontainersが提供するAPIを使ってプログラマブルな仕組みで解決できるというのが優位なポイント。この点についてはドキュメントにも記載がある。

https://testcontainers.com/getting-started/#differences-with-docker-and-docker-compose

RustでTestcontainersを使うためのtestcontainers-rs

Testcontainersは各言語に移植されている。Rustならtestcontainers-rsがある。crateの名前はtestcontainers。

github.com

Rustのtestcontainersには次の特長がある。

  • Imageトレイトを構造体が実装すると、その構造体をイメージを表すモジュールとして扱える
    • この記事では詳しく扱わないが、Image::ready_conditionsを実装することでプログラマブルにコンテナの利用可能条件を記述できる
  • コンテナを表す構造体がdropされるときに、Dockerで動いているコンテナが停止する。つまり明示的な後始末は不要

testcontainers-rsを使う

まずtestcontainers-rsの基本的な使い方を説明する。

コンテナを起動する

以下、testcontainers-rsを使うときはDockerランタイムが動いていることを前提とする。

まずコンテナを起動してみる。コンテナとして起動したい主要なミドルウェアのモジュールは、testcontainers-rs-modules-communityというリポジトリでメンテナンスされている。このリポジトリが提供するcrateの名前はtestcontainers-modules。

github.com

今回は私が先日testcontainers-modulesに追加したSQL Serverのモジュールを利用して、SQL Serverのコンテナを起動してみる1

Cargo.tomlにTestcontainers関連のcrateを依存として追加する。testcontainers-modulesが提供する各モジュールはfeatureとして明示的に依存に追加する必要がある。

[dev-dependencies]
testcontainers = "0.15.0"
testcontainers-modules = { version = "0.2.0", features = ["mssql_server"] }

コンテナを起動するだけのコードは次のとおり。

// examples/mssql_server.rs
use testcontainers::clients;
use testcontainers_modules::mssql_server;

fn main() {
    // Docker CLIへのインタフェースを作成する
    let docker = clients::Cli::default();

    // デフォルト設定でSQL Serverのコンテナを起動する
    let mssql_server = docker.run(mssql_server::MssqlServer::default());

    // 起動したコンテナの情報を表示する
    println!("Container ID: {}", mssql_server.id());

    let mssql_default_port = 1433;
    println!("Host port: {}", mssql_server.get_host_port_ipv4(mssql_default_port));
}

実行結果の一例は次のとおり。

$ cargo r --examples mssql_server
    Finished dev [unoptimized + debuginfo] target(s) in 0.09s
     Running `target/debug/examples/mssql_server`
Container ID: 6fcfae72e2ad14bdd85de6a2416d0fc15d1fd35e5663f64876519738a28850b9
Host port: 32830

testcontainers-modulesが提供するモジュールはDefaultトレイトを実装している。このおかげで、MssqlServer::default()を呼べば、普通に使える状態に設定されたイメージが構造体として得られる。例えばDBならユーザのパスワードなどがあらかじめ設定される。また、これらのモジュールはImageトレイトを実装した構造体でもあるので、clients::Cli::runにイメージを渡すとコンテナを起動できる。

コンテナを起動するとContainerのインスタンスが得られる。上のコードではコンテナIDをContainer::idで取得している。また、Container::get_host_port_ipv4を使うと、localhostにマッピングされたポート番号を取得できる。Testcontainersの特長にも書いたように、このポート番号は自動で採番される。実際にコンテナに接続したいときは、このメソッドを通じて取得した番号を利用することになる。

その後、上記のコードだとmain関数の終わりでmssql_serverの所有権が失われる。ContainerDropトレイトを実装しており、デフォルトではdropするときにコンテナを停止する。よって、main関数の終わりのタイミングでプログラムはコンテナを停止する。

イメージの設定を変更する

イメージのモジュールには主要な設定を変えるためのメソッドが存在することが多い。たとえば、MssqlServer::with_sa_passwordを使ってSQL ServerのSAユーザのパスワードを次のように変更できる。

// SAユーザ(管理者ユーザ)のパスワードを変える
let image = mssql_server::MssqlServer::default().with_sa_password("difficult!(passWORD)");
let mssql_server = docker.run(image)

また、そのようなメソッドが用意されていない場合でも、RunnableImageで既存のイメージを包むことで、使うイメージのタグや環境変数を変更できる。特殊な設定を入れたいときはこちらを使うのが便利。

// 少し古い2019年のイメージを使う
RunnableImage::from(MssqlServer::default()).with_tag("2019-CU23-ubuntu-20.04");

// SQL Serverのエディションを選択する環境変数を変更する
RunnableImage::from(MssqlServer::default()).with_env_var(("MSSQL_PID", "Evaluation"));

インテグレーションテストを書いてみる

Testcontainersを使ってDBにアクセスするモジュールに対するテストを書いてみよう。この節のサンプルコードは https://github.com/kymmt90/testcontainers-example で公開している。

インテグレーションテストとしてtestsディレクトリ配下にテストを書く。ディレクトリ構造は次のとおり2

src
└── lib.rs
tests
├── common
│   └── mod.rs
└── integration_test.rs

最終的に次のような形式のテストを書けるようにしていく。

#[tokio::test]
async fn test() -> anyhow::Result<()> {
    // commonに置いたセットアップ関数を使い、Testcontainersを通じてコンテナを起動する
    let container = common::setup_mssql_container().await?;

    // ホストにマッピングされたポート番号を使って、コンテナで動くSQL Serverに接続する
    let mut client = get_mssql_client(container.get_host_port_ipv4(common::MSSQL_PORT)).await?;

    // DBに接続してなにかするロジックを実行する

    // 期待するふるまいかどうか検証する

    Ok(())
}

テスト対象のモジュール

例なのでモジュールの仕様は最小限にする。まず、モジュールからアクセスするDBのスキーマは次のものとする。

create table products (
    id int identity(1,1) primary key,
    name varchar(255) not null,
    price decimal(10,2) not null
);

src/lib.rsは次のとおり。DBから取得したproductsのレコードのデシリアライズと、独自フォーマットの文字列表現作成のロジックだけを持つProductを定義する。このモジュールはtestcontainers_sampleから提供されるものとする。

use std::fmt;

use anyhow::{anyhow, Result};
use tiberius::numeric::Numeric;

#[derive(Debug, PartialEq)]
pub struct Product {
    pub id: i32,
    pub name: String,
    pub price: i128,
}

impl fmt::Display for Product {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}: {} - {}円", self.id, self.name, self.price)
    }
}

impl Product {
    pub fn deserialize(row: &tiberius::Row) -> Result<Self> {
        let id: i32 = row.get("id").ok_or(anyhow!("id not found"))?;

        let name = row
            .get::<&str, _>("name")
            .ok_or(anyhow!("name not found"))?
            .to_string();

        let price = row
            .get::<Numeric, _>("price")
            .ok_or(anyhow!("price not found"))?
            .int_part();

        Ok(Self { id, name, price })
    }
}

テストセットアップ用のヘルパー関数

tests/common/mod.rsはTestcontainersのセットアップなど、テスト全体で使うヘルパー関数を置く。mod.rsの一部は次のようになる。

use std::sync::OnceLock;
use testcontainers::{clients, Container};
use testcontainers_modules::mssql_server::MssqlServer;

pub static MSSQL_PORT: u16 = 1433;

static DOCKER: OnceLock<clients::Cli> = OnceLock::new();

pub type MssqlClient = tiberius::Client<tokio_util::compat::Compat<tokio::net::TcpStream>>;

pub async fn setup_mssql_container<'d>() -> anyhow::Result<Container<'d, MssqlServer>> {
    let docker = DOCKER.get_or_init(clients::Cli::default);
    let container = docker.run(MssqlServer::default());

    create_database(&container).await?;
    load_schema(&container).await?;
    load_master_data(&container).await?;

    Ok(container)
}

pub async fn connect_to_mssql_container(config: tiberius::Config) -> anyhow::Result<MssqlClient> {
    use tokio_util::compat::TokioAsyncWriteCompatExt as _;

    let tcp = tokio::net::TcpStream::connect(config.get_addr()).await?;
    tcp.set_nodelay(true)?;

    Ok(tiberius::Client::connect(config, tcp.compat_write()).await?)
}

// ...

setup_mssql_containerは、SQL Serverのコンテナを起動し、そのコンテナ上でテスト用にデータベースをセットアップするヘルパー関数である。

記事内では省略するが、データベースの作成(create_database)、スキーマのロード(load_schema)、テスト用のマスタデータのロード(load_master_data)を実行する。この例では、testデータベースにproductsテーブルを作り、load_master_dataでproductsテーブルにマスタデータとしてレコード10件をロードしているとする。

最終的に、setup_mssql_containerはテスト用のセットアップが完了したコンテナを返す。

connect_to_mssql_containerは、テストのセットアップやテスト自身からSQL Serverに接続するためのクライアントを取得するヘルパー関数である。

Testcontainersを使ったテスト

用意したセットアップ用のヘルパー関数を使うと、次のようにテストを書ける。ここでは例として2つのテストケースを書く。

mod common;

use testcontainers_sample::Product;

#[tokio::test]
async fn count_total_records() -> anyhow::Result<()> {
    let container = common::setup_mssql_container().await?;
    let mut client = get_mssql_client(container.get_host_port_ipv4(common::MSSQL_PORT)).await?;

    let r = client
        .simple_query("SELECT count(*) AS count FROM products")
        .await?
        .into_row()
        .await?
        .unwrap();

    assert_eq!(r.get("count"), Some(10));

    Ok(())
}

#[tokio::test]
async fn query_product() -> anyhow::Result<()> {
    let container = common::setup_mssql_container().await?;
    let mut client = get_mssql_client(container.get_host_port_ipv4(common::MSSQL_PORT)).await?;

    let id = 1;
    let r = client
        .query("SELECT * FROM products WHERE id = @P1", &[&id])
        .await?
        .into_row()
        .await?
        .unwrap();

    let product = Product::deserialize(&r)?;
    assert_eq!(product.to_string(), "1: product 1 - 1000円");

    Ok(())
}

async fn get_mssql_client(port: u16) -> anyhow::Result<common::MssqlClient> {
    let mut config = common::mssql_config();
    config.database("test");
    config.port(port);

    common::connect_to_mssql_container(config).await
}

1つ目のテストcount_total_recordsでは、productsテーブルにマスタデータがちゃんと10件ロードできているかをテストしている。また、2つ目のテストquery_productでは、productsテーブルからロードしたID 1のレコードをデシリアライズ、文字列化して期待どおり動くかをテストしている。

それぞれのテストでsetup_mssql_containerしているので、Testcontainersは各テストケースに対して1つコンテナを起動する。また、Cargoがテストを並列に実行するので、結果的に2つのコンテナが同時に起動する。ポート番号はそれぞれTestcontainersが採番するので、その番号を使えば問題ない。

テスト実行結果は次のとおり。DBコンテナを起動してテストを最後まで実行できている。

$ cargo t --test integration_test
    Finished test [unoptimized + debuginfo] target(s) in 0.13s
     Running tests/integration_test.rs (target/debug/deps/integration_test-6e5507a7a028eb02)

running 2 tests
test count_total_records ... ok
test query_product ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.29s

テスト実行中は次のようにコンテナが2つ起動できている。

$ docker ps
CONTAINER ID   IMAGE                                                   COMMAND                  CREATED         STATUS                  PORTS                     NAMES
082a3a3492f8   mcr.microsoft.com/mssql/server:2022-CU10-ubuntu-22.04   "/opt/mssql/bin/perm…"   2 seconds ago   Up Less than a second   0.0.0.0:32824->1433/tcp   musing_mcclintock
f9ef4fa9349f   mcr.microsoft.com/mssql/server:2022-CU10-ubuntu-22.04   "/opt/mssql/bin/perm…"   2 seconds ago   Up Less than a second   0.0.0.0:32823->1433/tcp   kind_neumann

これらのコンテナはそれぞれのテストの関数が終了するときに、containerがdropされることで停止する。

これで、テスト実行時に起動し、テスト終了とともに停止するコンテナを利用したテストを書くことができた。

補足

Java版での入門ドキュメントなどを見ると、beforeAllのようなフックでテスト実行前にコンテナを起動して複数のテストから使うようにも見える。しかし、実際はそのような意図ではないようだ3。入門ドキュメントの例はテストクラス内にテストが1つなので、そういう書きかたなのかもしれない。

RustではCargoが勝手にテストを並列実行してくれるとはいえ、テストごとにコンテナを起動するとテストスイート全体としては速度が遅くなる。Testcontainersを使うテストはテスト規模でいうところの中テスト(単一マシンで実行されるが、複数プロセスを使うテスト)であり、単一プロセスで実行する小テストより検証できることは増えるが、非決定的なテストの増加や速度低下につながる4。テストスイート全体におけるTestcontainersを使うテストの割合が多くなるなら、そもそもできるだけ単一プロセスでテストを完結できるような設計にできないかを考える必要がありそうだ。


  1. SQL Serverを使うシステムのテストで利用したかったという経緯があり、SQL Serverのモジュールがなかったのでコントリビュートした
  2. https://doc.rust-lang.org/book/ch11-03-test-organization.html も参照のこと
  3. testcontainers-rsのメンテナの人も、テストごとにコンテナは独立させるべきと言っている https://github.com/testcontainers/testcontainers-rs/issues/354
  4. cf. https://gihyo.jp/dev/serial/01/savanna-letter/0003