EphemeralデータベースインスタンスでRustの統合テストを効率化する
Emily Parker
Product Engineer · Leapcell

はじめに
ソフトウェア開発の世界では、アプリケーションの堅牢性と正確性を確保することが最優先事項です。統合テストは、特にデータベースなどの外部サービスとやり取りする場合に、システムのさまざまな部分が期待どおりに連携することを検証する上で重要な役割を果たします。しかし、複数の統合テスト間でデータベース状態を管理することは、大きな課題となる可能性があります。テストはしばしば残骸データを残し、干渉を引き起こし、テスト結果を決定論的でなくします。各テストスイートのためにデータベースを手動でセットアップおよび破棄することは、退屈でエラーが発生しやすく、開発サイクルを著しく遅らせます。ここで、Rust用のtestcontainers
が登場し、分離されたデータベースインスタンスを動的に作成および破棄するためのエレガントなソリューションを提供し、統合テストへのアプローチ方法に革命をもたらします。この記事では、testcontainers
の力を活用して、Rustでクリーンで信頼性が高く効率的なデータベース統合テストを実現する方法を探ります。
コアコンセプトと実装
実例に入る前に、議論の中心となるいくつかの重要な用語を明確にしましょう。
- 統合テスト (Integration Test): アプリケーションのさまざまなモジュールまたはサービスが期待どおりに連携することを検証するソフトウェアテストの一種です。私たちの文脈では、これは多くの場合、アプリケーションとデータベースのやり取りをテストすることを意味します。
- エフェメラルデータベースインスタンス (Ephemeral Database Instance): 特定のテストまたはテストセットの実行のみを目的として作成され、その後自動的に破棄されるデータベースインスタンスです。これにより、各テスト実行でクリーンな状態が保証されます。
testcontainers
: JavaおよびGoのTestcontainersに触発されたRustクレートです。これにより、RustコードからDockerコンテナをプログラムで作成および管理できるようになるため、テスト目的でデータベース、メッセージキューなど、分離されたサービス依存関係を起動するのに理想的です。- Docker: コンテナと呼ばれるパッケージでソフトウェアを配信するためにOSレベルの仮想化を使用するプラットフォームです。
testcontainers
は、これらの分離されたサービスインスタンスを管理するためにDockerに依存しています。
データベース統合テストにtestcontainers
を使用する背後にある主な原則は、データベースを一時的で分離されたリソースとして扱うことです。各テストまたはテストスイートは、理想的には独自の専用データベースインスタンスに対して実行されるべきです。これにより、テスト間のデータ汚染を防ぎ、複雑なセットアップおよび破棄スクリプトまたはデータロールバックメカニズムの必要性がなくなります。
PostgreSQLデータベースを使用した実例でこれを説明しましょう。データベースとやり取りするシンプルなRustアプリケーションをセットアップし、次にtestcontainers
を使用してデータベースのライフサイクルを管理する統合テストを作成します。
まず、testcontainers
はDockerに依存しているため、システムにDockerがインストールされ、実行されていることを確認してください。
次に、Cargo.toml
に必要な依存関係を追加します。
[dev-dependencies] testcontainers = "0.19.0" # 最新バージョンを使用 tokio = { version = "1", features = ["macros", "rt-multi-thread"] } sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "uuid", "chrono"] } uuid = { version = "1.0", features = ["v4"] } chrono = { version = "0.4", features = ["serde"] } [dependencies] # アプリケーションの依存関係
次に、src/models.rs
にシンプルなデータベースインタラクションモジュールを作成します。
use sqlx::{PgPool, Error}; use uuid::Uuid; use chrono::{DateTime, Utc}; #[derive(Debug, sqlx::FromRow, PartialEq)] pub struct User { pub id: Uuid, pub name: String, pub email: String, pub created_at: DateTime<Utc>, } pub async fn create_user(pool: &PgPool, name: &str, email: &str) -> Result<User, Error> { let new_user = sqlx::query_as!( User, r#"" INSERT INTO users (id, name, email, created_at) VALUES ($1, $2, $3, $4) RETURNING id, name, email, created_at ""#, Uuid::new_v4(), name, email, Utc::now() ) .fetch_one(pool) .await?; Ok(new_user) } pub async fn find_user_by_email(pool: &PgPool, email: &str) -> Result<Option<User>, Error> { let user = sqlx::query_as!( User, r#"" SELECT id, name, email, created_at FROM users WHERE email = $1 ""#, email ) .fetch_optional(pool) .await?; Ok(user) }
次に、統合テストを作成します。tests/integration_test.rs
というファイルを作成します。
use testcontainers::{clients, images::postgres::Postgres}; use sqlx::{PgPool, Executor}; use tokio; use crate::models::{create_user, find_user_by_email}; // models.rsがメインのlib/bin内にあると仮定、パスを調整してください // テストインスタンスのデータベーススキーマを設定するヘルパー関数 async fn setup_db(pool: &PgPool) -> Result<(), sqlx::Error> { pool.execute( r#"" CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY, name VARCHAR NOT NULL, email VARCHAR NOT NULL UNIQUE, created_at TIMESTAMPTZ NOT NULL ); ""#, ) .await?; Ok(()) } #[tokio::test] async fn test_user_crud_operations() { // 1. Testcontainersクライアントを初期化 let docker = clients::Cli::default(); // 2. PostgreSQLコンテナを起動 // 必要に応じてイメージバージョンやその他のパラメータをカスタマイズできます。例: Postgres::default().with_tag("13") let node = docker.run(Postgres::default()); // 3. データベース接続文字列を取得 let connection_string = &node.dbc.get_connection_string(); // 4. データベースに接続 let pool = PgPool::connect(connection_string) .await .expect("Failed to connect to PostgreSQL"); // 5. このテストインスタンスのスキーマを設定 setup_db(&pool).await.expect("Failed to set up database schema"); // 6. テスト操作を実行 let user_name = "Alice Smith"; let user_email = "alice.smith@example.com"; // 新しいユーザーを作成 let created_user = create_user(&pool, user_name, user_email) .await .expect("Failed to create user"); assert_eq!(created_user.name, user_name); assert_eq!(created_user.email, user_email); // Eメールでユーザーを検索 let found_user = find_user_by_email(&pool, user_email) .await .expect("Failed to find user") .expect("User should be found"); assert_eq!(found_user.id, created_user.id); assert_eq!(found_user.name, created_user.name); assert_eq!(found_user.email, created_user.email); // 重複Eメールを持つユーザーを作成しようとする let duplicate_result = create_user(&pool, "Bob Johnson", user_email).await; assert!(duplicate_result.is_err()); // unique email制約によるエラーを期待 // 7. `node`がスコープ外に出ると、コンテナは自動的に停止および削除されます。 // これは`testcontainers`のDrop実装によって処理されます。 }
models
モジュールを統合テストで利用可能にするために、通常はsrc/lib.rs
があり、テストはtests/
ディレクトリに配置されます。
// src/lib.rs pub mod models; // その他のモジュール
cargo test --color always --tests
を実行すると、次のことが起こります。
- Dockerクライアントの初期化:
clients::Cli::default()
はtestcontainers
Dockerクライアントを初期化します。 - コンテナの作成:
docker.run(Postgres::default())
は、postgres
Dockerイメージを(まだ存在しない場合は)プルし、それから新しいコンテナを起動するようにtestcontainers
に指示します。その後、コンテナが準備完了(例:PostgreSQLがポートでリッスンしている)になるまで待機します。 - 接続文字列:
node.dbc.get_connection_string()
は、Dockerによってマッピングされたランダムなポートを含む、実行中のPostgreSQLインスタンスの動的に生成された接続文字列を提供します。 - データベース接続とスキーマ設定:
sqlx::PgPool::connect
はこの一時的なデータベースへの接続を確立し、setup_db
は必要なusers
テーブルを作成します。 - テスト実行: アプリケーションロジックはこの分離されたデータベースとやり取りします。
- コンテナの破棄: 重要なのは、
#[tokio::test]
関数の終わりにnode
変数(Container
インスタンスを保持)がスコープ外に出ると、testcontainers
は自動的にDockerにコンテナを停止および削除するように信号を送ります。このクリーンアップは、テストが成功したか失敗したかにかかわらず発生し、後続のテストのためのクリーンな環境を保証します。
このアプローチは、いくつかの顕著な利点を提供します。
- 分離 (Isolation): 各テストは、独自のクリーンなデータベースに対して実行され、テストが互いに影響を与えるのを防ぎます。
- 信頼性 (Reliability): テストは、以前の実行によって残された状態に依存しないため、より決定論的になります。
- 効率性 (Efficiency): コンテナの起動にはある程度の時間がかかりますが、そのオーバーヘッドは統合テストでは許容範囲内であることが多く、手動のセットアップ/破棄よりも大幅に高速です。Dockerのレイヤリングとキャッシングも役立ちます。
- シンプルさ (Simplicity): セットアップと破棄のロジックは
testcontainers
ライブラリ内にカプセル化されており、テストのボイラープレートコードを削減します。 - 再現性 (Reproducibility): Dockerが利用可能な場所であればどこでもテストを実行でき、異なる開発環境やCI/CDパイプライン全体で一貫した動作を保証します。
同じ原則をMySQL、Redis、Kafka、Elasticsearch、またはDockerイメージとして利用可能なその他のサービスに適用できます。testcontainers
は、幅広い事前構築済みイメージを提供するか、GenericImage
を使用してカスタムDockerfileを使用できます。
結論
Rustでtestcontainers
を使用して統合テストのためのデータベースインスタンスを動的に作成および破棄することは、テストスイートの品質と保守性を劇的に向上させる強力なテクニックです。各テストが分離されたエフェメラルデータベースで動作することを保証することにより、開発者はより信頼性が高く、決定論的で、デバッグしやすい統合テストを作成できます。testcontainers
を採用することで、テストワークフローが合理化され、Rustアプリケーション開発がより堅牢で効率的になります。