Rustでテストを書いていると、「外部APIやデータベースに依存する処理をどうテストするか」という壁にぶつかります。他言語なら依存オブジェクトをモックに差し替えて済む場面でも、Rustでは所有権・借用ルールや厳格な型システムが絡むため、同じようにはいきません。
Rustのモックテストには複数のアプローチがあり、traitベースのDI設計を軸にしたmockall、HTTPレベルのモックサーバーを提供するmockitoやwiremock、ダミーデータ生成のfake、スナップショット比較のinstaといったクレートが用途ごとに使い分けられています。
Rustにおけるテストダブルの分類
モック(Mock)はテストダブル(Test Double)の一種です。テストダブルとは、テスト対象が依存するコンポーネントの「代役」を指す総称で、目的に応じて以下のように分かれます。
| 種類 | 役割 | Rustでの典型的な実現手段 |
|---|---|---|
| Stub | 固定値を返す | traitの手動実装 / mockallのreturning() |
| Mock | 呼び出し内容を検証する | mockallのexpect_*() + times() |
| Fake | 簡易的な代替実装 | HashMapによるインメモリリポジトリ |
| Spy | 実処理を通しつつ記録する | RefCell<Vec<T>>で呼び出し履歴を保持 |
Rustの型システムでは、依存コンポーネントの差し替えにtraitを使うのが一般的です。本番用の構造体とテスト用の構造体が同じtraitを実装し、ジェネリクスまたはトレイトオブジェクト経由で注入します。
traitとジェネリクスによるDI設計
外部ライブラリを使わずにモックテストを実現する基本パターンです。依存をtrait化し、テスト時に別の実装を差し込みます。
trait定義とジェネリクスによる注入
// domain層: リポジトリのインターフェイス
pub trait UserRepository {
fn find_by_id(&self, id: u64) -> Option<String>;
fn save(&self, id: u64, name: &str) -> Result<(), String>;
}
// ユースケース: ジェネリクスで依存を受け取る
pub struct GetUserUseCase<R: UserRepository> {
repo: R,
}
impl<R: UserRepository> GetUserUseCase<R> {
pub fn new(repo: R) -> Self {
Self { repo }
}
pub fn execute(&self, id: u64) -> String {
self.repo
.find_by_id(id)
.unwrap_or_else(|| "unknown".to_string())
}
}
テスト側でのStub実装
#[cfg(test)]
mod tests {
use super::*;
struct StubUserRepo {
users: std::collections::HashMap<u64, String>,
}
impl UserRepository for StubUserRepo {
fn find_by_id(&self, id: u64) -> Option<String> {
self.users.get(&id).cloned()
}
fn save(&self, _id: u64, _name: &str) -> Result<(), String> {
Ok(())
}
}
#[test]
fn returns_user_name_when_found() {
let mut users = std::collections::HashMap::new();
users.insert(1, "Alice".to_string());
let repo = StubUserRepo { users };
let uc = GetUserUseCase::new(repo);
assert_eq!(uc.execute(1), "Alice");
}
#[test]
fn returns_unknown_when_not_found() {
let repo = StubUserRepo {
users: std::collections::HashMap::new(),
};
let uc = GetUserUseCase::new(repo);
assert_eq!(uc.execute(999), "unknown");
}
}
この手動実装は依存ライブラリが不要で仕組みも明快ですが、traitのメソッド数が増えると、テストに使わないメソッドまで実装する手間が発生します。mockallを使えばこの問題を解決できます。
mockallクレートでtraitをモック化する
mockallはRustで最も利用されているモックライブラリです(crates.ioでの累計ダウンロード数は1億回超)。#[automock]属性をtrait定義に付与するだけで、MockXxx構造体が自動生成されます。
導入手順
Cargo.tomlの[dev-dependencies]に追加します。
[dev-dependencies]
mockall = "0.14"
基本的な使い方
use mockall::automock;
#[automock]
pub trait UserRepository {
fn find_by_id(&self, id: u64) -> Option<String>;
fn save(&self, id: u64, name: &str) -> Result<(), String>;
}
#[cfg(test)]
mod tests {
use super::*;
use mockall::predicate::eq;
#[test]
fn mock_returns_expected_value() {
let mut mock = MockUserRepository::new();
// find_by_id(1)が呼ばれたら"Alice"を返す
mock.expect_find_by_id()
.with(eq(1))
.times(1)
.returning(|_| Some("Alice".to_string()));
let uc = GetUserUseCase::new(mock);
assert_eq!(uc.execute(1), "Alice");
}
#[test]
fn verify_save_is_called() {
let mut mock = MockUserRepository::new();
mock.expect_save()
.with(eq(42), eq("Bob"))
.times(1)
.returning(|_, _| Ok(()));
// find_by_idも最低限セットアップ
mock.expect_find_by_id()
.returning(|_| None);
assert!(mock.save(42, "Bob").is_ok());
}
}
Expectation APIの主要メソッド
| メソッド | 説明 | 使用例 |
|---|---|---|
expect_メソッド名() | 期待する呼び出しを定義する | mock.expect_find_by_id() |
.with(predicate) | 引数の条件を指定する | .with(eq(1)) |
.times(n) | 呼び出し回数を指定する | .times(1), .times(0..=3) |
.returning(closure) | 戻り値を定義する | .returning(|_| Some("Alice".into())) |
.once() | 1回だけ呼ばれることを期待する | .once() |
.never() | 呼ばれないことを検証する | .never() |
具象型(struct)のモック化
traitではなくstructを直接モック化する場合は、mockall::automockに加えてmockall_doubleクレートの#[double]属性を使います。
// src/client.rs
pub struct ApiClient;
#[cfg_attr(test, mockall::automock)]
impl ApiClient {
pub fn fetch(&self, endpoint: &str) -> Result<String, String> {
// 本番ではHTTP通信を実行
todo!()
}
}
// src/service.rs
#[cfg(test)]
use mockall_double::double;
#[cfg_attr(test, double)]
use crate::client::ApiClient;
pub fn get_status(client: &ApiClient) -> String {
match client.fetch("/health") {
Ok(body) => body,
Err(_) => "unavailable".to_string(),
}
}
rust-analyzerのエラー対策
#[automock]で生成されるMockXxxはコンパイル時に作られるため、rust-analyzerがunresolved importエラーを表示することがあります。.vscode/settings.jsonに以下を追加すると解消します。
{
"rust-analyzer.cargo.features": "all",
"rust-analyzer.diagnostics.disabled": ["unresolved-import"]
}
mockitoとwiremockでHTTP通信をモック化する
外部APIへのHTTPリクエストをテストする場合、ローカルにモックサーバーを立てる方法が適しています。Rustではmockitoとwiremockが代表的な選択肢です。
mockitoの使い方
mockitoはテストごとにローカルHTTPサーバーを起動し、指定したパス・メソッドに対するレスポンスを定義できます。
[dev-dependencies]
mockito = "1.7"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use serde::Deserialize;
#[derive(Debug, Deserialize, PartialEq)]
pub struct Todo {
pub id: u32,
pub title: String,
pub completed: bool,
}
pub async fn fetch_todo(base_url: &str, id: u32) -> Result<Todo, String> {
let url = format!("{}/todos/{}", base_url, id);
let resp = reqwest::get(&url)
.await
.map_err(|e| e.to_string())?;
resp.json::<Todo>()
.await
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn fetch_todo_returns_parsed_response() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/todos/1")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"id":1,"title":"Buy milk","completed":false}"#)
.create_async()
.await;
let result = fetch_todo(&server.url(), 1).await.unwrap();
assert_eq!(result, Todo {
id: 1,
title: "Buy milk".to_string(),
completed: false,
});
mock.assert_async().await; // リクエストが1回来たことを検証
}
}
wiremockの使い方
wiremockはより柔軟なマッチング機能とテスト分離を提供します。各テストが独立したランダムポートで動作するため、並列実行時の干渉がありません。
[dev-dependencies]
wiremock = "0.6"
#[cfg(test)]
mod tests {
use wiremock::{MockServer, Mock, ResponseTemplate};
use wiremock::matchers::{method, path};
#[tokio::test]
async fn wiremock_get_example() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/users/1"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"name": "Alice"}))
)
.expect(1)
.mount(&mock_server)
.await;
let resp = reqwest::get(format!("{}/api/users/1", mock_server.uri()))
.await
.unwrap();
assert_eq!(resp.status(), 200);
}
}
mockitoとwiremockの違い
| 観点 | mockito | wiremock |
|---|---|---|
| サーバー分離 | サーバープール方式 | テストごとにランダムポート |
| async対応 | _asyncサフィックスの非同期版メソッドを提供 | 全APIがasync前提 |
| マッチャーの拡張 | 正規表現・JSON・クエリパラメータ | Match traitによるカスタムマッチャー |
| 呼び出し検証 | mock.assert() | .expect(n) + 自動検証 |
| crates.ioダウンロード数 | 約3,200万回 | 約4,100万回 |
| ランタイム | tokio / async-std | tokio / async-std |
HTTPモック目的であれば、テスト分離の堅牢性を重視する場合はwiremock、シンプルなAPI設計を好む場合はmockitoが適しています。
fakeクレートでテストデータを自動生成する
テストコード内でダミーの名前・メールアドレス・UUIDなどを手書きしていると、可読性が下がり保守コストが増えます。fakeクレートを使えば、型に応じたランダムなテストデータを一行で生成できます。
[dev-dependencies]
fake = { version = "4.4", features = ["derive"] }
rand = "0.8"
基本的な使い方
use fake::{Fake, Faker};
use fake::faker::name::en::Name;
use fake::faker::internet::en::SafeEmail;
#[test]
fn generate_fake_data() {
let name: String = Name().fake();
let email: String = SafeEmail().fake();
let age: u32 = (18..65).fake();
assert!(!name.is_empty());
assert!(email.contains('@'));
assert!(age >= 18 && age < 65);
}
Dummy deriveで構造体ごと生成する
use fake::Dummy;
#[derive(Debug, Dummy)]
pub struct User {
#[dummy(faker = "1..1000")]
pub id: u64,
#[dummy(faker = "fake::faker::name::en::Name()")]
pub name: String,
#[dummy(faker = "fake::faker::internet::en::SafeEmail()")]
pub email: String,
}
#[test]
fn generate_dummy_user() {
let user: User = Faker.fake();
assert!(user.id >= 1 && user.id < 1000);
assert!(!user.name.is_empty());
}
fakeはテストデータ生成に特化しており、モック(振る舞いの検証)とは役割が異なります。mockallと組み合わせて「モックの戻り値にfakeで生成したデータを返す」という使い方が実践的です。
instaクレートでスナップショットテスト
instaはテスト結果のスナップショット(期待値のスクリーンショット)をファイルに保存し、次回実行時に差分を比較するクレートです。JSON・YAML・文字列など複雑な出力を持つ処理のリグレッションテストに向いています。
[dev-dependencies]
insta = { version = "1.46", features = ["json"] }
基本的な使い方
use insta::assert_json_snapshot;
use serde_json::json;
#[test]
fn snapshot_api_response() {
let response = json!({
"status": "ok",
"users": [
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"}
]
});
assert_json_snapshot!(response);
}
初回実行時はsnap.newファイルが生成されます。cargo insta reviewコマンドで差分を確認し、承認すると.snapファイルとして保存されます。以降のテストではこのスナップショットと比較されます。
# スナップショットの確認・承認
cargo install cargo-insta
cargo insta review
出力が変わった場合は差分が表示されるため、意図した変更かどうかをレビューして承認します。手動でassertの期待値を書き換える必要がないため、出力が頻繁に変わる開発フェーズで特に有効です。
モック関連クレートの比較
各クレートの特性を一覧にまとめます。
| クレート | 用途 | 最新バージョン | 累計DL数 | 非同期対応 | 学習コスト |
|---|---|---|---|---|---|
| mockall | trait/structのモック自動生成 | 0.14.0 | 約1億回 | #[async_trait]併用で対応 | 中 |
| mockito | HTTPモックサーバー | 1.7.2 | 約3,200万回 | _asyncメソッドを提供 | 低 |
| wiremock | HTTPモックサーバー | 0.6.5 | 約4,100万回 | 全APIがasync | 中 |
| fake | テストデータ生成 | 4.4.0 | 約1,000万回 | 不要(データ生成のみ) | 低 |
| insta | スナップショットテスト | 1.46.3 | 約4,800万回 | 不要(出力比較のみ) | 低 |
用途別の選び方
テストの対象と目的に応じて、適切なクレートが異なります。
ビジネスロジックの単体テスト → mockall
- リポジトリやサービス層のtrait依存をモック化して、ロジック部分だけを検証します
- 呼び出し回数や引数の検証が必要な場面に最適です
外部APIとのHTTP通信テスト → mockito または wiremock
- ローカルサーバーを立てて、HTTPレスポンスをシミュレーションします
reqwestやhyperでHTTP通信する処理のテストに使います
テストデータの効率的な準備 → fake
- 大量のダミーデータが必要なテストや、テストごとに異なるランダム値を使いたい場合に適しています
mockallのreturning()内でfakeのデータ生成と組み合わせる使い方が実践的です
出力のリグレッション検証 → insta
- API応答のJSON構造やCLI出力など、出力全体の正しさを検証します
- 手書きのassertが煩雑になる複雑な出力のテストに向いています
ライブラリ不要のシンプルなStub → trait手動実装
- 依存が少なく、traitのメソッド数が少ない場合は手動でStubを書く方が早い場合もあります
- プロジェクトの依存を増やしたくない場合に有効です
テスト設計のプラクティス
trait設計でモックしやすいコードにする
モック化の前提として、依存コンポーネントがtrait経由で注入されている設計が必要です。以下のポイントを押さえると、テストしやすいRustコードになります。
- ビジネスロジックは具体的な構造体ではなくtrait境界のジェネリクスで受け取る
- I/Oや外部通信を行う処理はtrait化して、ドメインロジックから分離する
#[cfg(test)]ブロック内でモック実装を配置し、本番コードを汚さない
// よい設計: traitで抽象化
pub fn process_order<R: OrderRepository, N: Notifier>(
repo: &R,
notifier: &N,
order_id: u64,
) -> Result<(), String> {
let order = repo.find(order_id).ok_or("not found")?;
notifier.send(&format!("Order {} processed", order.id))?;
Ok(())
}
非同期traitのモック
async fnを含むtraitをモック化する場合、async-traitクレートとmockallを組み合わせます。
use async_trait::async_trait;
use mockall::automock;
#[automock]
#[async_trait]
pub trait AsyncUserRepository {
async fn find_by_id(&self, id: u64) -> Option<String>;
}
#[cfg(test)]
mod tests {
use super::*;
use mockall::predicate::eq;
#[tokio::test]
async fn async_mock_test() {
let mut mock = MockAsyncUserRepository::new();
mock.expect_find_by_id()
.with(eq(1))
.returning(|_| Some("Alice".to_string()));
let result = mock.find_by_id(1).await;
assert_eq!(result, Some("Alice".to_string()));
}
}
Rust 1.75以降ではtrait内にasync fnを直接書けるようになりましたが、mockall 0.14時点ではasync-traitクレートとの併用が推奨されています。
まとめ
Rustのモックテストは、言語の型システムを活かしたtrait + ジェネリクスのDI設計が土台になります。その上で、目的に合ったクレートを選ぶことが効率的なテスト構築の鍵です。
- ロジック層のモック:
mockall(traitに#[automock]を付与するだけでモック構造体が生成される) - HTTP通信のモック:
mockito(手軽)/wiremock(テスト分離が堅牢) - テストデータ生成:
fake(名前・メール・数値などをランダムに生成) - 出力のスナップショット:
insta(JSON/YAMLの出力を自動比較)
各クレートの公式リポジトリは以下のとおりです。