paild tech blog

法人カードのクラウド型発行・管理サービスpaild

Rust + testcontainersでのテスト環境構築とハマりどころ

こんにちは、お手伝いの大櫛です。

今回はRust + testcontainersのテスト環境、非同期にテストを行う際の注意点や工夫ポイントなどについて紹介していきます。

testcontainersとは

testcontainersとは、PostgreSQLのようなDBや、Nginxのようなweb serverなどの環境をコンテナ形式でお手軽に用意してくれるフレームワークです。

testcontainers.com

主にテスト環境をお手軽に作成したいときに有用で、ローカルやCIなどの環境に併せてコンテナ・Dockerセットアップを細かく変える必要がない点が一つメリットに挙げられそうです。

Rustの他にJavaやGo、RubyやNode.jsなど、メジャーなプログラミング言語・実行環境向けのライブラリも用意されているので、コンテナの立ち上げからテスト実施までを開発言語内で完結させることができます。

ただし、開発体制の充実具合はまちまちのようで、Rustを含めたいくつかの言語・実行環境サポートはcommunity-maintainedであることに注意が必要です。

最新のサポート状況は以下のGitHub organization READMEで確認できます:

github.com

普通に使ってみる

今回はDynamoDBを例にとって説明します。

testcontainersではよく利用されるDBなどの環境のwrapperを提供しています(moduleと呼ばれます)。

Rustでは以下のrepositoryでmoduleがcommunity-maintainedとして提供されており、DynamoDBも含まれています:

github.com

DynamoDB用のexampleは執筆時点で存在しなかったため、以下のテストコードを参考にして実装を用意してみましょう:

github.com

下記のようなテープル作成用の関数を作成する関数を用意します:

async fn create_table(table: &str, attr: &str, client: &Client) -> anyhow::Result<CreateTableOutput> {
    let key_schema = KeySchemaElement::builder()
        .attribute_name(attr.to_string())
        .key_type(KeyType::Hash)
        .build()?;

    let attribute_def = AttributeDefinition::builder()
        .attribute_name(attr.to_string())
        .attribute_type(ScalarAttributeType::S)
        .build()?;

    let provisioned_throughput = ProvisionedThroughput::builder()
        .read_capacity_units(10)
        .write_capacity_units(5)
        .build()?;

    let resp = client
        .create_table()
        .table_name(table.to_string())
        .key_schema(key_schema)
        .attribute_definitions(attribute_def)
        .provisioned_throughput(provisioned_throughput)
        .send()
        .await?;

    Ok(resp)
}

テーブル・アトリビュート名とDynamoDBクライアントを受け取り、テーブル作成を行います。

この関数に対するテストコードとして以下を用意します:

    #[tokio::test]
    async fn dynamodb_local_create_table_test_1() {
        let table_name = "test_1";
        let docker = Cli::default();
        let node = docker.run(DynamoDb);
        let host_port = node.get_host_port_ipv4(8000);
        let dynamodb = build_dynamodb_client(host_port).await;

        let result = create_table(table_name, "title", &dynamodb).await;
        assert!(result.is_ok());

        let req = dynamodb.list_tables().limit(10);
        let list_tables_result = req.send().await.unwrap();

        assert!(list_tables_result.table_names().contains(&String::from(table_name)));
    }

    async fn build_dynamodb_client(host_port: u16) -> Client {
        let endpoint_uri = format!("http://127.0.0.1:{host_port}");
        let region_provider = RegionProviderChain::default_provider().or_else("us-east-1");
        let creds = Credentials::new("fakeKey", "fakeSecret", None, None, "test");

        let shared_config = aws_config::defaults(BehaviorVersion::latest())
            .region(region_provider)
            .endpoint_url(endpoint_uri)
            .credentials_provider(creds)
            .load()
            .await;

        Client::new(&shared_config)
    }

テーブルを作成し、それが成功したことと、渡したテーブル名で作成されているかをテストしています。

テストを実行してみると、無事にpassすること、そしてテストの実行が終了したらコンテナが削除されていることが分かります。便利ですね。

拡張性の課題

