項目30:ユニットテスト以上のものを書こう
LT;DR
private なコードを含む個々の機能を細かくテストするには、単体テスト を書く cargo test で実行する
public な API をテストするには、統合テスト を書く cargo test で実行する
cargo test で実行する
public な API 全体の使い方を例示するには、サンプルコードを書く
cargo test --examples または cargo run --example <name> で実行する
cargo bench で実行する
信頼できない入力値が渡ってくるコードには、ファズテスト を書く cargo fuzz で実行する
hr.icon
単体テストコードは、独立したモジュールに書く
モジュール名は慣習的に test または tests
モジュールの定義は実装ファイルと同じか、別のファイルを作成しても良い
テストモジュールでは、テスト対象の親モジュールのすべてを ワイルドカード(use super::*)を用いても良い テストモジュールは親モジュールの内容を 可視性 に関わらず利用可能 テストの失敗には panic! を用いるので、テストコードでは expect や unwrap を用いても良い
例えば、 assert! や assert_eq! は失敗すると panic を起こす
テスト対象が panic を起こす可能性がある場合は、テスト関数に #[should_panic] を付ける
型システムで保証されていることをテストする必要はない
依存クレートの機能を用いているコードはテストを書くべき
SemVer では表現されない API のシグネチャの変化以外の 必要とする機能が変化したことに気づくため 統合テストは tests/ に配置する
このディレクトリ内の各ファイルは #[test] が付与されたすべての関数を実行する
private なアイテムにはアクセスできないので、public な API だけをテストする
cargo test 実行時、ドキュメンテーションコメント 内の個々のコードは暗黙的に fn main() {...} でラップされて実行される cargo test --doc <item-name> のように関数名を指定して実行することも可能
CI で定常的にテストを実行すれば、サンプルコードと API が乖離することを防ぐことができる サンプルコード
サンプルコードは examples/ に配置する
このディレクトリ配下の 個々のファイル や main.rsを含むサブディレクトリ は独立した バイナリ となる 実行するには cargo run --example <name> または cargo test --example <name> で可能
サンプルコードはクレートの public API にしかアクセスできない
CI で定期的にビルド・実行(cargo test --examples)することで、リグレッション に気が付けるようにすると良い ユーザがコピペして利用することを考慮して unwrap メソッドを使わないようにし、main 関数の戻り値も Result<(), Box<dyn Error>> として常に ? を用いるようにした方が良い
ベンチマーク
ベンチマークを実行するには、cargo bench を実行する
warning.icon 実際にベンチマークを測定するには、安定化していない API(e.g. std::test::Bencher)を利用する必要があるため、cargo +nightly bench を実行する必要がある CI で定常的に実行することで、コードやツールチェインの変更によって パフォーマンス に悪影響をいち早く検知できる warning.icon コンパイル最適化 によって稼働時の値よりも良い結果が得られることがある コード
code:rs
extern crate test;
pub fn factorial(n: u128) -> u128 {
match n {
0 => 1,
_ => n * factorial(n - 1),
}
}
mod test {
use super::*;
fn bench_factorial(b: &mut test::Bencher) {
b.iter(|| {
let result = factorial(15);
assert_eq!(result, 1_307_674_368_000);
});
}
}
実行
code:sh
$ cargo +nightly bench
running 1 test
test tests::bench_factorial ... bench: 0.42 ns/iter (+/- 0.00)
原因
以下の 2 つの理由により、コンパイラは最適化でループをすべて取り除いて、結果だけを返すコードに置き換えるため
テスト対象のコードに渡される引数が決まっていて実行時に変更されている
テスト対象のコード量が少ない
e.g.
code:rs
fn bench_factorial(b: &mut test::Bencher) {
b.iter(|| {
let result = factorial(std::hint::black_box(15));
assert_eq!(result, 1_307_674_368_000);
});
}
再実行
code:sh
$ cargo +nightly bench
running 1 test
test tests::bench_factorial ... bench: 14.72 ns/iter (+/- 0.48)
「潜在的な攻撃者にコードが晒されるなら、ファズテストを行おう」
ファズテストは メモリ安全性 を保証する際に利用されることが多いため Rust には必要ないと思われがち しかし、バグをすべて防げるわけではないし、panic を起こす コードパス があれば、コード全体に対する DoS 攻撃 が成立する 1. 任意のバイト列を入力として受け取る関数を見つける
code:rs
pub fn is_fuzz(data: &u8) -> bool { if data.len() >= 3
{
true
} else {
false
}
}
2. cargo fuzz init でファズテストのサブプロジェクトを作成する
作成に成功すると、以下のような fuzz ディレクトリが作成される
code:_
fuzz/
├── Cargo.toml
└── fuzz_targets
└── fuzz_target_1.rs
3. fuzz_target! 内でテスト対象の関数を呼び出す
code:fuzz/fuzz_targets/fuzz_target_1.rs
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &u8| { let _ = some_crate::is_fuzz(data);
});
4. cargo +nightly fuzz run <fuzz target name> を実行する
e.g. cargo +nightly fuzz run fuzz_target_1
クラッシュするまで、ランダムなデータを用いてファズテストを実行し続ける
通常、ファズテストは問題を見つけるまでに時間を要するため、CI 上で実行すべきではない 代わりに、新しいリリースや大きな変更がある場合に限定して、一定期間だけ実行するのが良い
ファズテストの効率を上げるには、新しいコードパスを発見した際の入力を保存して再利用 すると良い
これにより、以降のテストでは既存のコードパスを再テストせずに、新しいコードパスの探索に注力できる
テストに関するアドバイス
「変更するたびに CI ですべてのテストを実行しよう」(ファズテストは除く) バグを修正する際には、「バグを顕在させるテストを修正前に書こう」
これにより、バグが修正されたことも確信でき、将来バグが誤って再現することも無くなる
クレートに フィーチャ がある場合「フィーチャのすべての可能な組み合わせに対してテストしよう」 #[cfg(target_os = "...")] が存在する場合、「すべてのプラットフォームでテストを実行しよう」