堅牢なRust開発のための外部依存関係のモック
Grace Collins
Solutions Engineer · Leapcell

はじめに
ソフトウェア開発の世界では、信頼性が高く保守性の高いアプリケーションの構築は、効果的なテスト能力に大きく依存します。しかし、データベース、サードパーティAPI、メッセージキューなどの外部サービスとコードがやり取りする場合、直接テストすることは遅くなったり、予測不能になったり、さらにはコストがかかったりする可能性があります。これらの外部依存関係は非決定性をもたらし、テスト対象のユニットを分離し、一貫した結果を保証することを困難にします。そこでモッキングが役立ちます。実際の依存関係を制御されたシミュレートされたバージョンに置き換えることで、高速で決定的かつ分離されたテストを実現できます。Rustでは、この課題に取り組むための説得力のある戦略があります。この記事では、データベースや外部サービスをモックするための2つの著名なアプローチ、すなわちトレイトベースモッキングと強力なmockallクレートについて掘り下げ、より堅牢なRustアプリケーションを作成するための明確な道筋を提供します。
コアコンセプトの理解
実装の詳細に入る前に、Rustにおけるモッキング戦略の理解に不可欠ないくつかの基本的な概念を明確にしましょう。
モッキング: ソフトウェアテストにおいて、モッキングとは、実際の依存関係の動作を模倣するシミュレートされたオブジェクトを作成することを含みます。これらのモックオブジェクトは、定義済みの方法で呼び出しに応答するように設計されており、テスターは実際の外部システムに依存することなく、環境を制御し、やり取りを検証できます。
トレイト: Rustのポリモーフィズムと抽象化の中心にあるトレイトは、型が実装できる共有される動作のセットを定義します。これらは、型が遵守しなければならない契約を提供し、特定のトレイトを実装する任意の型で操作するジェネリックコードを書くことを可能にします。これはトレイトベースモッキングの基礎となります。
依存性注入: コンポーネントが、それ自体で作成するのではなく、外部ソースから依存関係を受け取るデザインパターンです。これにより、疎結合が促進され、テスト中に依存関係の異なる実装(モックオブジェクトを含む)を簡単に置き換えることができます。
テストダブル: テスト目的で実際のオブジェクトの代わりに使用される任意のオブジェクトに対する一般的な用語です。モックはテストダブルの一種であり、やり取りと動作をアサートできます。その他のタイプには、スタブ(事前定義された応答を返す)やフェイク(より単純なインメモリ実装)があります。
トレイトベースモッキング:Rustネイティブアプローチ
トレイトベースモッキングは、Rustの強力なトレイトシステムを活用して依存関係の逆転を実現し、依存関係の簡単な置き換えを可能にします。中心的な考え方は、外部サービスに対する操作を概説するトレイトを定義することです。次に、具体的な実装(例:データベースクライアント)がこのトレイトを実装します。テストのために、同じトレイトを実装する別の「モック」構造体を作成しますが、そのメソッドには制御された定義済みの動作が含まれます。
原理と実装
- トレイトの定義: アプリケーションが外部サービスに対して実行する操作を表すトレイトを作成します。
- 具体的なサービスの Среализация: 実際のサービス実装(例:PostgreSQLデータベースとのやり取り)がこのトレイトを実装します。
- モックサービスの Среализация: 同じトレイトを実装するモック構造体を作成します。そのメソッドは、定義済みの値の返却やメソッド呼び出しの記録など、テスト固有のロジックを含みます。
- 依存性注入: 通常はコンストラクタまたは関数引数を介して、アプリケーションコードに適切な実装(実物またはモック)を注入します。
コード例
ユーザーデータベースとやり取りする必要があるサービスを想像してみましょう。
// src/lib.rs // 1. データベース操作のためのトレイトを定義 pub trait UserRepository { fn get_user(&self, id: u32) -> Option<String>; fn save_user(&self, id: u32, name: String) -> bool; } // 2. 具体的な実装(例:実際のデータベースクライアント) // 実際のアプリケーションでは、これはDBに接続するでしょう。 #[derive(Debug)] pub struct RealDbRepository; impl UserRepository for RealDbRepository { fn get_user(&self, id: u32) -> Option<String> { println!("Real DB: Fetching user with ID {}", id); // データベース検索をシミュレート match id { 1 => Some("Alice".to_string()), _ => None, } } fn save_user(&self, id: u32, name: String) -> bool { println!("Real DB: Saving user ID {} with name {}", id, name); // データベース保存をシミュレート true } } // UserRepositoryを使用するアプリケーションサービス pub struct UserService<R: UserRepository> { repository: R, } impl<R: UserRepository> UserService<R> { pub fn new(repository: R) -> Self { UserService { repository } } pub fn fetch_and_display_user(&self, user_id: u32) -> String { match self.repository.get_user(user_id) { Some(name) => format!("User found: {}", name), None => format!("User with ID {} not found", user_id), } } } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; use std::sync::Mutex; // 並行テスト実行のため // 3. テストのためのモック実装 pub struct MockUserRepository { // Mutexを使用して、テスト間で可変アクセスを許可し、 // 呼び出しを記録してアサートできるようにします。 pub users: Mutex<HashMap<u32, String>>, pub get_user_calls: Mutex<Vec<u32>>, pub save_user_calls: Mutex<Vec<(u32, String)>>, } impl MockUserRepository { pub fn new(initial_users: HashMap<u32, String>) -> Self { MockUserRepository { users: Mutex::new(initial_users), get_user_calls: Mutex::new(Vec::new()), save_user_calls: Mutex::new(Vec::new()), } } } impl UserRepository for MockUserRepository { fn get_user(&self, id: u32) -> Option<String> { self.get_user_calls.lock().unwrap().push(id); self.users.lock().unwrap().get(&id).cloned() } fn save_user(&self, id: u32, name: String) -> bool { self.save_user_calls.lock().unwrap().push((id, name.clone())); self.users.lock().unwrap().insert(id, name); true } } #[test] fn test_fetch_existing_user() { let mut initial_users = HashMap::new(); initial_users.insert(1, "Alice".to_string()); let mock_repo = MockUserRepository::new(initial_users); let user_service = UserService::new(mock_repo); let result = user_service.fetch_and_display_user(1); assert_eq!(result, "User found: Alice"); assert_eq!(user_service.repository.get_user_calls.lock().unwrap().len(), 1); assert_eq!(user_service.repository.get_user_calls.lock().unwrap()[0], 1); } #[test] fn test_fetch_non_existing_user() { let mock_repo = MockUserRepository::new(HashMap::new()); let user_service = UserService::new(mock_repo); let result = user_service.fetch_and_display_user(99); assert_eq!(result, "User with ID 99 not found"); assert_eq!(user_service.repository.get_user_calls.lock().unwrap().len(), 1); assert_eq!(user_service.repository.get_user_calls.lock().unwrap()[0], 99); } }
アプリケーションシナリオ
トレイトベースモッキングは、以下のようなシナリオに理想的です。
- 型システムを堅牢に保ち、Rustの保証を活用したい場合。
- モックの内部状態と動作を完全に制御したい場合。
- モッキングの要件が比較的単純で、各モックのボイラープレートコードを書くことに抵抗がない場合。
- 外部モッキングフレームワークなしで、「ゼロコスト抽象化」アプローチを好む場合。
Mockall: 強力なモッキングフレームワーク
トレイトベースモッキングは効果的ですが、複雑なインターフェースやメソッド呼び出しに対する動的な期待を定義する必要がある場合、冗長になる可能性があります。mockallは、トレイトのモック実装を自動生成することで、モックオブジェクトの作成を簡略化する人気のRustクレートです。メソッド呼び出しに対する期待を設定し、定義済みの値を返却し、呼び出しを記録して後で検証することができます。
原理と実装
mockallは、プロシージャルマクロを使用してコンパイル時にモック構造体とその実装を生成します。トレイトに #[automock]をアノテートすると、mockallは対応するモック構造体を作成します。
- mockall依存関係の追加:- Cargo.tomlに- mockallを含めます。
- トレイトのアノテート: トレイト定義の上に #[automock]を追加します。
- モックオブジェクトの生成: mockallは自動的にMockTraitNameという構造体を生成し、トレイトを実装します。
- 期待値の設定: モックオブジェクトの expect_*()メソッドを使用して、特定のメソッド呼び出しに対してどのように動作するかを定義します。これには、返却値、引数、呼び出し回数の指定が含まれます。
コード例
mockallを使用してユーザーリポジトリの例を再実装してみましょう。
// Cargo.toml // [dev-dependencies] // mockall = "0.12" // src/lib.rs // UserServiceやRealDbRepositoryの変更なし #[cfg(test)] // mockallは通常dev-dependencyです mod tests { use super::*; use mockall::{automock, predicate::*}; // automockとpredicateをインポート // 1. トレイトに#[automock]をアノテートします #[automock] pub trait UserRepository { fn get_user(&self, id: u32) -> Option<String>; fn save_user(&self, id: u32, name: String) -> bool; } #[test] fn test_fetch_existing_user_with_mockall() { // 2. mockallはMockUserRepositoryを生成します let mut mock_repo = MockUserRepository::new(); // 3. 期待値の設定 // get_user()が1で呼び出されたら、Some("Alice".to_string())を返却するはずです // そして、1回だけ呼び出されるべきです。 mock_repo.expect_get_user() .with(eq(1)) // 引数に一致させるためにpredicateを使用 .times(1) .returning(|_| Some("Alice".to_string())); let user_service = UserService::new(mock_repo); let result = user_service.fetch_and_display_user(1); assert_eq!(result, "User found: Alice"); // このアサートはUserServiceの出力のみに基づいています // mock_repoは、ドロップされるか、.checkpoint()が呼び出されたときに期待値をアサートします。 } #[test] fn test_fetch_non_existing_user_with_mockall() { let mut mock_repo = MockUserRepository::new(); // get_user()がいかなるu32でも呼び出されたら、Noneを返却するはずです。 // 1回だけ呼び出されるべきです。 mock_repo.expect_get_user() .with(always()) // 任意の入力を一致させる .times(1) .returning(|_| None); let user_service = UserService::new(mock_repo); let result = user_service.fetch_and_display_user(99); assert_eq!(result, "User with ID 99 not found"); } #[test] fn test_save_user_with_mockall() { let mut mock_repo = MockUserRepository::new(); mock_repo.expect_save_user() .with(eq(101), eq("Bob".to_string())) .times(1) .returning(|_, _| true); let user_service = UserService::new(mock_repo); // 実際のシナリオでは、UserServiceは一部のロジックに基づいてsave_userを呼び出すでしょう。 // この単純なテストでは、モックの応答能力を実証するためだけに直接呼び出します。 // 注意:この例のUserServiceは、公開メソッドで`save_user`を直接呼び出しません。 // 通常、それを呼び出すメソッドをテストします。 // このテストでは、モックが応答できることを確認するだけだと仮定します。 let saved = user_service.repository.save_user(101, "Bob".to_string()); assert!(saved); } }
アプリケーションシナリオ
mockallは、以下のような状況で優れています。
- 複雑なインターフェース: メソッドが多数あり、手動でモックを実装するのが面倒なトレイトがある場合。
- 動的な期待値: 引数に基づいて同じメソッドに対して異なる動作を定義したり、呼び出し順序/回数を検証したりする必要がある場合。
- リファクタリング: モック実装を手動で更新する必要がないため、リファクタリングが容易になります。
- ボイラープレートコードの削減: モッキングに必要なボイラープレートコードを大幅に削減します。
- 動作の検証: 特定のメソッドが特定の引数で、何回呼び出されたか を 検証したい場合。
結論
トレイトベースモッキングとmockallは、Rustでデータベースや外部サービスをモックするための堅牢なソリューションを提供しており、それぞれに強みがあります。トレイトベースモッキングは、手動実装のコストで、きめ細かな制御を提供する軽量でRustらしいアプローチです。一方、mockallはモッキングプロセスの多くを自動化し、より複雑で動的なモッキングシナリオのための強力で機能豊富なフレームワークを提供し、ボイラープレートコードを大幅に削減します。どちらを選ぶかは、プロジェクトの複雑さ、チームの好み、テストの特定の要件によって異なります。最終的に、選択したアプローチに関係なく、効果的なモッキングは、外部依存関係からコードを分離することによって、テスト容易性、保守性、信頼性の高いRustアプリケーションを作成できるようにします。

