第20章: テスト - 信頼性の高いコードを書く

学習目標

  • Rustのテストフレームワークの基礎を理解する
  • 単体テスト、統合テスト、ドキュメントテストの違いを学ぶ
  • テスト駆動開発(TDD)の実践方法を習得する
  • テストのベストプラクティスを身につける
  • カバレッジツールの使い方を学ぶ

---

20.1 Rustにおけるテストの重要性

20.1.1 なぜテストが重要か

┌─────────────────────────────────────────────────────────┐
│              Rustの安全性保証のレイヤー                  │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  コンパイル時        型システム、借用チェッカー           │
│  ─────────────    メモリ安全性、スレッド安全性            │
│                                                         │
│  ランタイム          panic!、unwrap()                    │
│  ─────────────    境界チェック、オーバーフロー検出        │
│                                                         │
│  テスト             論理エラー、ビジネスロジック           │
│  ─────────────    期待される動作の検証                   │
│                                                         │
└─────────────────────────────────────────────────────────┘

Rustのコンパイラは多くのバグを防ぎますが、論理エラーは防げません:

// コンパイルは通るが、論理的に間違っている
fn calculate_discount(price: f64, discount_percent: f64) -> f64 {
    price * discount_percent  // ❌ 100で割るのを忘れている
}

// テストで発見できる
#[test]
fn test_discount() {
    assert_eq!(calculate_discount(100.0, 10.0), 90.0);  // Fail!
}

20.1.2 Rustのテストの特徴

ビルトイン

  • テストフレームワークが標準で付属
  • 外部ライブラリ不要
  • cargo testで即座に実行

高速

  • 並列実行がデフォルト
  • インクリメンタルコンパイル

表現力豊か

  • assert!, assert_eq!, assert_ne!
  • カスタムエラーメッセージ
  • should_panic属性

---

20.2 単体テスト(Unit Tests)

20.2.1 基本的なテストの書き方

// src/lib.rs または src/main.rs

fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]  // テスト時のみコンパイル
mod tests {
    use super::*;  // 親モジュールをインポート

    #[test]  // この関数がテストであることを示す
    fn test_add() {
        assert_eq!(add(2, 3), 5);
    }

    #[test]
    fn test_add_negative() {
        assert_eq!(add(-1, -2), -3);
    }
}

実行

$ cargo test

running 2 tests
test tests::test_add ... ok
test tests::test_add_negative ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured

20.2.2 アサーションマクロ

基本的なアサーション

#[test]
fn test_assertions() {
    // 条件が真であることを確認
    assert!(2 + 2 == 4);

    // 等値性の確認
    assert_eq!(2 + 2, 4);

    // 非等値性の確認
    assert_ne!(2 + 2, 5);
}

カスタムメッセージ

#[test]
fn test_with_message() {
    let result = calculate_something();
    assert_eq!(
        result,
        expected_value,
        "計算結果が期待値と異なります: 実際={}, 期待={}",
        result,
        expected_value
    );
}

浮動小数点数の比較

#[test]
fn test_float() {
    let a = 0.1 + 0.2;
    let b = 0.3;

    // ❌ これは失敗する可能性がある
    // assert_eq!(a, b);

    // ✅ 許容誤差を設定
    assert!((a - b).abs() < 1e-10);
}

20.2.3 パニックのテスト

should_panic属性

fn divide(a: i32, b: i32) -> i32 {
    if b == 0 {
        panic!("0で除算しようとしました");
    }
    a / b
}

#[test]
#[should_panic]
fn test_divide_by_zero() {
    divide(10, 0);  // これはパニックするはず
}

#[test]
#[should_panic(expected = "0で除算")]
fn test_divide_by_zero_with_message() {
    divide(10, 0);  // パニックメッセージも検証
}

20.2.4 Result型を返すテスト

#[test]
fn test_with_result() -> Result<(), String> {
    let result = risky_operation()?;

    if result == expected {
        Ok(())
    } else {
        Err(format!("期待値と異なります: {}", result))
    }
}

fn risky_operation() -> Result<i32, String> {
    Ok(42)
}

---

20.3 テストの構成とヘルパー

