Rust 実践講座 #6 テスト — パーサーの単体テストから CLI の統合テストまで

読了 4分

パーサー(第 3 回)、集計(第 4 回)、並列化(第 5 回)と機能が積み上がったので、次回の大手術(リリース最適化、クロスコンパイル)の前に安全網を張る番です。Rust はテストランナーが言語に組み込まれているので、フレームワーク選びから始める必要がありません。cargo test 1 つで始めます。

単体テスト — コードの隣に置く検証 #

Rust の単体テストは、検証対象と同じファイルに住みます。パーサーモジュールの下に付けます。

src/parser.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_normal_line() {
        let line = r#"203.0.113.9 - - [23/Aug/2026:10:12:01 +0900] "GET /api/users HTTP/1.1" 200 512"#;
        let entry = parse_line(line).unwrap();
        assert_eq!(entry.ip, "203.0.113.9");
        assert_eq!(entry.method, "GET");
        assert_eq!(entry.path, "/api/users");
        assert_eq!(entry.status, 200);
        assert_eq!(entry.bytes, 512);
    }

    #[test]
    fn dash_bytes_becomes_zero() {
        let line = r#"203.0.113.9 - - [23/Aug/2026:10:12:01 +0900] "HEAD / HTTP/1.1" 301 -"#;
        assert_eq!(parse_line(line).unwrap().bytes, 0);
    }

    #[test]
    fn empty_line_is_empty_error() {
        assert!(matches!(parse_line("   "), Err(ParseError::Empty)));
    }

    #[test]
    fn garbage_is_rejected() {
        assert!(parse_line("まったく別の形式の行").is_err());
    }
}

#[cfg(test)] は「このモジュールは cargo test のときだけコンパイルする」という条件付きコンパイルの指示なので、配布バイナリにテストコードは入りません。テストの本題はケースの選定です。正常な行を 1 つ、第 3 回で「寛大に受け取る」と決めた - のバイト数、「失敗だが失敗に数えない」と決めた空行、そして完全なゴミ入力。パーサーを作りながら下した設計判断の 1 つ 1 つが、テストケース 1 つ 1 つとして文書化されるわけです。エラーの種類まで確かめるときは、matches! マクロが Err(ParseError::Empty) のようなパターンをそのまま検査してくれます(基礎第 5 回のパターンマッチがテストでも働いています)。第 3 回で LogEntryPartialEq を導出しておいたのが assert_eq! のための準備だった、という伏線もここで回収されます。

テストでは unwrap が正当だということ(基礎第 6 回)も確認しておきます。テストにおいて失敗はそのままテストの失敗であり、それこそが望む動作です。

統合テスト — 利用者の席から実行する #

単体テストが部品検査なら、統合テストは完成品検査です。プロジェクトルートの tests/ ディレクトリに置くファイルは、外部の利用者と同じ条件でクレートを使う別のバイナリとしてコンパイルされます。CLI ツールの完成品検査は「実際に実行して出力を見る」で、その道具が assert_cmd です。

インストール
cargo add --dev assert_cmd predicates tempfile

--dev は開発・テスト専用の依存という印で、配布バイナリとは無関係です。

tests/cli.rs
// tests/cli.rs
use assert_cmd::Command;
use predicates::prelude::*;
use std::io::Write;

#[test]
fn stats_counts_lines() {
    let mut log = tempfile::NamedTempFile::new().unwrap();
    writeln!(log, r#"203.0.113.9 - - [t] "GET /a HTTP/1.1" 200 10"#).unwrap();
    writeln!(log, r#"203.0.113.9 - - [t] "GET /b HTTP/1.1" 500 20"#).unwrap();
    writeln!(log, "壊れた行").unwrap();

    Command::cargo_bin("loglens").unwrap()
        .args(["stats", log.path().to_str().unwrap()])
        .assert()
        .success()
        .stdout(predicate::str::contains("パース成功: 2"))
        .stdout(predicate::str::contains("パース失敗: 1"));
}

#[test]
fn missing_file_fails_with_context() {
    Command::cargo_bin("loglens").unwrap()
        .args(["stats", "no-such-file.log"])
        .assert()
        .failure()
        .stderr(predicate::str::contains("ログファイルを開けません"));
}

Command::cargo_bin("loglens") がビルドされたばかりの実物のバイナリを探して実行し、assert() 以降のチェーンが終了コード(success/failure)と出力(predicates の文字列条件)を検証します。特に重要なのが 2 つ目のテストです。第 2 回で設計したエラーメッセージ(「何をしようとして失敗したか」+ 終了コード 1)が利用者に実際に届いているかを検証するもので、エラー処理も機能であり、機能にはテストが付くという原則の実践です。テストデータは tempfile が作る一時ファイルに書き、ファイルはテストが終わると自動で消えます(基礎第 3 回の drop がここでも働いています)。

cargo test が並列だという事実 #

実行
cargo test

1 行で単体・統合テストがすべて走り、既定で複数スレッドの並列実行になります。テストが速くなる代わりに、規則が 1 つ生まれます。テスト同士が同じファイルパスやグローバル状態を共有すると、順序によって結果が揺れます。上で固定パスではなく tempfile を使ったのは、後始末の自動化のためだけではなく、テストごとに独立したファイルを与えて並列実行でも干渉しないようにする目的が大きいです。第 4 回で「HashMap の出力はソートして安定させる」と言ったのも、ここで効いてきます。出力の順序が実行ごとに変わるツールには、統合テストが書けません。

まとめ #

  • 単体テストは #[cfg(test)] でコードの隣に置きます。パーサーの設計判断(寛大な -、失敗に数えない空行)がそのままテストケースになります。
  • エラー種類の検証は matches!、構造体の比較は PartialEq の導出 + assert_eq! が基本装備です。
  • 統合テストは tests/ ディレクトリで完成品を検査します。assert_cmd で実物のバイナリを実行し、終了コードと出力を検証します。
  • エラーメッセージも機能です。「ファイルがない」のメッセージと終了コードまでテストが守ります。
  • cargo test は並列実行が既定です。tempfile によるテストごとの独立ファイルと、ソートで安定させた出力がその前提です。
  • 次回は配布準備の前半です。release プロファイルを締めてバイナリを最適化し、別の OS のためのクロスコンパイルまで扱います。
X