この記事はRust Advent Calendar 2023 シリーズ1の4日目の記事です。
あるソフトウェアをテストするとき、そのソフトウェアがデータベースやメッセージブローカのような外部のサービスに依存する場合に、その依存をどのように扱うかという問題がつきまとう。この問題へのよくある解決策としては
- 依存サービスを差し替えられるような設計にして、テスト実行時はモックに差し替える
- Docker Composeなどであらかじめ依存サービスをテスト用のコンテナとして立ち上げておき、テスト実行時はそれらのサービスが動くコンテナにアクセスする
などが挙げられる。
今回はさらに別の方法として、テスト実行時にテストコードの中から依存サービスをコンテナとして立ち上げるためのフレームワークであるTestcontainersを紹介する。また、RustでTestcontainersを使うときの実例を紹介する。
- Testcontainersとは
- RustでTestcontainersを使うためのtestcontainers-rs
- testcontainers-rsを使う
- インテグレーションテストを書いてみる
- 補足
Testcontainersとは
Testcontainersは、Dockerを利用して外部のサービスをテストコードからプログラマブルに作成するためのフレームワークである。
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。
Rustのtestcontainersには次の特長がある。
Image
トレイトを構造体が実装すると、その構造体をイメージを表すモジュールとして扱える- この記事では詳しく扱わないが、
Image::ready_conditions
を実装することでプログラマブルにコンテナの利用可能条件を記述できる
- この記事では詳しく扱わないが、
- コンテナを表す構造体がdropされるときに、Dockerで動いているコンテナが停止する。つまり明示的な後始末は不要
testcontainers-rsを使う
まずtestcontainers-rsの基本的な使い方を説明する。
コンテナを起動する
以下、testcontainers-rsを使うときはDockerランタイムが動いていることを前提とする。
まずコンテナを起動してみる。コンテナとして起動したい主要なミドルウェアのモジュールは、testcontainers-rs-modules-communityというリポジトリでメンテナンスされている。このリポジトリが提供するcrateの名前はtestcontainers-modules。
今回は私が先日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
の所有権が失われる。Container
はDrop
トレイトを実装しており、デフォルトでは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を使うテストの割合が多くなるなら、そもそもできるだけ単一プロセスでテストを完結できるような設計にできないかを考える必要がありそうだ。
- SQL Serverを使うシステムのテストで利用したかったという経緯があり、SQL Serverのモジュールがなかったのでコントリビュートした↩
- https://doc.rust-lang.org/book/ch11-03-test-organization.html も参照のこと↩
- testcontainers-rsのメンテナの人も、テストごとにコンテナは独立させるべきと言っている https://github.com/testcontainers/testcontainers-rs/issues/354↩
- cf. https://gihyo.jp/dev/serial/01/savanna-letter/0003↩