Open10

ソフトウェアテスト

nukopynukopy

テストダブルの説明と 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
nukopynukopy

モックとスタブの違い

「スタブ」と「モック」はどっちも“本物の代わり”に見えるから、線引きが曖昧になりがち。平たくいうと、スタブは「何を返すか」に注目する」「モックは「どう呼ばれたか」に注目する」

スタブ (Stub)

  • 定義: 「呼ばれたら決まった値を返すだけ」の代替物。

  • 狙い: 外部依存の出力を固定して、テスト対象の処理を確実に進める。

  • 用途: 成功ケースやエラーケースを意図的に作りたいとき。

    • 例: DB から必ずユーザーを返す/必ず None を返す
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");
}

👉 ポイント: 「返す値」ではなく「呼ばれた証拠」を見る。


まとめ

  • スタブ → 「結果を作る」:テスト対象に必要なデータを渡すため
  • モック → 「行動を監視する」:テスト対象が正しく依存を呼んでいるか確認するため

例えるなら:

  • スタブは「シナリオ用のセリフを棒読みする代役」
  • モックは「監督が“セリフをちゃんと言ったか”をチェックする代役」
nukopynukopy

テスト対象のモジュール、サービスの依存関係を、契約(インタフェース)は保ったまま代替するのがテストダブル。「契約(インタフェース)は保ったまま」って表現がかなりしっくり来る。


Test Double(テストダブル)の定義・表現案

推奨の一文(実務向け)

テストダブルとは、テスト対象が相互作用する 外部の協働体(依存や資源) を、
その 契約(インタフェース/プロトコル)を保ったまま 制御可能・観測可能な代替実装 に置き換えたもの。

もっと短く

  • 外部依存の 契約互換な“替え玉”実装
  • テスト対象の 境界の相手 を、テスト用の 代替実装 に差し替えること。

もう少し丁寧

テスト対象とやり取りする 外部の接点(コラボレータ/資源:DB、HTTP API、ファイル、時刻、乱数、 メッセージブローカ、環境変数、関数コールバック、別プロセス/スレッド、デバイス等) を、

期待する契約を満たす仮実装 に置き換え、入力を自在に制御し、呼び出しや副作用を観測できるようにする仕組み。

含意(定義に入れ込むとブレない要素)

  • 契約互換:同じインタフェース/プロトコルで差し替え可能
  • 制御可能:成功・失敗・遅延・例外などを注入できる
  • 観測可能:呼び出し回数・引数・順序などを記録できる
  • 外部協働体の広さ:オブジェクトや“サービス”に限らず、関数・モジュール・OS資源・プロセス・デバイスまで

用語のバリエーション(文脈でチョイス)

  • 代替実装 / 置き換え体 / スタンドイン / 身代わり実装 / 仮実装
  • 外部協働体(collaborator)/ 依存点 / 境界の相手
nukopynukopy

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` をモック/スパイにする(ここではダミーのスコープ外)。
}
nukopynukopy

Q. ダミーのテストの OrderService::new(DummyMailer, DummyPayment) はこれで初期化できるんだっけ?構造体に new とか {} いらないの?

いい視点!確かに 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を実装した型のインスタンス
DummyMailerDummyPayment は「単なる型」であって、値ではないから、正しくはこう呼ぶ必要がある:

let svc = OrderService::new(DummyMailer, DummyPayment);

が、これは ゼロサイズ構造体ならそのまま値として使えるんだよ。
Rust では struct DummyMailer; とフィールドなしのユニット構造体にしていると、型名がそのまま「値」として扱える。

もし struct DummyMailer {} のように中括弧付きで定義していたら、明示的にインスタンスを作る必要がある:

let svc = OrderService::new(DummyMailer {}, DummyPayment {});

✅ まとめ

  • struct DummyMailer; のようにユニット構造体なら DummyMailer だけで値になるので OK。
  • struct DummyMailer {} と書いた場合は DummyMailer {} と書かないと初期化できない。
nukopynukopy

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: タイムアウトしました。再試行してください");
    }
}
nukopynukopy

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"));
    }
}
nukopynukopy

Mock と Spy の違いを理解するための実装例

Mock と Spy の違い

  • Mock
    • 「どのように呼ばれたか」を検証するための代替物。
    • 呼び出しの回数・引数・順序が正しいかをテストの成否条件とする。
    • 本物の処理は行わなくてもよい(返り値はテスト用に用意するだけでも可)。
    • よくある用途: メール送信、外部 API 呼び出し、DB 保存のように「呼ばれたこと自体」を保証したいとき。
  • Spy
    • 本物の処理を動かしつつ「どう呼ばれたか」を記録する代替物。
    • 副作用は実際に発生させた上で、呼び出し回数や引数を後から確認できる。
    • よくある用途: キャッシュのヒット/ミスを測定する、実際の DB 呼び出し回数を確認する、といった「観測」に使う。

Mock の実装例

処理内容

  • 例: Webhook 通知を送る Notifier
  • 要件: /notify1回だけ正しいヘッダ/ボディで 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");
}
nukopynukopy

Fake の実装例

ポイント

  • 狙い: 実際の DB や外部サービスを使わず、テスト用の「軽量だけど動く代替実装」を用意する。
  • 特徴:
    • 本物のように動くが、インメモリや簡易な仕組みで代替。
    • データはプロセス内だけに保持されるので、速度が速く、外部依存が不要。
    • 実際の保存・検索ロジックを簡略化しつつ、本番に近い挙動を再現可能。
  • 使い所:
    • DB に繋げない/繋ぎたくないユニットテストや CI。
    • 外部サービスに料金やネットワークをかけずにロジックを確認したいとき。
    • 開発時に「ちょっと動かす」ための簡易スタブ環境。
  • 実務 Tip:
    • Rust では cfg(test) や DI(依存性注入)を使って、本番では実 DB、テスト時には Fake に切り替える構成がよく使われる。

ソースコード

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」と分けて使うことができる。