ソフトウェアテスト

続き、改めてテストダブルの整理。その整理をベースに Rust での実装例を ChatGPT に書かせてみる。Claude Code Codex と比較。

テストダブルの説明と Rust の実装例
テストダブルとは?
テストダブル test doubles は「本物の依存の代わりに使うテスト用の代替物」の総称。
依存を差し替えることでテストを 速く・安定して・狙ったシナリオで 行えるようにする。
-
テストダブル, test double
- 定義: ソフトウェアテストにおいて、テスト対象のプログラムの依存関係の代替として動作するもの。用途によって、Dummy / Stub / Mock / Spy / Fake の 5 つの種類がある。
- 意図: 本物の依存を差し替えることで、テストを速くし、制御しやすくし、失敗箇所を明確にする。
- 用途:
- DB に繋がずにテストしたい
- ネットワークや外部 API を呼びたくない
- 振る舞い(呼び出し方や順序)を確認したい
- 再現が難しいケース(エラーやタイムアウト)を意図的に起こしたい
1) Dummy(ダミー)
- 定義: 型を埋めるためだけに存在する代替物。呼ばれる想定はない。
- 意図: コンパイルや関数シグネチャを満たすためだけに置く。
- 用途: テストで依存を全く使わないのに、引数やフィールドで必須なとき。
pub struct DummyRepo;
impl UserRepo for DummyRepo {
fn find_by_id(&self, _id: &str) -> Option<User> { unreachable!() }
fn save(&self, _user: User) { unreachable!() }
}
2) Stub(スタブ)
- 定義: 呼ばれたら「決められた結果」を返すだけの代替物。
- 意図: 依存の出力を固定して、上流のロジックをテストしやすくする。
- 用途: 「成功ならこう振る舞う」「失敗ならこう返る」といった分岐を確実に試したいとき。
pub struct StubRepo { pub result: Option<User> }
impl UserRepo for StubRepo {
fn find_by_id(&self, _id: &str) -> Option<User> { self.result.clone() }
fn save(&self, _user: User) {}
}
3) Mock(モック)
- 定義: 呼び出し方(回数や引数)が正しいかを検証する代替物。
- 意図: 「こう呼ばれるべきだ」という契約をテストする。
- 用途: データ保存、通知送信、外部 API 呼び出しなど「呼び出しの有無や内容」が重要なとき。
use std::cell::RefCell;
pub struct MockRepo {
pub saved: RefCell<Vec<User>>,
}
impl UserRepo for MockRepo {
fn find_by_id(&self, _id: &str) -> Option<User> { None }
fn save(&self, user: User) { self.saved.borrow_mut().push(user); }
}
4) Spy(スパイ)
- 定義: 本物の処理を実行しながら、呼び出し状況を記録する代替物。
- 意図: 実際の挙動を保ちながら「何回呼ばれたか」を観測する。
- 用途: 「呼ばれたかどうか」だけでなく「処理も動かしたい」場面。キャッシュ、ラッパー、メトリクス収集など。
use std::cell::Cell;
pub struct SpyRepo<R: UserRepo> {
inner: R,
pub save_calls: Cell<usize>,
}
impl<R: UserRepo> UserRepo for SpyRepo<R> {
fn find_by_id(&self, id: &str) -> Option<User> { self.inner.find_by_id(id) }
fn save(&self, user: User) {
self.save_calls.set(self.save_calls.get() + 1);
self.inner.save(user)
}
}
5) Fake(フェイク)
- 定義: 本物に近いけど簡易化された実装。ちゃんと動くが軽量。
- 意図: 複雑な依存(DBや外部API)の代わりに、テスト用の「動く代替品」を用意する。
- 用途: インメモリDBや簡易キャッシュなど。I/O を避けつつ現実的な動作を確認したいとき。
use std::collections::HashMap;
use std::cell::RefCell;
pub struct InMemoryRepo {
store: RefCell<HashMap<String, User>>,
}
impl UserRepo for InMemoryRepo {
fn find_by_id(&self, id: &str) -> Option<User> {
self.store.borrow().get(id).cloned()
}
fn save(&self, user: User) {
self.store.borrow_mut().insert(user.id.clone(), user);
}
}
6) Test Double(総称)
- 定義: Dummy / Stub / Mock / Spy / Fake をひっくるめた総称。
- 意図: 本物の依存を差し替えることで、テストを速くし、制御しやすくし、失敗箇所を明確にする。
-
用途:
- DB に繋がずにテストしたい
- ネットワークや外部 API を呼びたくない
- 振る舞い(呼び出し方や順序)を確認したい
- 再現が難しいケース(エラーやタイムアウト)を意図的に起こしたい
📌 使い分けまとめ(再掲)
- 戻り値を固定したい → Stub
- 呼び出しの仕方を検証したい → Mock
- 処理も動かしつつ記録したい → Spy
- 軽量で現実的な代替実装が欲しい → Fake
- ただの穴埋め → Dummy