ここまでは問題なくテストできるのですが、このままテストを増やしていくと少し問題が発生します。

同じ構造のまま test_2 を追加してみましょう:

#[tokio::test]
    async fn dynamodb_local_create_table_test_2() {
        let table_name = "test_2";
        // 以下同文...
    }

やっていることは同じなのでもちろんpassしますが、テスト内でcontainerの作成を行っているので、初期化処理・readテスト用データの入れ込みなど一度だけやりたい処理を持ちたいときに問題となります。

テスト数が少ない・複雑度が低いうちは見過ごせるでしょうが、開発とともにテスト数が増えて複雑な仕組みが欲しくなってくると悩みのタネになり得ます。

static変数を用意してみる

では、コンテナ作成処理とそれのクライアントをstatic変数で用意してみるのはどうでしょうか。once_cellを用いつつ、以下のように作成してみます:

static DOCKER: Lazy<Cli> = Lazy::new(|| Cli::default());
static CONTAINER: Lazy<Container<'static, DynamoDb>> = Lazy::new(|| DOCKER.run(DynamoDb));

あとは各テスト内でこのstatic変数を呼ぶようにしてみましょう:

- let host_port = node.get_host_port_ipv4(8000);
+ let host_port = CONTAINER.get_host_port_ipv4(8000);

無事にpassしていることを確認できると思います。やりましたね!

……ただし、先ほどまではテストの実行終了時に削除されていたコンテナが何やら生き続けているということに気付くはずです。一体何が起こっているのでしょう……?

コンテナが生き続ける理由

これはtestcontainersのドキュメントを読むと理由が分かります。 Container structについてのドキュメントを見ると以下のような記載があります:

Containers have a custom destructor that removes them as soon as they go out of scope

docs.rs

なるほど!今まで勝手にお片付けしてくれていたのはscopeから抜ける際にdropが働いていたからのようです。

ここでstatic変数の制約を思い出してみます。

Static items do not call drop at the end of the program.

doc.rust-lang.org

……完全に理解しましたね。どうやらstatic変数にする→dropが呼ばれずお片付けされないままテストが完走してしまう、というのが原因のようです。

無理やりdropさせてみる

となると、どうにかstatic変数を使っていてもdropを呼ぶ方法があれば問題は解決しそうですね。

ここでの解決策の一つとして、 thread_local! マクロを用いるという方法があります。

この解決法を今回のテストコードに適用すると以下のようになります:

    static DOCKER: Lazy<Cli> = Lazy::new(|| Cli::default());
    thread_local! {
        static CONTAINER: Container<'static, DynamoDb> = {
            DOCKER.run(DynamoDb)
        };
    }
    static HOST_PORT: Lazy<u16> = Lazy::new(|| CONTAINER.with(|c| {
        c.get_host_port_ipv4(8000)
    }));

    # テスト内で `HOST_PORT` を呼び出し
    let dynamodb = build_dynamodb_client(*HOST_PORT).await;

ここでは単純にhost portを返しているのみですが、 CONTAINER static変数を使いながら自由に初期化処理を行うことができます。

実行してみると、コンテナが無事にテスト終了時に破棄されていることが分かると思います。 副次的な効果として、 static変数としてDocker起動処理を一度のみ行っているので起動するコンテナの数も一つとなります。

注意点

thread_local! によるdropの制御はbest-effort™によるものだということに注意が必要です。

今回の例ではうまくいきましたが、環境の差異などにより条件が整わなければdropが呼ばれない可能性もあります。

users.rust-lang.org

doc.rust-lang.org

その場合は、別の手段でdropを呼ばせる、あるいはscopeguardのようなcrateを使いつつ破棄処理を自前で実装するなど、別の対応が必要となります。

github.com

まとめ

今回はtestcontainers(-rs)の紹介と、staticにコンテナを管理しつつテスト終了時にコンテナを破棄するhackについて紹介しました。

testcontainersに限らず、once_cellなどを絡めたstatic itemと初期化処理、そのdrop callまわりのhackはある程度汎用的なお話かと思います。参考になっていましたら幸いです!