第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の使い方を学びます。
---