モックとスタブの違い
「スタブ」と「モック」はどっちも“本物の代わり”に見えるから、線引きが曖昧になりがち。平たくいうと、スタブは「何を返すか」に注目する」「モックは「どう呼ばれたか」に注目する」。
スタブ (Stub)
-
定義: 「呼ばれたら決まった値を返すだけ」の代替物。
-
狙い: 外部依存の出力を固定して、テスト対象の処理を確実に進める。
-
用途: 成功ケースやエラーケースを意図的に作りたいとき。
- 例: DB から必ずユーザーを返す/必ず
None
を返す
- 例: DB から必ずユーザーを返す/必ず
pub struct StubRepo { pub result: Option<User> }
impl UserRepo for StubRepo {
fn find_by_id(&self, _id: &str) -> Option<User> { self.result.clone() }
fn save(&self, _user: User) {}
}
#[test]
fn service_uses_stubbed_result() {
let repo = StubRepo { result: Some(User { id: "1".into(), name: "Alice".into() }) };
let service = UserService::new(repo);
assert_eq!(service.display_name("1"), "Alice");
}
👉 ポイント: 「返ってくるデータを決め打ち」できる。
モック (Mock)
-
定義: 「どう呼ばれたか(回数・引数)」を検証する代替物。
-
狙い: 「呼ばれること自体」や「呼ばれ方の契約」を確認する。
-
用途: 外部 API コール、メール送信、DB 保存など「呼び出しが行われたこと」が重要なとき。
- 例:
save()
が必ず一回呼ばれているか?
- 例:
use std::cell::RefCell;
pub struct MockRepo {
pub saved: RefCell<Vec<User>>,
}
impl UserRepo for MockRepo {
fn find_by_id(&self, _id: &str) -> Option<User> { None }
fn save(&self, user: User) { self.saved.borrow_mut().push(user); }
}
#[test]
fn service_calls_save_once() {
let repo = MockRepo { saved: RefCell::new(vec![]) };
let service = Registration::new(&repo);
service.register("42", "Bob");
// 「正しく呼ばれたか」を検証
assert_eq!(repo.saved.borrow().len(), 1);
assert_eq!(repo.saved.borrow()[0].name, "Bob");
}
👉 ポイント: 「返す値」ではなく「呼ばれた証拠」を見る。
まとめ
- スタブ → 「結果を作る」:テスト対象に必要なデータを渡すため
- モック → 「行動を監視する」:テスト対象が正しく依存を呼んでいるか確認するため
例えるなら:
- スタブは「シナリオ用のセリフを棒読みする代役」
- モックは「監督が“セリフをちゃんと言ったか”をチェックする代役」

テスト対象のモジュール、サービスの依存関係を、契約(インタフェース)は保ったまま代替するのがテストダブル。「契約(インタフェース)は保ったまま」って表現がかなりしっくり来る。
Test Double(テストダブル)の定義・表現案
推奨の一文(実務向け)
テストダブルとは、テスト対象が相互作用する 外部の協働体(依存や資源) を、
その 契約(インタフェース/プロトコル)を保ったまま 制御可能・観測可能な代替実装 に置き換えたもの。
もっと短く
- 外部依存の 契約互換な“替え玉”実装。
- テスト対象の 境界の相手 を、テスト用の 代替実装 に差し替えること。
もう少し丁寧
テスト対象とやり取りする 外部の接点(コラボレータ/資源:DB、HTTP API、ファイル、時刻、乱数、 メッセージブローカ、環境変数、関数コールバック、別プロセス/スレッド、デバイス等) を、
期待する契約を満たす仮実装 に置き換え、入力を自在に制御し、呼び出しや副作用を観測できるようにする仕組み。
含意(定義に入れ込むとブレない要素)
- 契約互換:同じインタフェース/プロトコルで差し替え可能
- 制御可能:成功・失敗・遅延・例外などを注入できる
- 観測可能:呼び出し回数・引数・順序などを記録できる
- 外部協働体の広さ:オブジェクトや“サービス”に限らず、関数・モジュール・OS資源・プロセス・デバイスまで
用語のバリエーション(文脈でチョイス)
- 代替実装 / 置き換え体 / スタンドイン / 身代わり実装 / 仮実装
- 外部協働体(collaborator)/ 依存点 / 境界の相手