20.3.1 setup/teardownパターン

struct TestContext {
    db: Database,
    temp_dir: TempDir,
}

impl TestContext {
    fn new() -> Self {
        // セットアップ
        TestContext {
            db: Database::connect_test(),
            temp_dir: TempDir::new().unwrap(),
        }
    }
}

impl Drop for TestContext {
    fn drop(&mut self) {
        // ティアダウン(自動クリーンアップ)
        self.db.close();
    }
}

#[test]
fn test_with_context() {
    let ctx = TestContext::new();
    // テストコード
    // ctx がドロップされると自動的にクリーンアップ
}

20.3.2 共通のテストユーティリティ

#[cfg(test)]
mod tests {
    use super::*;

    // ヘルパー関数
    fn create_test_user(name: &str) -> User {
        User {
            id: 1,
            name: name.to_string(),
            email: format!("{}@test.com", name),
        }
    }

    #[test]
    fn test_user_creation() {
        let user = create_test_user("Alice");
        assert_eq!(user.name, "Alice");
    }

    #[test]
    fn test_user_validation() {
        let user = create_test_user("Bob");
        assert!(user.is_valid());
    }
}

20.3.3 テストの無視とフィルタリング

#[test]
#[ignore]  // デフォルトではスキップ
fn expensive_test() {
    // 時間がかかるテスト
}

実行方法

# 通常のテスト実行(ignoreは除外)
cargo test

# ignoreされたテストのみ実行
cargo test -- --ignored

# すべてのテスト実行
cargo test -- --include-ignored

# 特定のテストのみ実行(名前でフィルタ)
cargo test test_add

# 並列度を制御
cargo test -- --test-threads=1

---

20.4 統合テスト(Integration Tests)

20.4.1 統合テストの配置

my_project/
├── Cargo.toml
├── src/
│   ├── lib.rs
│   └── utils.rs
└── tests/          ← 統合テスト専用ディレクトリ
    ├── integration_test.rs
    └── common/
        └── mod.rs  ← 共通ヘルパー

統合テストの例

// tests/integration_test.rs

use my_project::public_api_function;

#[test]
fn test_public_api() {
    let result = public_api_function(42);
    assert_eq!(result, 84);
}

特徴

  • 各ファイルが独立したクレートとして扱われる
  • tests/ディレクトリ内のファイルは自動的にテスト
  • 公開APIのみテスト可能

20.4.2 共通ヘルパーの配置

// tests/common/mod.rs
pub fn setup() {
    // 共通のセットアップ処理
}

// tests/integration_test.rs
mod common;

#[test]
fn test_with_common_setup() {
    common::setup();
    // テストコード
}

注意tests/common/mod.rsとして配置することで、commonがテストファイルとして扱われるのを防ぐ。

---

20.5 ドキュメントテスト(Documentation Tests)

20.5.1 基本的なドキュメントテスト

/// 2つの数を加算します。
///
/// # Examples
///
/// 
/// use my_project::add; /// /// let result = add(2, 3); /// assert_eq!(result, 5); ///
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

実行

$ cargo test --doc

running 1 test
test src/lib.rs - add (line 5) ... ok

20.5.2 ドキュメントテストの特徴

失敗する例を示す

/// # Panics
///
/// 0で除算しようとするとパニックします。
///
/// 
should_panic /// use my_project::divide; /// /// divide(10, 0); // パニックする ///
pub fn divide(a: i32, b: i32) -> i32 {
    assert!(b != 0, "0で除算しようとしました");
    a / b
}

コンパイルエラーを示す

/// この関数は可変参照を要求します。
///
/// 
compile_fail /// let x = 5; /// my_function(&x); // エラー: 可変参照が必要 ///
///
/// 正しい使い方:
/// 
/// let mut x = 5; /// my_function(&mut x); ///
pub fn my_function(x: &mut i32) {
    *x += 1;
}

非表示のコード

/// # Examples
///
/// 
/// # use my_project::Database; /// # let db = Database::connect_test(); /// let users = db.get_all_users(); /// assert!(users.len() > 0); ///

#で始まる行はドキュメントには表示されないが、テストでは実行される。

