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

タスクに「〜対応」という名前をつけるのを避けたい理由

先日同僚と雑談的に話してたことを書いておく。ソフトウェア開発のバックログにおける話です。

「〜対応」とは

主に差し込みで入ったタスクやなにか早めに単一の解決したい事象のためのタスクに名付けられやすい名前。

あくまでも例としてだが

  • 「マーケから割引データ表示依頼対応」
  • 「監視アラート対応」

みたいなやつ。「〜対応」というのは日本語としてはかなり便利なので、とりあえずバックログに入れておきたいときに使いがち。

なぜ避けたいか

完了基準があいまいになる

タスクを流していく際の問題。

バックログ上のタスクは完了基準を定めておかないと、作業スコープがどんどん広がったり、完了したかどうかを確認する人から見ると完了していないということが作業後にわかったりして不便。「〜対応」という名前をつけるタスクは、そもそもの作業スコープがはっきりしていないことが多く、結果として、作業を始める前に関係者との認識合わせが再度必要になって思ったより時間がかかったり、本来やらなくてよかったところまでやってしまったりするかもしれない。

なにをやっているのかわからない名前になりやすい

作業の透明度についての問題。

「〜対応」というタスクを入れた人がそのままタスクを進める場合に起きがちなケース。やっている人にはある程度道筋が見えているので、作業の進行自体には問題ないことも多い。一方で、他の人からだと話を聞かないとなにをやっているのか見えづらいことがある。実は作業スコープが本来必要なものより大きいこともありえるが、他の人がそれに気づくきっかけが少なくなることがある。

とにかくこなさないとまずいことが起きそうに見える

バックログ上での優先度を決めるときの問題。

「〜対応」ってなんか急ぎっぽいじゃないですか。でも実際はステークホルダーとしては「〜対応」の一部だけ特定の期限までにやってくれればよかったりすることもある。また、「〜対応」のなかにユーザー価値に直結することと技術的タスクが混じっていて、まずはそこの優先順位をつけるべきこともある。

どうするか

いくつか挙げてみる。他にいい方法があったら教えてください。

完了したと明確に言えるタスク名にする

もう少し掘り下げて「〜する」、「〜できる」という方式で名前を考えてみる。最初に挙げた例だと

  • 「マーケから割引データ表示依頼対応」は「〜のページから割引ページが見えるようになる」
    • マーケチームからの依頼という情報はタグなどで表現する
  • 「監視アラート対応」は「〜エラーのアラートが毎朝鳴らないようにする」

みたいに変えると、名前から完了基準が明確になる。加えて、タスクの説明として完了基準を詳しく書いておくとよい。

実は複合的なタスクではないかと疑って分解する

「〜対応」というタスクは実は複合的な作業を表しているということが多い。この場合は、あらためてチームでタスク分解から考えるのがよい。たとえば「監視アラート対応」が

  • 「〜系のエラーは無視して問題ないのでSentryに送らない」
  • 「〜エラーのアラートが毎朝鳴らないようにリトライ機構を入れる」

のように分解できるかもしれない。こういう分解はできればバックログに入れる早い段階でなされるとよい。また、タスクを分解しておくとフロー効率の面でもよい効果があるだろう。

参考

RustでTiberiusを使ってSQL Serverのコンテナに接続する

Microsoftが提供しているSQL ServerのDockerイメージを使うと、デフォルト設定では無料のSQL Server Developer Editionのコンテナを起動できる。

learn.microsoft.com

このコンテナに対してRustからクエリを発行したい。Rustでは、SQL ServerのクライアントとしてPrismaが開発しているTiberiusというcrateがあるので、これを使う。

github.com

今回は0.12.2を使う。

SQL Serverのコンテナを起動する

version: '3'

services:
  db:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      ACCEPT_EULA: Y
      MSSQL_SA_PASSWORD: yourStrong(!)Password
    ports:
      - '1433:1433'
    volumes:
      - sqlvolume:/var/opt/mssql

volumes:
  sqlvolume:

これで起動する。mssql/serverのイメージはamd64アーキテクチャにしか対応していないので、AppleシリコンのmacOSかつDocker Desktopを使う場合、Docker Desktopの"Use Rosetta for x86/amd64 emulation on Apple Silicon"を有効にする必要がある1

sqlcmdから接続確認してもよい。

$ sqlcmd -U sa -P "yourStrong(!)Password"
1>

Tiberiusから接続する

ここから先は、すでにtestデータベースにproductsテーブルがあることにする。

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

TiberiusからSQL Serverのコンテナに接続する。macOSでRustのコードを実行する場合、tiberius crateのfeatureとしてrustlsを有効化する必要があることに注意2。デフォルトのnative-tlsを使うとDB接続時にスタックしてしまう現象が見られた。

具体的には、tiberius crateデフォルトのfeatureをすべて無効化して、rustlsをfeatureとして追加する。Cargo.tomlに次のように設定を追加すればよい(デフォルトで有効なfeatureであるtds73も再度追加している)。

[dependencies]
tiberius = { version = "0.12.2", default-features = false, features = ["tds73", "rustls"] }
# ...

また、今回は非同期ランタイムとしてTokioを使う。cargo add tokio tokio-utilしておく。

SQL Serverのコンテナに接続するコードは次のとおり。

use tiberius::{AuthMethod, Client, Config};
use tokio::net::TcpStream;
use tokio_util::compat::TokioAsyncWriteCompatExt;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // 接続設定を作る。デフォルトでlocalhost:1433に接続する
    let mut config = Config::new();
    config.database("test");
    config.authentication(AuthMethod::sql_server("sa", "yourStrong(!)Password"));
    config.trust_cert(); // TLS証明書を検証しない

    // TCP接続する
    let tcp = TcpStream::connect(config.get_addr()).await?;
    tcp.set_nodelay(true)?;

    // DBに接続する
    let mut client = Client::connect(config, tcp.compat_write()).await?;

    // INSERT
    client
        .query(
            "insert into products (name, price) values (@P1, @P2)",
            &[&"Mac Studio", &1999],
        )
        .await?;

    // SELECT
    let stream = client.query("select top 1 * from products", &[]).await?;
    let row = stream.into_row().await?.unwrap();

    println!("{:#?}", row);

    Ok(())
}

注意点としては、開発用なのでTLS証明書を検証しない設定でないと接続に失敗する。DB設定の作成時にtiberius::Config::trust_certを呼んでおけばよい。

このコードを実行すると、次のように出力される。

Row {
    columns: [
        Column {
            name: "id",
            column_type: Int4,
        },
        Column {
            name: "name",
            column_type: BigVarChar,
        },
        Column {
            name: "price",
            column_type: Decimaln,
        },
    ],
    data: TokenRow {
        data: [
            I32(
                Some(
                    15,
                ),
            ),
            String(
                Some(
                    "Mac Studio",
                ),
            ),
            Numeric(
                Some(
                    1999.00,
                ),
            ),
        ],
    },
    result_index: 0,
}

  1. https://github.com/microsoft/mssql-docker/issues/668#issuecomment-1436802153
  2. TiberiusのREADMEによると、"For some reasons the Security Framework on macOS does not work with SQL Server TLS settings, and on Apple platforms if needing TLS it is recommended to use rustls instead of native-tls"とのこと