Dummy の実装例
ポイント
- ダミーは「このテストでは依存を絶対に触らない」を強制するための楔(くさび)。
- 依存を使うテストではダミーを使わない(スタブ/フェイク/モック/スパイに切り替える)。
- つまりダミーは “純粋な計算テスト” の守衛。触ったら即退場(panic)でバグを可視化。
ソースコード
//! Dummy(ダミー)の実践例:
//! - 依存を「穴埋め」するだけ。呼ばれたら即パニックでバグを炙り出す。
//! - テスト対象のコードパスで本当に依存を触っていないかを保証できる。
/// 依存インターフェース(例:メール送信)
pub trait Mailer {
fn send(&self, to: &str, subject: &str, body: &str);
}
/// 依存インターフェース(例:支払いゲートウェイ)
pub trait PaymentGateway {
fn charge(&self, user_id: &str, amount_jpy: u64) -> bool;
}
/// ダミー:呼ばれたらダメ。呼ばれたら panic! で即発見できる。
pub struct DummyMailer;
impl Mailer for DummyMailer {
fn send(&self, _to: &str, _subject: &str, _body: &str) {
panic!("DummyMailer should never be called");
}
}
pub struct DummyPayment;
impl PaymentGateway for DummyPayment {
fn charge(&self, _user_id: &str, _amount_jpy: u64) -> bool {
panic!("DummyPayment should never be called");
}
}
/// テスト対象となるユースケース例
pub struct OrderService<M: Mailer, P: PaymentGateway> {
mailer: M,
payment: P,
}
impl<M: Mailer, P: PaymentGateway> OrderService<M, P> {
pub fn new(mailer: M, payment: P) -> Self {
Self { mailer, payment }
}
/// 見積り計算:I/O もしないし、副作用もない純粋関数的な処理。
/// 本メソッドのテストでは Mailer/Payment は **一切使わない**。
pub fn quote_total(&self, unit_price: u64, qty: u32, discount_pct: u8) -> u64 {
let subtotal = unit_price.saturating_mul(qty as u64);
let discount = subtotal.saturating_mul(discount_pct as u64) / 100;
subtotal.saturating_sub(discount)
}
/// 実際の購入フロー:ここでは Mailer/Payment を使う。
pub fn checkout(&self, user_id: &str, amount_jpy: u64) -> bool {
let ok = self.payment.charge(user_id, amount_jpy);
if ok {
self.mailer
.send(user_id, "Thanks!", "注文ありがとうございました。");
}
ok
}
}
#[cfg(test)]
mod tests {
use super::*;
/// ✅ ダミーの王道:このテストは純粋計算のみなので、Mailer/Payment を触らない。
/// もしうっかり触ったら Dummy が panic して、意図せぬ依存を即検知できる。
#[test]
fn quote_total_does_not_touch_dependencies() {
let svc = OrderService::new(DummyMailer, DummyPayment);
let total = svc.quote_total(1200, 3, 10); // 1200 * 3 = 3600, 10% off => 3240
assert_eq!(total, 3240);
}
/// ❌ これは意図的に失敗例:ダミーは「呼ばれたらアウト」を可視化する。
///(コメントアウトを外すとテスト失敗し、依存に触れたことが分かる)
// #[test]
// #[should_panic(expected = "DummyPayment should never be called")]
// fn checkout_accidentally_calls_payment_but_we_used_dummy() {
// let svc = OrderService::new(DummyMailer, DummyPayment);
// let _ = svc.checkout("user_1", 5000); // ダミーなので panic
// }
/// 参考:ダミーではなく「スタブ/フェイク」を使うべき場面もある。
/// checkout をテストしたいなら、`PaymentGateway` をスタブし、
/// `Mailer` をモック/スパイにする(ここではダミーのスコープ外)。
}

OrderService::new(DummyMailer, DummyPayment)
はこれで初期化できるんだっけ?構造体に new
とか {}
いらないの?
Q. ダミーのテストの いい視点!確かに OrderService::new(DummyMailer, DummyPayment)
はそのままだと Rust の型システムに引っかかる。
Rust の構造体にジェネリクスを持たせる場合、こんなふうに書いてたよね:
pub struct OrderService<M: Mailer, P: PaymentGateway> {
mailer: M,
payment: P,
}
ここで OrderService::new
は次の形にしてあったはず:
impl<M: Mailer, P: PaymentGateway> OrderService<M, P> {
pub fn new(mailer: M, payment: P) -> Self {
Self { mailer, payment }
}
}
つまり new
の引数は それぞれのトraitを実装した型のインスタンス。
DummyMailer
と DummyPayment
は「単なる型」であって、値ではないから、正しくはこう呼ぶ必要がある:
let svc = OrderService::new(DummyMailer, DummyPayment);
が、これは ゼロサイズ構造体ならそのまま値として使えるんだよ。
Rust では struct DummyMailer;
とフィールドなしのユニット構造体にしていると、型名がそのまま「値」として扱える。
もし struct DummyMailer {}
のように中括弧付きで定義していたら、明示的にインスタンスを作る必要がある:
let svc = OrderService::new(DummyMailer {}, DummyPayment {});
✅ まとめ
-
struct DummyMailer;
のようにユニット構造体ならDummyMailer
だけで値になるので OK。 -
struct DummyMailer {}
と書いた場合はDummyMailer {}
と書かないと初期化できない。