20.5.3 ドキュメントテストのベストプラクティス

/// ユーザーを検索します。
///
/// # Arguments
///
/// * `id` - ユーザーID
///
/// # Returns
///
/// ユーザーが見つかった場合は`Some(User)`、見つからない場合は`None`
///
/// # Examples
///
/// 
/// use my_project::{Database, User}; /// /// # let db = Database::connect_test(); /// match db.find_user(1) { /// Some(user) => println!("Found: {}", user.name), /// None => println!("Not found"), /// } ///
///
/// # Errors
///
/// データベース接続エラーが発生する可能性があります。
///
/// 
/// # use my_project::Database; /// # let mut db = Database::connect_test(); /// let result = db.find_user(999); /// // エラーハンドリングが推奨される ///
pub fn find_user(&self, id: u32) -> Option<User> {
    // 実装
}

---

20.6 テスト駆動開発(TDD)

20.6.1 Red-Green-Refactorサイクル

┌─────────────────────────────────────────────────────────┐
│              TDDのサイクル                               │
├─────────────────────────────────────────────────────────┤
│                                                         │
│         ┌──────────┐                                    │
│         │   RED    │ ← 失敗するテストを書く              │
│         └─────┬────┘                                    │
│               │                                         │
│               ▼                                         │
│         ┌──────────┐                                    │
│         │  GREEN   │ ← 最小限の実装で通す                │
│         └─────┬────┘                                    │
│               │                                         │
│               ▼                                         │
│         ┌──────────┐                                    │
│         │ REFACTOR │ ← リファクタリング                  │
│         └─────┬────┘                                    │
│               │                                         │
│               └──────┐                                  │
│                      │                                  │
│                      └──► 繰り返し                      │
│                                                         │
└─────────────────────────────────────────────────────────┘

20.6.2 TDDの実践例

ステップ1: Red(失敗するテストを書く)

// tests/test_calculator.rs

#[test]
fn test_factorial() {
    assert_eq!(factorial(0), 1);
    assert_eq!(factorial(1), 1);
    assert_eq!(factorial(5), 120);
}

// まだ実装していないのでコンパイルエラー

ステップ2: Green(最小限の実装)

// src/lib.rs

pub fn factorial(n: u32) -> u32 {
    match n {
        0 | 1 => 1,
        _ => n * factorial(n - 1),
    }
}

ステップ3: Refactor(リファクタリング)

pub fn factorial(n: u32) -> u32 {
    (1..=n).product()  // より簡潔に
}

20.6.3 TDDのメリット

  • 設計の改善
- テストしやすいコード = 疎結合 - インターフェースが明確

  • リグレッション防止
- 既存の動作を保証 - 安心してリファクタリング

  • ドキュメントとして機能
- テストが使い方の例になる

---

20.7 モックとテストダブル

20.7.1 トレイトによる抽象化

// プロダクションコード
pub trait UserRepository {
    fn find(&self, id: u32) -> Option<User>;
    fn save(&mut self, user: User) -> Result<(), Error>;
}

pub struct DatabaseRepository {
    connection: DbConnection,
}

impl UserRepository for DatabaseRepository {
    fn find(&self, id: u32) -> Option<User> {
        // 実際のDB操作
    }

    fn save(&mut self, user: User) -> Result<(), Error> {
        // 実際のDB操作
    }
}

// ビジネスロジック
pub struct UserService<R: UserRepository> {
    repo: R,
}

impl<R: UserRepository> UserService<R> {
    pub fn get_user_name(&self, id: u32) -> String {
        self.repo.find(id)
            .map(|u| u.name)
            .unwrap_or_else(|| "Unknown".to_string())
    }
}

テストコード

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashMap;

    // モックリポジトリ
    struct MockRepository {
        users: HashMap<u32, User>,
    }

    impl MockRepository {
        fn new() -> Self {
            let mut users = HashMap::new();
            users.insert(1, User {
                id: 1,
                name: "Alice".to_string(),
            });
            MockRepository { users }
        }
    }

    impl UserRepository for MockRepository {
        fn find(&self, id: u32) -> Option<User> {
            self.users.get(&id).cloned()
        }

        fn save(&mut self, user: User) -> Result<(), Error> {
            self.users.insert(user.id, user);
            Ok(())
        }
    }

    #[test]
    fn test_get_user_name() {
        let repo = MockRepository::new();
        let service = UserService { repo };

        assert_eq!(service.get_user_name(1), "Alice");
        assert_eq!(service.get_user_name(999), "Unknown");
    }
}

