Web API サーバを Testcontainers と Mockall で単体・結合テストする
データベースに対して CRUD の操作をする Web API サーバをテストするときにテスト用のデータベースを用意するのは一般的だと思いますが、ローカルにデータベースをセットアップしたり Docker Compose でデータベースの Docker コンテナを立ち上げる手間があると思います。
そこで Testcontainers を利用してテストコード内で Docker コンテナを立ち上げることで余計な手間を省く方法について紹介します。
また、本記事では Web API サーバを Rust で実装しレイヤードアーキテクチャを採用したので Mockall で各層のモックを作成する方法についても説明します。
今回追加したテストのコードは GitHub の Pull Request にまとめています。
おさらい
本記事では Rust で Event Sourcing を試してみた ~ AWS のブログを参考に模倣する ~ で実装した Web API サーバをベースに説明するので用語やアーキテクチャ構成をおさらいします。
用語
- 集約: ドメイン駆動設計における一連の関連するエンティティをグループ化しドメインモデルの一貫性と整合性を保つためのオブジェクト
- コマンド: 集約の内部状態を外部からのリクエストやユーザーのアクションに応じて変更するためのオブジェクト
- イベント: 集約内で行われた操作 (コマンド) の結果としての状態変更結果を表現するオブジェクト
アーキテクチャ構成
アプリケーションのアーキテクチャは driver
/ app
/ kernel
/ adapter
の4層構造を採用していて、それぞれの層の役割は以下の通りです。
-
driver
: ルーターとサーバーの起動を担当する層 -
app
: ユースケースを実現するための層で集約へのコマンド実行を担当する -
kernel
: ドメインを担当する層で集約やイベント、コマンドを定義する -
adapter
: 集約やイベントの永続化を担当する層
各層の依存関係は以下のようになっており、上位のモジュールから下位のモジュールを呼び出すようにして、kernel
と adapter
は依存性逆転の原則 (DIP) により adapter
の変更による影響範囲を最小限にしています。
それぞれの層は下位のモジュールから上位のモジュールを呼び出せないようにするために Rust の workspace で区切っています。
単体テスト
Testcontainers や Mockall を使ったテストを説明する前にテーブル駆動テストについて紹介します。
テーブル駆動テストとはテストケースのデータをテーブル形式で管理するテスト手法で、各テストケースは入力データと期待される結果のペアで構成されています。テストランナーはテーブル内の各テストケースを反復処理し、実際の結果を期待される結果と比較します。Rust ではテーブル駆動テストは一般的ではないと思いますが、テーブル形式でテストケースを管理することでテストケースの作成・編集・管理が容易になります。また、テストデータが構造化されているためテストの内容が明確になり、テストの可読性が向上すると思います。
下記のコードは AggregateError
から WidgetServiceError
に変換する処理のテストコードです。
TestCase
構造体はテストケースを表現し、error
フィールドは入力データを、assert
フィールドは引数に変換後の値を error
を受け取って期待する結果をテストします。
Testcontainers を使ったテスト
実装したアプリケーションでは SQLx でデータベース (MySQL) とやり取りしますが、 SQLx にはテスト実行時にテスト用のデータベースを作成したりマイグレーション・フィクスチャーを実行したりする sqlx::test という便利な Attribute Macro が用意されているので、こちらを利用するといいでしょう。しかし、ゆくゆくはデータベースを Amazon DynamoDB に変える予定なので極力 SQLx に依存しないテストを書くために今回は Testcontainers で Docker コンテナとして立ち上げたテスト用の MySQL に対してテストを実行します。
といっても下記のようにたった2行で Testcontainers で MySQL コンテナが立ち上がり、コンテナに接続するためのポートは testcontainers::core::Container::get_host_port_ipv4 関数で取得することができます。
あとは MySQL に接続してマイグレーションを実行してテスト用のデータベースの準備は完了です。
実際に集約の作成を永続化する関数 ( create_widget_aggregate
) を例に説明します。テスト対象の関数の実装は以下の通りです。
この関数では集約に作成コマンドを実行した結果を Aggregate テーブルに永続化する関数なので、作成コマンド実行結果を引数に受け取った場合は
- 関数が成功する
- Aggregate テーブルに永続化される集約は1レコード
- レコードのそれぞれのカラムに意図した値が入っている
といったことをテストできたら良いでしょう。以下がテスト関数内で TestCase
構造体の定義とテストケースの定義、それをもとにテストを実行する処理です。
TestCase
構造体の assert
フィールド内でレコードのデータを取得するために SQLx を利用しますが、async クロージャーはまだ 安定版では利用できない ので
のように std::future::Future
を std::boxed::Box
で包んで、さらに await を呼び出せるように std::pin::Pin
で包んだものを返す関数を型として定義します。assert
フィールドはテストケース名 ( name
) と create_widget_aggregate
関数の返り値 ( result
) 、SQLx で利用するコネクションプール ( pool
) を引数に取り、クロージャー内でそれらを利用して関数が意図した挙動を取るかテストします。
Mockall を使ったテスト
Mockall は Trait や構造体の振る舞いをモックするための crate です。今回は kernel
に定義したデータベースを操作する振る舞いを定義した CommandProcessor
を例にモックの作成と利用方法について説明します。
モックの作成
CommandProcessor
のモックをただ作成するなら
#[mockall::automock]
pub trait CommandProcessor {
...
}
のように mockall::automock Attribute Macro を Trait に追加するだけで自動でモック用のコードを作成してくれます。しかし、このままコンパイルするとプロダクションコードにモックのコードが含まれてしまうためサイズが増えてしまいます。そこで、cfg_attr Attribute を利用してテスト時のみモックを作るようにします。
#[cfg_attr(test, mockall::automock)]
pub trait CommandProcessor {
...
}
これで kernel
crate の CommandProcessor
のモックを app
crate から呼び出せると思いきや、 test Attribute は同一 crate 内でしか機能しないため今回のように workspace で区切っていた場合は別 crate からモックを呼び出すことができません。なので今回は features の Optional Dependencies で app
crate からモックを呼び出せるようにします。
kernel
crate の Cargo.toml を以下のように定義して
[dependencies]
mockall = { version = "0.12.1", optional = true }
[features]
mockall = ["dep:mockall"]
CommandProcessor
は cfg_attr で mockall
feature のときのみ mockall::automock
をコンパイルするようにします。
#[cfg_attr(feature = "mockall", mockall::automock)]
pub trait CommandProcessor {
...
}
そして app
crate の Cargo.toml は以下のように dev-dependencies
で mockall
feature を有効にして依存関係を追加することでテスト時のみモックを呼び出すことできます。
[dev-dependencies]
kernel = { path = "../kernel", features = ["mockall"] }
モックを利用する
mockall::automock
によって CommandProcessor
に MockCommandProcessor
が生成され、各関数に expect_
プレフィックスが付いた関数を呼び出し、以下のように returning
関数で振る舞いをモックできます。
他にも with や withf、withf_st の関数を利用してモックした関数の引数をテストできたり、
never や once、times 関数で呼び出し回数をテストできたりするのでぜひ User Guide を参考にしてみてください。
結合テスト
結合テストは立ち上げた API サーバに対してリクエストをして期待した挙動をテストするため、永続化の単体テストと同様 Testcontainers を利用します。
Testcontainers で MySQL のコンテナを立ち上げるのは同様のため割愛して API サーバを立ち上げる部分について説明したいと思います。
上記のようにサーバーを別スレッドで立ち上げてヘルスチェックのエンドポイントである /healthz
がレスポンスを返すまでループします。その後他の単体テスト同様 TestCase
構造体でテストケースを作成しテストを実行します。
今回は別スレッドで API サーバを立ち上げましたが、axum の Router の oneshot
関数を利用して直接 Router にリクエストすることもできます。 ( axum/examples/testing/src/main.rs )
まとめ
Testcontainers を利用してテストコード内で Docker コンテナを立ち上げてテストに利用する方法と、Mockall で各層のモックを作成してテストに利用する方法について説明しました。テストが有るのと無いのでは開発の進め方がかなり違うと思うのでどなたかの参考になればと思います。
次回は OpenTelemetry と OpenObserve を利用してアプリケーションを可視化してみたい と思います。
Discussion