Stub の実装例
ポイント
- 狙い: 依存の「返り値」をテスト用に固定して、上位ロジックの分岐(成功/失敗/例外系)を確実に通す。
- やらないこと: 「何回/どう呼ばれたか」の検証はしない(それはモック/スパイの仕事)。
-
使い所:
- DB・外部APIの成功パス/失敗パスを作り打ちしたい
- エラーやタイムアウトなど再現しづらい状況を即席で作りたい
- I/Oを一切避けてビジネスロジックの分岐だけテストしたい
-
実務Tip:
Stub
は入力→出力の対応を「テーブル駆動」的に設定できると強い。
呼び方の検証が必要になった瞬間、それはMock/Spy
案件。
補足(現場感)
- 失敗ケースを「好きなだけ」作れるのがスタブの強み。ネットワークや外部SLAを待たずに即テスト。
- 「呼ばれた/呼ばれない」を検証したくなったら、それはモック/スパイへ出番交代。
- テーブル駆動のwhen(...)で条件分岐の網羅がやりやすくなる。テストの意図も読みやすい。
ソースコード
//! Stub(スタブ)の実践例:
//! - 依存の返り値を「決め打ち」して、上位ロジックの分岐を確実にテストする。
//! - 呼び出し回数や引数検証はしない(それはモックの領域)。
use std::collections::HashMap;
/// 依存インターフェース(例:決済ゲートウェイ)
pub trait PaymentGateway {
/// 正常: Ok(tx_id)、失敗: Err(kind)
fn charge(&self, user_id: &str, amount_jpy: u64) -> Result<String, ChargeError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChargeError {
InsufficientFunds,
GatewayDown,
Timeout,
}
/// テスト対象のユースケース
pub struct CheckoutService<P: PaymentGateway> {
payment: P,
}
impl<P: PaymentGateway> CheckoutService<P> {
pub fn new(payment: P) -> Self {
Self { payment }
}
/// 成功なら領収メッセージ、失敗ならユーザー向けエラー文言を返す
pub fn checkout(&self, user_id: &str, amount: u64) -> String {
match self.payment.charge(user_id, amount) {
Ok(tx) => format!("OK: tx={}", tx),
Err(ChargeError::InsufficientFunds) => "NG: 残高不足です".into(),
Err(ChargeError::GatewayDown) => "NG: 現在決済が混み合っています".into(),
Err(ChargeError::Timeout) => "NG: タイムアウトしました。再試行してください".into(),
}
}
}
/// ==== ここが Stub ====
/// - 返り値をテスト用に固定できる
/// - 入力ごとに期待出力をマップで指定できる(テーブル駆動)
pub struct StubPayment {
// (user_id, amount) -> Result<String, ChargeError>
table: HashMap<(String, u64), Result<String, ChargeError>>,
default: Result<String, ChargeError>,
}
impl StubPayment {
/// `default` はテーブルに無いケースの既定の返答
pub fn new(default: Result<String, ChargeError>) -> Self {
Self { table: HashMap::new(), default }
}
/// 特定の入力に対する返り値を設定
pub fn when(mut self, user: &str, amount: u64, ret: Result<String, ChargeError>) -> Self {
self.table.insert((user.to_string(), amount), ret);
self
}
}
impl PaymentGateway for StubPayment {
fn charge(&self, user_id: &str, amount_jpy: u64) -> Result<String, ChargeError> {
self.table
.get(&(user_id.to_string(), amount_jpy))
.cloned()
.unwrap_or_else(|| self.default.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn success_path_is_forced_by_stub() {
// 既定は成功。特定入力で個別指定も可能。
let payment = StubPayment::new(Ok("tx-default".into()))
.when("alice", 5000, Ok("tx-alice-5000".into()));
let svc = CheckoutService::new(payment);
// 個別設定が効く
assert_eq!(svc.checkout("alice", 5000), "OK: tx=tx-alice-5000");
// テーブルに無い入力は default へ
assert_eq!(svc.checkout("bob", 1200), "OK: tx=tx-default");
}
#[test]
fn error_paths_are_trivially_simulated() {
// 既定は残高不足にして、特定ケースだけ別エラーにする
let payment = StubPayment::new(Err(ChargeError::InsufficientFunds))
.when("charlie", 10_000, Err(ChargeError::GatewayDown))
.when("diana", 3_000, Err(ChargeError::Timeout));
let svc = CheckoutService::new(payment);
assert_eq!(svc.checkout("anyone", 1), "NG: 残高不足です");
assert_eq!(svc.checkout("charlie", 10_000), "NG: 現在決済が混み合っています");
assert_eq!(svc.checkout("diana", 3_000), "NG: タイムアウトしました。再試行してください");
}
}

Mock の実装例
ソースコード
1) 「DB 読み書き」モック … 呼び出し回数・引数・保存内容を検証
//! モックの実践例を 2 本立てで:
//! 1) 「DB 読み書き」モック … 呼び出し回数・引数・保存内容を検証
//! 2) 「Web API リクエスト」モック … URL/ヘッダ/ボディ/回数を検証
//!
//! いずれも「どう呼ばれたか」を**検証**するのがモックのポイント。
//! (戻り値の固定はスタブの仕事、ここでは“呼ばれ方”を厳しく見る)
use std::cell::{Cell, RefCell};
/// ===== 共通で使うドメイン =====
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct User {
pub id: String,
pub name: String,
}
//
// ============================================================
// 1) DB 読み書きモック
// ============================================================
//
/// DB リポジトリの抽象
pub trait UserRepo {
fn fetch(&self, id: &str) -> Option<User>;
fn save(&self, user: &User) -> Result<(), RepoError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RepoError {
Conflict,
Io,
}
/// テスト対象:存在すれば更新、無ければ作成(upsert 的なユースケース)
pub struct UserService<R: UserRepo> {
repo: R,
}
impl<R: UserRepo> UserService<R> {
pub fn new(repo: R) -> Self {
Self { repo }
}
/// ユーザー名を upsert
pub fn upsert_name(&self, id: &str, new_name: &str) -> Result<User, RepoError> {
let mut user = match self.repo.fetch(id) {
Some(mut u) => {
u.name = new_name.to_string();
u
}
None => User { id: id.to_string(), name: new_name.to_string() },
};
self.repo.save(&user)?;
Ok(user)
}
}
/// ===== 手作りモック(呼び出し履歴と期待を検証) =====
pub struct MockUserRepo {
// 返すための次回フェッチ結果
pub next_fetch: RefCell<Option<Option<User>>>,
// 返すための次回セーブ結果
pub next_save: RefCell<Option<Result<(), RepoError>>>,
// 検証用の呼び出し記録
pub fetch_calls: RefCell<Vec<String>>,
pub save_calls: RefCell<Vec<User>>,
// 期待(なければ検証しない)
pub expected_fetch_calls: Cell<Option<usize>>,
pub expected_save_calls: Cell<Option<usize>>,
}
impl MockUserRepo {
pub fn new() -> Self {
Self {
next_fetch: RefCell::new(None),
next_save: RefCell::new(None),
fetch_calls: RefCell::new(vec![]),
save_calls: RefCell::new(vec![]),
expected_fetch_calls: Cell::new(None),
expected_save_calls: Cell::new(None),
}
}
pub fn will_fetch(mut self, result: Option<User>) -> Self {
*self.next_fetch.borrow_mut() = Some(result);
self
}
pub fn will_save(mut self, result: Result<(), RepoError>) -> Self {
*self.next_save.borrow_mut() = Some(result);
self
}
pub fn expect_fetch_calls(mut self, n: usize) -> Self {
self.expected_fetch_calls.set(Some(n));
self
}
pub fn expect_save_calls(mut self, n: usize) -> Self {
self.expected_save_calls.set(Some(n));
self
}
pub fn verify(&self) {
if let Some(n) = self.expected_fetch_calls.get() {
assert_eq!(self.fetch_calls.borrow().len(), n, "fetch() call count mismatch");
}
if let Some(n) = self.expected_save_calls.get() {
assert_eq!(self.save_calls.borrow().len(), n, "save() call count mismatch");
}
}
}
impl UserRepo for MockUserRepo {
fn fetch(&self, id: &str) -> Option<User> {
self.fetch_calls.borrow_mut().push(id.to_string());
// next_fetch が設定されていればそれを一度だけ使う。無ければ None を返す。
if let Some(v) = self.next_fetch.borrow_mut().take() {
v
} else {
None
}
}
fn save(&self, user: &User) -> Result<(), RepoError> {
self.save_calls.borrow_mut().push(user.clone());
if let Some(v) = self.next_save.borrow_mut().take() {
v
} else {
Ok(())
}
}
}
#[cfg(test)]
mod db_mock_tests {
use super::*;
#[test]
fn upsert_creates_when_not_found_and_saves_once() {
// fetch は None を返す(新規作成パスへ)、save は Ok。
let repo = MockUserRepo::new()
.will_fetch(None)
.will_save(Ok(()))
.expect_fetch_calls(1)
.expect_save_calls(1);
let svc = UserService::new(&repo);
let out = svc.upsert_name("42", "Bob").unwrap();
// 呼び出し検証(モックの本領)
repo.verify();
assert_eq!(repo.fetch_calls.borrow()[0], "42");
assert_eq!(repo.save_calls.borrow()[0], User { id: "42".into(), name: "Bob".into() });
// 戻り値も確認(ロジック整合)
assert_eq!(out, User { id: "42".into(), name: "Bob".into() });
}
#[test]
fn upsert_updates_when_found_and_saves_once() {
let existing = User { id: "7".into(), name: "Old".into() };
let repo = MockUserRepo::new()
.will_fetch(Some(existing.clone()))
.will_save(Ok(()))
.expect_fetch_calls(1)
.expect_save_calls(1);
let svc = UserService::new(&repo);
let out = svc.upsert_name("7", "NewName").unwrap();
repo.verify();
assert_eq!(repo.fetch_calls.borrow()[0], "7");
// save に渡された引数の検証
assert_eq!(repo.save_calls.borrow()[0], User { id: "7".into(), name: "NewName".into() });
assert_eq!(out, User { id: "7".into(), name: "NewName".into() });
}
#[test]
fn save_error_bubbles_up_and_still_verifies_calls() {
let repo = MockUserRepo::new()
.will_fetch(None)
.will_save(Err(RepoError::Io))
.expect_fetch_calls(1)
.expect_save_calls(1);
let svc = UserService::new(&repo);
let err = svc.upsert_name("x", "Y").unwrap_err();
repo.verify();
assert_eq!(err, RepoError::Io);
}
}
2) 「Web API リクエスト」モック … URL/ヘッダ/ボディ/回数を検証
//
// ============================================================
// 2) Web API リクエスト・モック
// ============================================================
//
/// シンプルな HTTP クライアント抽象(POST JSON だけに絞る)
pub trait HttpClient {
fn post_json(&self, url: &str, body_json: &str, headers: &[(&str, &str)]) -> Result<HttpResponse, HttpError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpResponse {
pub status: u16,
pub body: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HttpError {
Network,
Timeout,
BadRequest,
}
/// テスト対象:通知送信ユースケース
pub struct Notifier<C: HttpClient> {
client: C,
endpoint: String,
token: String,
}
impl<C: HttpClient> Notifier<C> {
pub fn new(client: C, endpoint: impl Into<String>, token: impl Into<String>) -> Self {
Self { client, endpoint: endpoint.into(), token: token.into() }
}
/// Webhook に通知を投げる。200 なら Ok、それ以外・エラーは Err。
pub fn send(&self, title: &str, message: &str) -> Result<(), String> {
let url = format!("{}/notify", self.endpoint);
let body = format!(r#"{{"title":"{}","message":"{}"}}"#, escape(title), escape(message));
let headers = [("Authorization", self.token.as_str()), ("Content-Type", "application/json")];
match self.client.post_json(&url, &body, &headers) {
Ok(resp) if resp.status == 200 => Ok(()),
Ok(resp) => Err(format!("unexpected status: {}", resp.status)),
Err(e) => Err(format!("http error: {:?}", e)),
}
}
}
fn escape(s: &str) -> String {
s.replace('"', "\\\"")
}
/// ===== 手作りモック(URL/ヘッダ/ボディ/回数を検証) =====
pub struct MockHttp {
// 次に返す結果(1 回使い切り)
pub next: RefCell<Option<Result<HttpResponse, HttpError>>>,
// 呼び出し記録
pub calls: RefCell<Vec<HttpCall>>,
// 期待
pub expected_calls: Cell<Option<usize>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpCall {
pub url: String,
pub body: String,
pub headers: Vec<(String, String)>,
}
impl MockHttp {
pub fn new() -> Self {
Self { next: RefCell::new(None), calls: RefCell::new(vec![]), expected_calls: Cell::new(None) }
}
pub fn will_return(mut self, resp: Result<HttpResponse, HttpError>) -> Self {
*self.next.borrow_mut() = Some(resp);
self
}
pub fn expect_calls(mut self, n: usize) -> Self {
self.expected_calls.set(Some(n));
self
}
pub fn verify(&self) {
if let Some(n) = self.expected_calls.get() {
assert_eq!(self.calls.borrow().len(), n, "HTTP call count mismatch");
}
}
}
impl HttpClient for MockHttp {
fn post_json(&self, url: &str, body_json: &str, headers: &[(&str, &str)]) -> Result<HttpResponse, HttpError> {
self.calls.borrow_mut().push(HttpCall {
url: url.to_string(),
body: body_json.to_string(),
headers: headers.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
});
if let Some(r) = self.next.borrow_mut().take() {
r
} else {
Ok(HttpResponse { status: 200, body: "".into() })
}
}
}
#[cfg(test)]
mod http_mock_tests {
use super::*;
#[test]
fn notifier_builds_correct_request_and_succeeds() {
let http = MockHttp::new()
.will_return(Ok(HttpResponse { status: 200, body: "ok".into() }))
.expect_calls(1);
let notifier = Notifier::new(&http, "https://api.example.com", "Bearer token123");
notifier.send("Deploy", "Ship it!").unwrap();
http.verify();
let call = &http.calls.borrow()[0];
assert_eq!(call.url, "https://api.example.com/notify");
// ヘッダ検証
assert!(call.headers.iter().any(|(k, v)| k == "Authorization" && v == "Bearer token123"));
assert!(call.headers.iter().any(|(k, v)| k == "Content-Type" && v == "application/json"));
// ボディ検証(最低限)
assert!(call.body.contains(r#""title":"Deploy""#));
assert!(call.body.contains(r#""message":"Ship it!""#));
}
#[test]
fn notifier_propagates_non_200_as_error() {
let http = MockHttp::new()
.will_return(Ok(HttpResponse { status: 503, body: "busy".into() }))
.expect_calls(1);
let notifier = Notifier::new(&http, "https://api.example.com", "Bearer t");
let err = notifier.send("A", "B").unwrap_err();
http.verify();
assert!(err.contains("unexpected status: 503"));
}
#[test]
fn notifier_maps_http_error() {
let http = MockHttp::new()
.will_return(Err(HttpError::Timeout))
.expect_calls(1);
let notifier = Notifier::new(&http, "https://api.example.com", "Bearer t");
let err = notifier.send("x", "y").unwrap_err();
http.verify();
assert!(err.contains("Timeout"));
}
}

Mock と Spy の違いを理解するための実装例
Mock と Spy の違い
-
Mock
- 「どのように呼ばれたか」を検証するための代替物。
- 呼び出しの回数・引数・順序が正しいかをテストの成否条件とする。
- 本物の処理は行わなくてもよい(返り値はテスト用に用意するだけでも可)。
- よくある用途: メール送信、外部 API 呼び出し、DB 保存のように「呼ばれたこと自体」を保証したいとき。
-
Spy
- 本物の処理を動かしつつ「どう呼ばれたか」を記録する代替物。
- 副作用は実際に発生させた上で、呼び出し回数や引数を後から確認できる。
- よくある用途: キャッシュのヒット/ミスを測定する、実際の DB 呼び出し回数を確認する、といった「観測」に使う。
Mock の実装例
処理内容
- 例: Webhook 通知を送る
Notifier
。 - 要件:
/notify
に 1回だけ、正しいヘッダ/ボディで POST していることを検証する。
ポイント
- 本物の通信は発生しない。
- 検証対象は「呼び出し方が正しいか」のみ。
ソースコード
use std::cell::RefCell;
pub trait HttpClient {
fn post_json(&self, url: &str, body: &str) -> Result<u16, String>;
}
pub struct Notifier<C: HttpClient> {
client: C,
endpoint: String,
}
impl<C: HttpClient> Notifier<C> {
pub fn new(client: C, endpoint: impl Into<String>) -> Self {
Self { client, endpoint: endpoint.into() }
}
pub fn send(&self, msg: &str) -> Result<(), String> {
let url = format!("{}/notify", self.endpoint);
let body = format!(r#"{{"message":"{}"}}"#, msg);
let status = self.client.post_json(&url, &body)?;
if status == 200 { Ok(()) } else { Err(format!("status {}", status)) }
}
}
// ==== Mock ====
pub struct MockHttp {
expected_url: String,
expected_body: String,
calls: RefCell<usize>,
}
impl MockHttp {
pub fn new(expected_url: &str, expected_body: &str) -> Self {
Self {
expected_url: expected_url.to_string(),
expected_body: expected_body.to_string(),
calls: RefCell::new(0),
}
}
pub fn verify(&self) {
assert_eq!(*self.calls.borrow(), 1, "must be called exactly once");
}
}
impl HttpClient for MockHttp {
fn post_json(&self, url: &str, body: &str) -> Result<u16, String> {
*self.calls.borrow_mut() += 1;
assert_eq!(url, self.expected_url);
assert_eq!(body, self.expected_body);
Ok(200)
}
}
#[test]
fn notifier_sends_correct_request() {
let mock = MockHttp::new("https://api.example.com/notify", r#"{"message":"Hello"}"#);
let notifier = Notifier::new(&mock, "https://api.example.com");
notifier.send("Hello").unwrap();
mock.verify();
}
Spy の実装例
テスト内容
- 例: キャッシュ付きユーザー取得。
- 要件: 1回目は DB に行き、2回目はキャッシュから返る。
- Spy を使って「DB に行った回数」を観測する。
ポイント
- Spy のポイント: 本物の DB ロジックは動く。
- 同時に「何回呼ばれたか」を記録できる。
ソースコード
use std::cell::Cell;
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct User { pub id: String, pub name: String }
pub trait UserRepo { fn find(&self, id: &str) -> Option<User>; }
// 本物: インメモリDB
pub struct InMemoryDb { map: HashMap<String, User> }
impl InMemoryDb {
pub fn with(seed: &[User]) -> Self {
let mut m = HashMap::new();
for u in seed { m.insert(u.id.clone(), u.clone()); }
Self { map: m }
}
}
impl UserRepo for InMemoryDb {
fn find(&self, id: &str) -> Option<User> { self.map.get(id).cloned() }
}
// ==== Spy ====
pub struct SpyRepo<R: UserRepo> {
inner: R,
pub find_calls: Cell<usize>,
}
impl<R: UserRepo> SpyRepo<R> {
pub fn new(inner: R) -> Self { Self { inner, find_calls: Cell::new(0) } }
}
impl<R: UserRepo> UserRepo for SpyRepo<R> {
fn find(&self, id: &str) -> Option<User> {
self.find_calls.set(self.find_calls.get() + 1);
self.inner.find(id) // 本物を呼ぶ
}
}
// キャッシュ付きリポジトリ
pub struct CachedUsers<R: UserRepo> {
repo: R,
cache: HashMap<String, User>,
}
impl<R: UserRepo> CachedUsers<R> {
pub fn new(repo: R) -> Self { Self { repo, cache: HashMap::new() } }
pub fn get(&mut self, id: &str) -> Option<User> {
if let Some(u) = self.cache.get(id).cloned() {
return Some(u);
}
let v = self.repo.find(id)?;
self.cache.insert(id.to_string(), v.clone());
Some(v)
}
}
#[test]
fn first_hit_db_then_hit_cache() {
let db = InMemoryDb::with(&[User { id: "1".into(), name: "Alice".into() }]);
let spy = SpyRepo::new(db);
let mut cached = CachedUsers::new(&spy);
// 1回目は DB に行く
cached.get("1").unwrap();
// 2回目はキャッシュから
cached.get("1").unwrap();
assert_eq!(spy.find_calls.get(), 1, "DB should be hit only once");
}

Fake の実装例
ポイント
- 狙い: 実際の DB や外部サービスを使わず、テスト用の「軽量だけど動く代替実装」を用意する。
-
特徴:
- 本物のように動くが、インメモリや簡易な仕組みで代替。
- データはプロセス内だけに保持されるので、速度が速く、外部依存が不要。
- 実際の保存・検索ロジックを簡略化しつつ、本番に近い挙動を再現可能。
-
使い所:
- DB に繋げない/繋ぎたくないユニットテストや CI。
- 外部サービスに料金やネットワークをかけずにロジックを確認したいとき。
- 開発時に「ちょっと動かす」ための簡易スタブ環境。
-
実務 Tip:
- Rust では
cfg(test)
や DI(依存性注入)を使って、本番では実 DB、テスト時には Fake に切り替える構成がよく使われる。
- Rust では
ソースコード
use std::cell::RefCell;
use std::collections::HashMap;
/// ドメインモデル
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct User {
pub id: String,
pub name: String,
}
/// リポジトリの抽象(本番は DB、テストは Fake を使えるようにする)
pub trait UserRepo {
fn find_by_id(&self, id: &str) -> Option<User>;
fn save(&self, user: User);
}
/// 本番用(例: 実際の DB アクセスを伴う)
pub struct PostgresUserRepo; // 実装は省略
impl UserRepo for PostgresUserRepo {
fn find_by_id(&self, _id: &str) -> Option<User> {
unimplemented!("実際の DB に問い合わせ")
}
fn save(&self, _user: User) {
unimplemented!("実際の DB に書き込み")
}
}
/// ==== Fake 実装 ====
/// インメモリ DB として動作する
pub struct InMemoryUserRepo {
store: RefCell<HashMap<String, User>>,
}
impl InMemoryUserRepo {
pub fn new() -> Self {
Self { store: RefCell::new(HashMap::new()) }
}
}
impl UserRepo for InMemoryUserRepo {
fn find_by_id(&self, id: &str) -> Option<User> {
self.store.borrow().get(id).cloned()
}
fn save(&self, user: User) {
self.store.borrow_mut().insert(user.id.clone(), user);
}
}
/// テスト対象となるサービス
pub struct UserService<R: UserRepo> {
repo: R,
}
impl<R: UserRepo> UserService<R> {
pub fn new(repo: R) -> Self { Self { repo } }
pub fn register(&self, id: &str, name: &str) {
let u = User { id: id.into(), name: name.into() };
self.repo.save(u);
}
pub fn get_name(&self, id: &str) -> Option<String> {
self.repo.find_by_id(id).map(|u| u.name)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn register_and_get_user_works_with_fake_repo() {
let repo = InMemoryUserRepo::new(); // ← Fake を使う
let svc = UserService::new(&repo);
svc.register("u1", "Alice");
svc.register("u2", "Bob");
assert_eq!(svc.get_name("u1"), Some("Alice".into()));
assert_eq!(svc.get_name("u2"), Some("Bob".into()));
assert_eq!(svc.get_name("u404"), None);
}
}
一言
このように Fake を用意すると、本番コードはそのままにして、テストだけインメモリ版に差し替えられる。
Rust では trait
+ 実装切り替えが自然なパターンで、cfg(test)
や依存性注入で「本番=Postgres」「テスト=Fake」と分けて使うことができる。