20.7.2 mockallクレートの使用

// Cargo.toml
// [dev-dependencies]
// mockall = "0.12"

use mockall::*;

#[automock]
pub trait UserRepository {
    fn find(&self, id: u32) -> Option<User>;
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_with_mockall() {
        let mut mock = MockUserRepository::new();

        // 期待値を設定
        mock.expect_find()
            .with(eq(1))
            .times(1)
            .returning(|_| Some(User {
                id: 1,
                name: "Alice".to_string(),
            }));

        let service = UserService { repo: mock };
        assert_eq!(service.get_user_name(1), "Alice");
    }
}

---

20.8 ベンチマークテスト

20.8.1 Criterionの使用

# Cargo.toml
[dev-dependencies]
criterion = "0.5"

[[bench]]
name = "my_benchmark"
harness = false

// benches/my_benchmark.rs

use criterion::{black_box, criterion_group, criterion_main, Criterion};

fn fibonacci(n: u64) -> u64 {
    match n {
        0 => 1,
        1 => 1,
        n => fibonacci(n - 1) + fibonacci(n - 2),
    }
}

fn criterion_benchmark(c: &mut Criterion) {
    c.bench_function("fib 20", |b| {
        b.iter(|| fibonacci(black_box(20)))
    });
}

criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);

実行

cargo bench

---

20.9 カバレッジツール

20.9.1 Tarpaulinの使用

# インストール
cargo install cargo-tarpaulin

# 実行
cargo tarpaulin --out Html

20.9.2 LLVMカバレッジ

# ビルド
RUSTFLAGS="-C instrument-coverage" cargo build

# テスト実行
LLVM_PROFILE_FILE="coverage-%p-%m.profraw" cargo test

# レポート生成
llvm-profdata merge -sparse coverage-*.profraw -o coverage.profdata
llvm-cov show target/debug/my_project \
    -instr-profile=coverage.profdata \
    -format=html \
    -output-dir=coverage

---

20.10 テストのベストプラクティス

20.10.1 テスト命名規則

// ✅ 良い例:何をテストしているか明確
#[test]
fn test_add_positive_numbers() { }

#[test]
fn test_add_negative_numbers() { }

#[test]
fn test_divide_by_zero_panics() { }

// ❌ 悪い例:内容が不明
#[test]
fn test1() { }

#[test]
fn test2() { }

20.10.2 テストの独立性

// ❌ 悪い例:テストが相互依存
static mut COUNTER: i32 = 0;

#[test]
fn test_a() {
    unsafe { COUNTER += 1; }
    assert_eq!(unsafe { COUNTER }, 1);  // 並列実行で失敗する可能性
}

// ✅ 良い例:テストが独立
#[test]
fn test_a() {
    let mut counter = 0;
    counter += 1;
    assert_eq!(counter, 1);
}

20.10.3 テストのドキュメント化

#[test]
fn test_user_authentication() {
    // Arrange(準備)
    let user = create_test_user();
    let password = "secure_password";

    // Act(実行)
    let result = authenticate(&user, password);

    // Assert(検証)
    assert!(result.is_ok());
    assert_eq!(result.unwrap().id, user.id);
}

---

20.11 まとめ

学んだこと

  • テストの種類
- 単体テスト(#[test]) - 統合テスト(tests/ディレクトリ) - ドキュメントテスト(/// `...``

  • テストツール
-
cargo test - テスト実行 - cargo bench - ベンチマーク - cargo tarpaulin - カバレッジ

  • TDD
- Red-Green-Refactorサイクル - テストファースト開発

  • モック
- トレイトによる抽象化 -
mockall`クレート

次のステップ

次の章では、ドキュメンテーションとrustdocの使い方を学びます。

---

参考資料