コンテンツにスキップ

Rustのテストをもっと活用する:モック・プロパティテスト・ベンチマーク

#[test] と assert_eq! でテストを書く方法は、もう知っていますね(まだの方は Rustのテスト から始めてください)。小さなプログラムなら、それで十分です。でも実際のプロジェクトでは、すぐに新しい疑問が出てきます。テストはどこに置けばいいのでしょう? メールを送ったり時計を読んだりするコードは、どうやってテストするのでしょう? 自分では思いつかなかった「あの1つの入力」は、どうすれば見つかるでしょう? そして、「速くした」はずのバージョンは本当に速いのでしょうか?

車の整備工場にたとえてみましょう。基本的なテストは、部品が1つずつ正しく動くかを確かめる作業です。このページでは、工場の残りの設備をそろえます。部品用のテスト台(フェイクとモック)、車をランダムな段差で何千回も揺さぶる機械(プロパティテスト)、前回の様子を撮った写真(スナップショット)、そして信頼できるストップウォッチ(ベンチマーク)です。

始める前に1つ正直にお伝えしておきます。Rust Playground の Run ボタンが実行するのは main で、テストではありません。また、この先で使うテスト用クレートも Playground には入っていません。そのため、このページの例の多くは、ふつうのパソコン上の小さなプロジェクトで実際に動かした cargo test と cargo bench の本物の出力 を載せています。main があるプログラムには、いつもどおり Try it ボタンがあります。

このページで使う小さな shop ライブラリの構成は、次のとおりです。

shop/
├── Cargo.toml
├── src/
│ ├── lib.rs <- コード + `mod tests` の単体テスト
│ └── orders.rs
├── tests/
│ ├── checkout.rs <- 結合テスト
│ └── common/
│ └── mod.rs <- 結合テストで共有するヘルパー
└── benches/
└── stock.rs <- ベンチマーク

Rust のテストには3種類あり、cargo test はそのすべてを実行します。

  • 単体テスト(unit test)は、コードのすぐ隣の #[cfg(test)] mod tests ブロックに書きます。非公開の関数も見えます。
  • 結合テスト(integration test)は tests/ フォルダに置きます。各ファイルは別のクレートとしてコンパイルされ、ライブラリを外側から、つまり利用者とまったく同じ立場で使います。そのため、呼べるのは pub なものだけです。
  • ドキュメンテーションテスト(doc test)は、/// コメントの中に書いたコード例です。ドキュメントが今でも正しく動くことを証明してくれます。詳しくは Rustのドキュメント のページで説明しています。

これは src/lib.rs の一部です。apply_discount のドキュメンテーションテストと、mod tests の中にあるヘルパー関数・フィクスチャ関数に注目してください。

/// ショッピングカートの1行分。価格の単位はセント。
#[derive(Debug, Clone, PartialEq)]
pub struct Item {
pub name: String,
pub price_cents: u32,
pub qty: u32,
}
pub fn cart_total(items: &[Item]) -> u32 {
items.iter().map(|i| i.price_cents * i.qty).sum()
}
/// 価格から `percent` パーセントを割り引く。
///
/// ```
/// use shop::apply_discount;
///
/// assert_eq!(apply_discount(1000, 25), 750);
/// ```
pub fn apply_discount(price_cents: u32, percent: u32) -> u32 {
assert!(percent <= 100, "discount must be at most 100%, got {percent}%");
price_cents - price_cents * percent / 100
}
/// "12.50" を 1250 セントに変換する。
pub fn parse_price(text: &str) -> Result<u32, String> { /* ... */ }
/// 1250 セントを "12.50" に変換する。
pub fn format_price(cents: u32) -> String {
format!("{}.{:02}", cents / 100, cents % 100)
}
#[cfg(test)]
mod tests {
use super::*;
// ヘルパー: 短く呼べて、どのテストも同じ方法で商品を作れる。
fn item(name: &str, price_cents: u32, qty: u32) -> Item {
Item { name: name.to_string(), price_cents, qty }
}
// フィクスチャ: 複数のテストで共有する、現実的なカート。
fn sample_cart() -> Vec<Item> {
vec![item("coffee", 450, 2), item("bagel", 300, 1)]
}
#[test]
fn total_of_sample_cart() {
assert_eq!(cart_total(&sample_cart()), 1200);
}
#[test]
fn empty_cart_costs_nothing() {
assert_eq!(cart_total(&[]), 0);
}
#[test]
#[should_panic(expected = "at most 100%")]
fn discount_over_100_panics() {
apply_discount(1000, 150);
}
// テストは Result を返せるので、中で `?` が使える。
#[test]
fn parses_a_price() -> Result<(), String> {
let cents = parse_price("12.50")?;
assert_eq!(cents, 1250);
Ok(())
}
#[test]
#[ignore = "slow: checks ten million prices"]
fn every_price_round_trips() {
for cents in 0..10_000_000 {
assert_eq!(parse_price(&format_price(cents)), Ok(cents));
}
}
}

いくつかポイントを見ておきましょう。

  • ヘルパーもフィクスチャも、ただの関数です。 Rust には専用のフィクスチャ機能はありませんが、困ることはほとんどありません。#[cfg(test)] の中にあるので、本番のプログラムに入り込むこともありません。
  • #[should_panic(expected = "...")] は、パニックのメッセージにその文字列が含まれるときだけ成功します。別のバグで起きたパニックは成功扱いになりません。Rustのテスト で、ほかの例も交えて説明しています。
  • Result を返すテスト では ? が使えます。テストが Err を返すと失敗になり、たとえば Error: "missing '.' in \"12,50\"" のようにエラーが表示されます。
  • #[ignore] を付けると、遅いテストや特別なテストをふだんはスキップできます。= のあとの文字列は理由で、cargo test がそれを表示します。

結合テストは、ライブラリを公開 API を通して使います。共有のヘルパーは tests/common/mod.rs に置きます。

tests/common/mod.rs
use shop::Item;
pub fn breakfast_cart() -> Vec<Item> {
vec![
Item { name: "coffee".into(), price_cents: 450, qty: 2 },
Item { name: "bagel".into(), price_cents: 300, qty: 1 },
]
}
tests/checkout.rs
mod common;
use shop::{apply_discount, cart_total, format_price};
#[test]
fn discounted_breakfast_total() {
let total = cart_total(&common::breakfast_cart());
let to_pay = apply_discount(total, 10);
println!("to pay: {}", format_price(to_pay));
assert_eq!(format_price(to_pay), "10.80");
}

cargo test を実行すると、テストの種類ごとにセクションが分かれて表示されます。

Running unittests src/lib.rs (target/debug/deps/shop-ffc1628aec947def)
running 5 tests
test tests::every_price_round_trips ... ignored, slow: checks ten million prices
test tests::parses_a_price ... ok
test tests::empty_cart_costs_nothing ... ok
test tests::total_of_sample_cart ... ok
test tests::discount_over_100_panics - should panic ... ok
test result: ok. 4 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.08s
Running tests/checkout.rs (target/debug/deps/checkout-c1e4aa0cf6375c2a)
running 1 test
test discounted_breakfast_total ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests shop
running 1 test
test src/lib.rs - apply_discount (line 16) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

ふだんの開発では、毎回すべてのテストを流したいわけではありません。そんなときは次のコマンドが便利です。単独の -- より後ろに書いたものは、Cargo ではなくテストプログラム自体に渡されます。

Terminal window
cargo test discount # 名前に "discount" を含むテストだけ
cargo test --test checkout # tests/checkout.rs のファイルだけ
cargo test --lib # src/ の単体テストだけ
cargo test --doc # ドキュメンテーションテストだけ
cargo test -- --ignored # #[ignore] のテストだけ
cargo test -- --include-ignored # 無視したものも含めてすべて
cargo test -- --nocapture # 成功したテストの println! の出力も表示
cargo test -- --test-threads=1 # テストを1つずつ順番に実行

名前での絞り込みは、テストのフルネームの一部にマッチし、すべてのテストファイルが対象になります。ここでは discount で、単体テストと結合テストが1つずつ見つかりました。

Running unittests src/lib.rs (target/debug/deps/shop-ffc1628aec947def)
running 1 test
test tests::discount_over_100_panics - should panic ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 6 filtered out; finished in 0.09s
Running tests/checkout.rs (target/debug/deps/checkout-630939367802bf1e)
running 1 test
test discounted_breakfast_total ... ok

遅いテストは、頼んだときだけ実行されます。1000万回の往復変換に、ここでは約3秒かかりました。

running 1 test
test tests::every_price_round_trips ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 4 filtered out; finished in 3.20s

レポートをすっきり保つため、Rust は成功したテストの println! の出力をふだんは隠します。--nocapture を付けると表示されます。

$ cargo test --test checkout -- --nocapture
running 1 test
to pay: 10.80
test discounted_breakfast_total ... ok

テストダブル:トレイトで偽物を作る

Section titled “テストダブル:トレイトで偽物を作る”

外の世界とやり取りするコードがあります。メールを送ったり、API を呼んだり、時計を読んだりするコードです。テストのたびに本物のメールが送られては困りますよね。それに、コードがいつも本物の時計を見ているなら、「夜9時にどうなるか」もテストできません。

Rust らしい解決法は、ライブラリをまったく必要としません。依存するものを トレイト として表し、関数がそのトレイトを実装したものなら何でも受け取れるようにして、テスト用に小さな フェイク(fake)を書くのです。本番のプログラムは本物を渡し、テストはフェイクを渡します。

この例では、注文の確定時にお客さんへメールを送り、大口の注文なら営業チームにも知らせます。

trait Mailer {
fn send(&mut self, to: &str, body: &str);
}
// 本物のメーラー。実際のアプリならメール送信サービスを呼ぶところ。
struct ConsoleMailer;
impl Mailer for ConsoleMailer {
fn send(&mut self, to: &str, body: &str) {
println!("{to} へのメール: {body}");
}
}
struct Order {
id: u32,
email: String,
total_cents: u32,
}
fn confirm_order(order: &Order, mailer: &mut impl Mailer) {
let body = format!("ご注文 #{} を承りました", order.id);
mailer.send(&order.email, &body);
if order.total_cents >= 10_000 {
mailer.send("[email protected]", &format!("大口注文 #{}", order.id));
}
}
fn main() {
let mut mailer = ConsoleMailer;
let small = Order { id: 7, email: "[email protected]".into(), total_cents: 2_450 };
let big = Order { id: 8, email: "[email protected]".into(), total_cents: 12_000 };
confirm_order(&small, &mut mailer);
confirm_order(&big, &mut mailer);
}
#[cfg(test)]
mod tests {
use super::*;
// フェイク: 何も送らず、呼ばれた内容をすべて書き留めるだけ。
#[derive(Default)]
struct FakeMailer {
sent: Vec<(String, String)>,
}
impl Mailer for FakeMailer {
fn send(&mut self, to: &str, body: &str) {
self.sent.push((to.to_string(), body.to_string()));
}
}
#[test]
fn small_order_sends_one_email() {
let mut fake = FakeMailer::default();
let order = Order { id: 7, email: "[email protected]".into(), total_cents: 2_450 };
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 1);
assert_eq!(fake.sent[0].0, "[email protected]");
}
#[test]
fn big_order_also_tells_sales() {
let mut fake = FakeMailer::default();
let order = Order { id: 8, email: "[email protected]".into(), total_cents: 12_000 };
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 2);
assert_eq!(fake.sent[1], ("[email protected]".to_string(), "大口注文 #8".to_string()));
}
}

実行結果:

[email protected] へのメール: ご注文 #7 を承りました
[email protected] へのメール: ご注文 #8 を承りました
[email protected] へのメール: 大口注文 #8
▶ 実際に試してみよう

この例は外部クレートを使っていないので、Playground では Run の隣のメニューから Test を選ぶこともできます。

running 2 tests
test tests::small_order_sends_one_email ... ok
test tests::big_order_also_tells_sales ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

confirm_order は、どのメーラーを渡されたのかを知りませんし、気にもしません。仕掛けはこれだけです。同じ考え方は、Clock トレイト(テストでは夜9時で止まった時計を使う)にも、PaymentGateway にも、テストでは HashMap を裏側に使う Storage トレイトにも使えます。時計のバージョンは、下の練習問題でやってみましょう。

フェイクを手で書くのは、トレイトが1つか2つなら問題ありません。たくさんあるときは、mockall(英語)クレートに生成してもらえます。テストと example でだけ使われるように、dev-dependency として追加しましょう(Cargo.toml の各セクションは Cargoとクレート で説明しています)。

Terminal window
cargo add --dev mockall

トレイトに #[automock] を付けると、mockall が MockMailer 型を作ってくれます。cfg_attr(test, ...) の部分は「テストのときだけこれを行う」という意味なので、モックが本番のビルドに入ることはありません。cfg については Cargo のフィーチャーと cfg で詳しく説明しています。

src/orders.rs
#[cfg(test)]
use mockall::automock;
#[cfg_attr(test, automock)]
pub trait Mailer {
fn send(&mut self, to: &str, body: &str);
}
// Order と confirm_order は上と同じ。
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn big_order_also_tells_sales() {
let mut mailer = MockMailer::new();
mailer
.expect_send()
.withf(|to, _body| to == "[email protected]")
.times(1)
.return_const(());
mailer
.expect_send()
.withf(|to, body| to == "[email protected]" && body == "大口注文 #8")
.times(1)
.return_const(());
let order = Order { id: 8, email: "[email protected]".into(), total_cents: 12_000 };
confirm_order(&order, &mut mailer);
}
}

expect_send() の1つ1つが 期待(expectation)です。「send が、withf に合う引数で、ちょうど1回呼ばれるはずだ」という意味になります。return_const(()) はモックが返す値を指定します(ここでは何も返しません)。テストの最後にモックが破棄されるとき、mockall はすべての期待が満たされたかを確認します。

バグを捕まえる様子を見るために、confirm_order の条件を order.total_cents > 12_000 に変えてみましょう。すると営業チームへのメールが送られなくなります。

thread 'orders::tests::big_order_also_tells_sales' (12345) panicked at src/orders.rs:4:18:
MockMailer::send: Expectation(<function>) called 0 time(s) which is fewer than expected 1

このメッセージは「期待では1回呼ばれるはずなのに、0回しか呼ばれなかった」という意味です。

フェイクとモック、どちらを使うべきでしょう? まずは手書きのフェイクから始めましょう。ふつうの Rust のコードなので読みやすく、依存も増えません。フェイクにしたいトレイトがたくさんあるときや、「この引数でちょうど N 回呼ばれたか」が本当に大事なときに、mockall を使いましょう。

proptest でプロパティベーステスト

Section titled “proptest でプロパティベーステスト”

ふつうのテストは、自分で選んだ1つの例を確かめます。「“12.50” は 1250 になる」のように。プロパティテスト(property test)は、すべての 入力で成り立つはずのルールを書き、ライブラリがそれを破ろうと何百ものランダムな入力を試します。自分では思いつかないような変な値を、根気よく次々に試してくれる人を雇うようなものです。

よいプロパティは、たとえば次のような形をしています。

  • 2回やると元に戻る:リストを2回反転すると、元のリストになる。
  • 往復すると元の値に戻る:parse(format(x)) == x。
  • ある事実がいつも成り立つ:ソートしたリストは、入力と同じ長さになる。

proptest(英語)を cargo add --dev proptest で追加し、proptest! マクロの中にテストを書きます。name in strategy の各行で、どんなランダムな値を作るかを指定します。

tests/properties.rs
use proptest::prelude::*;
use shop::{format_price, parse_price};
proptest! {
#[test]
fn reversing_twice_gives_back_the_original(list in prop::collection::vec(any::<i32>(), 0..100)) {
let mut copy = list.clone();
copy.reverse();
copy.reverse();
prop_assert_eq!(copy, list);
}
#[test]
fn price_survives_format_then_parse(cents in 0u32..10_000_000) {
prop_assert_eq!(parse_price(&format_price(cents)), Ok(cents));
}
}

any::<i32>() は「任意の i32」、0u32..10_000_000 は「その範囲の数」、vec(..., 0..100) は「要素が最大99個のリスト」を意味します。デフォルトでは、1つのプロパティにつき256個のランダムなケースを試します。proptest! の中では assert_eq! ではなく prop_assert_eq! を使いましょう。そうすると proptest が失敗をうまく扱えます。どちらのテストも成功します。

running 2 tests
test price_survives_format_then_parse ... ok
test reversing_twice_gives_back_the_original ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

これは、さきほどの遅い #[ignore] テストと同じチェックだと気づきましたか? あちらは1000万通りの価格をすべて試して3秒かかりました。プロパティテストはランダムな256通りを100分の1秒で試すので、毎回実行できます。

では、わざと間違えてみましょう。誰かが format_price を「改良」して、1,000.00 のように3桁ごとの区切りを入れるようにしました。見た目はよくなりましたが、parse_price のことを忘れています。

pub fn format_price(cents: u32) -> String {
let euros = cents / 100;
let euros = if euros >= 1000 {
format!("{},{:03}", euros / 1000, euros % 1000)
} else {
euros.to_string()
};
format!("{euros}.{:02}", cents % 100)
}

cargo test --test properties の実際の出力を、少しだけ省略して載せます。

---- price_survives_format_then_parse stdout ----
proptest: Saving this and future failures in tests/properties.proptest-regressions
thread 'price_survives_format_then_parse' (12345) panicked at tests/properties.rs:4:1:
Test failed: assertion failed: `(left == right)`
left: `Err("bad euros in \"1,000.00\"")`,
right: `Ok(100000)` at tests/properties.rs:15.
minimal failing input: cents = 100000

minimal failing input: cents = 100000(失敗する最小の入力)に注目してください。最初に失敗したランダムな値は、たぶん 7,384,912 のような半端な数だったはずです。そこから proptest は値を 縮小(shrink)します。失敗したままの、より小さな値を次々に試して、いちばん単純なものを報告するのです。100000 セントはちょうど 1,000.00 で、カンマが入る最初の価格です。これでバグの場所がすぐにわかります。

proptest は失敗したケースをテストの隣の .proptest-regressions ファイルにも保存し、次回からはそれを最初に試します。このファイルはコミットしておきましょう。そうすれば、同じバグがこっそり戻ってくることはありません。もっと小さなライブラリがよければ、quickcheck(英語)が、機能は少なめですが同じ役割を果たします。

insta でスナップショットテスト

Section titled “insta でスナップショットテスト”

出力が長いことがあります。レシート、エラーレポート、大きな JSON ドキュメントなどです。期待するテキスト全体を assert_eq! に書くのは大変ですよね。スナップショットテスト(snapshot test)は、最初の実行で出力をファイルに保存し、次からは新しい出力を保存しておいた「写真」と比べます。

insta(英語)を使えば(cargo add --dev insta)、テストは1行で書けます。

pub fn receipt(items: &[Item]) -> String {
let mut out = String::new();
for i in items {
let line = format_price(i.price_cents * i.qty);
out.push_str(&format!("{:<10} x{} {:>7}\n", i.name, i.qty, line));
}
out.push_str(&format!("{:<14}{:>7}\n", "TOTAL", format_price(cart_total(items))));
out
}
#[test]
fn receipt_layout() {
insta::assert_snapshot!(receipt(&sample_cart()));
}

最初の実行はわざと失敗し、確認用の .snap.new ファイルを書き出します。内容を承認すると(cargo-insta ツールの cargo insta review を使うか、ファイル名を .snap に変えます)、src/snapshots/ に置かれます。

---
source: src/lib.rs
assertion_line: 84
expression: receipt(&sample_cart())
---
coffee x2 9.00
bagel x1 3.00
TOTAL 12.00

レシートは {:<10} などで桁をそろえているので、商品名や "TOTAL" は英語のままにしています。日本語の文字は画面上で2文字分の幅を取るため、列がずれてしまうからです。

あとで誰かが "TOTAL" を "Total" に変えると、テストはわかりやすい差分を出して失敗します。

-old snapshot
+new results
────────────┬───────────────────────────────────────────────────────────────────
1 1 │ coffee x2 9.00
2 2 │ bagel x1 3.00
3 │-TOTAL 12.00
3 │+Total 12.00
────────────┴───────────────────────────────────────────────────────────────────
To update snapshots run `cargo insta review` or set `INSTA_UPDATE=always`

意図した変更なら、新しいスナップショットを承認します。そうでなければ、バグを1つ見つけたことになります。スナップショットは「うっかり変わってはいけない」もののチェックにぴったりですが、出力が 正しい かどうかまでは教えてくれません。最初のスナップショットは、承認する前によく読みましょう。

コードが速いかどうかを知りたいなら、測りましょう。推測は意外なほどよく外れます。#[test] の中で時間を計るのもうまくいきません。テストは最適化なしでビルドされますし、1回だけの計測ではばらつきが大きすぎるからです。

criterion(英語)は、安定版(stable)の Rust でよく使われるツールです。コードを何度も実行し、統計処理をして、前回の結果を覚えておくので、速くなったか遅くなったかも教えてくれます。cargo add --dev criterion で追加したら、Cargo にベンチマークのファイルを伝え、組み込みのハーネスをオフにします。

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

このベンチマークで調べるのは、「1000個の商品コードの中に、ある商品コードがあるか(在庫があるか)を確かめるなら、Vec と HashSet のどちらが速いか?」です。

benches/stock.rs
use std::collections::HashSet;
use std::hint::black_box;
use criterion::{criterion_group, criterion_main, Criterion};
fn stock_lookup(c: &mut Criterion) {
// "SKU-0"、"SKU-1"、… のような商品コードを1000個。
let codes: Vec<String> = (0..1000).map(|n| format!("SKU-{n}")).collect();
let code_set: HashSet<String> = codes.iter().cloned().collect();
let wanted = String::from("SKU-999"); // Vec にとって最悪のケース: 最後の1つ
c.bench_function("vec contains", |b| {
b.iter(|| codes.contains(black_box(&wanted)))
});
c.bench_function("hashset contains", |b| {
b.iter(|| code_set.contains(black_box(&wanted)))
});
}
criterion_group!(benches, stock_lookup);
criterion_main!(benches);

b.iter(...) の中が、計測されるコードです。std::hint::black_box は、値を最適化から隠します。これがないと、コンパイラが「答えは毎回同じだ」と気づいて1回だけ計算してしまい、何も計っていないことになりかねません。

cargo bench で実行します。cargo bench は常に最適化あり(リリースモード)でビルドします。数値はあなたのパソコンでは違ってくるはずです。

vec contains time: [2.4222 µs 2.4470 µs 2.4742 µs]
Found 2 outliers among 100 measurements (2.00%)
1 (1.00%) low mild
1 (1.00%) high mild
hashset contains time: [18.514 ns 18.891 ns 19.312 ns]
Found 8 outliers among 100 measurements (8.00%)
4 (4.00%) high mild
4 (4.00%) high severe

読み方を説明します。3つの時間は範囲を表しています。真ん中がいちばん確からしい推定値で、両端は「本当の値はほぼ確実にこの間にある」という意味です。ここでは HashSet が約19ナノ秒、Vec が約2.4マイクロ秒なので、このケースでは HashSet のほうが100倍以上速いことになります。「outliers」(外れ値)は、たまたま極端に遅かったり速かったりした数回の実行で、パソコン上のほかのプログラムが原因のことがよくあります。少しなら気にしなくて大丈夫です。

コードを変えてからもう一度実行すると、criterion が前回と比べてくれます。

vec contains time: [2.5124 µs 2.5430 µs 2.5773 µs]
change: [−0.5279% +0.8368% +2.1133%] (p = 0.22 > 0.05)
No change in performance detected.

変化は1%未満で、ただのばらつきかもしれません。そのため criterion は「No change」(変化なし)と判断しています。target/criterion/ には HTML のレポートも書き出されます。速度を気にすべきとき(と、気にしなくていいとき)については パフォーマンス を見てください。

古いコードでは #[bench] 関数を見かけるかもしれません。これは組み込みの test::Bencher を使っていて、nightly のコンパイラでしか動きません。

#![feature(test)] // nightly でのみ使える
extern crate test;
#[bench]
fn bench_total(b: &mut test::Bencher) {
b.iter(|| (1..=1000u64).sum::<u64>());
}

安定版の Rust では criterion を使いましょう。

テストがそろったら、さらに2つのツールが役に立ちます。どちらも Rustのツールと環境構築 で紹介したほかのツールと同じく、cargo install でインストールします。

cargo-nextest は、より速いテストランナーです。テストを1つずつ別のプロセスで実行し、見やすい出力を表示し、不安定な(flaky な)テストを再試行することもできます。1つだけ注意があります。ドキュメンテーションテストは実行しないので、cargo test --doc も別に実行してください。

Terminal window
cargo install cargo-nextest --locked
cargo nextest run

カバレッジ(coverage)は、テストが実際にどの行を実行したかを教えてくれます。cargo-llvm-cov はコンパイラに組み込まれたカバレッジ機能を使い、Linux、macOS、Windows で動きます。

Terminal window
cargo install cargo-llvm-cov
cargo llvm-cov # ターミナルに集計表を表示
cargo llvm-cov --html # target/llvm-cov/html にブラウザで見られるレポートを作成

cargo-tarpaulin は、昔からある人気の代替ツールです。Linux でいちばんよく動き、CI でもよく使われます。

Terminal window
cargo install cargo-tarpaulin
cargo tarpaulin --out Html

カバレッジは点数ではなく、地図だと考えましょう。どのテストも一度も通らないコードを教えてくれますが、assert が正しいことを確かめているかまでは教えてくれません。しっかりした assert で80%のカバレッジのほうが、弱い assert で100%よりも価値があります。

CI(GitHub Actions、GitLab など)では、手元で実行するのと同じ3つのコマンドから始めるのがおすすめです。そうすれば、チェックが失敗したときの意味がどこでも同じになります。

Terminal window
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test

バイナリだけのクレートに結合テストを書く。 プロジェクトに src/main.rs しかないと、tests/ のファイルからは何もインポートできません。

error[E0432]: unresolved import `todo`
--> tests/tasks.rs:1:5
|
1 | use todo::add_task;
| ^^^^ use of unresolved module or unlinked crate `todo`

コンパイラは「todo というモジュールやクレートが見つからない」と言っています。

直し方:ロジックを src/lib.rs に移し、main.rs は小さく保ちましょう。そうすれば main.rs もテストも、同じライブラリを呼び出せます。コードを再利用しやすくもなります。

共有のヘルパーを tests/common.rs に置く。 tests/ の直下にある .rs ファイルは、それぞれが独立したテストファイルとして扱われます。そのため、出力に意味のないセクションが1つ増えてしまいます。

Running tests/common.rs (target/debug/deps/common-634ea0b624054b77)
running 0 tests

直し方:tests/common/mod.rs を使い、必要なテストファイルごとに mod common; と書きましょう。

テスト用クレートを [dependencies] に入れる。 proptest、mockall、insta、criterion は [dev-dependencies] に入れるものです(cargo add --dev を使いましょう)。そうしないと、あなたのクレートを使う人全員がそれらをダウンロードしてコンパイルすることになります。

状態を共有するテスト。 テストは並列に実行されます。2つのテストが同じファイルに書き込んだり、同じ環境変数を変えたりすると、ランダムに失敗します。テストごとに専用の一時フォルダを用意するか(tempfile クレートが便利です)、最後の手段として --test-threads=1 で実行しましょう。

black_box なしで、あるいはテストの中でベンチマークする。 結果が使われない処理は、最適化で消されてしまうことがあります。また、デバッグモードの #[test] で計った時間からは、ほとんど何もわかりません。criterion と black_box を使いましょう。

この関数は、いつも本物の時計を見ているので、確実にテストすることができません。

fn greeting() -> &'static str {
let hour = current_hour(); // システムの時刻を読む
match hour {
5..=11 => "おはようございます",
12..=17 => "こんにちは",
_ => "こんばんは",
}
}

メソッドを1つ(fn hour(&self) -> u64)だけ持つ Clock トレイトを受け取るように書き直してください。いつも同じ時刻を返す FixedClock を作り、境目をテストしましょう。11時と12時、17時と18時です。ヒント: struct FixedClock(u64); で十分です。本番のアプリでは、時刻を読む SystemClock を渡すことになります。

解答を見る
trait Clock {
fn hour(&self) -> u64; // 0 から 23
}
// いつも同じ時刻を返す時計。テストにぴったり。
struct FixedClock(u64);
impl Clock for FixedClock {
fn hour(&self) -> u64 {
self.0
}
}
fn greeting(clock: &impl Clock) -> &'static str {
match clock.hour() {
5..=11 => "おはようございます",
12..=17 => "こんにちは",
_ => "こんばんは",
}
}
fn main() {
for hour in [8, 12, 21] {
println!("{hour}:00 -> {}", greeting(&FixedClock(hour)));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn morning_ends_at_noon() {
assert_eq!(greeting(&FixedClock(11)), "おはようございます");
assert_eq!(greeting(&FixedClock(12)), "こんにちは");
}
#[test]
fn evening_starts_at_six() {
assert_eq!(greeting(&FixedClock(17)), "こんにちは");
assert_eq!(greeting(&FixedClock(18)), "こんばんは");
}
#[test]
fn very_early_is_still_evening() {
assert_eq!(greeting(&FixedClock(3)), "こんばんは");
}
}

実行結果:

8:00 -> おはようございます
12:00 -> こんにちは
21:00 -> こんばんは
▶ 実際に試してみよう

greeting は、時刻をどこから取ってくるかを自分で決めなくなりました。決めるのは呼び出す側です。この1つの変更だけで、どの時刻でも数ミリ秒でテストできるようになります。Playground のメニューで Test を選ぶと、3つのテストがすべて成功します。

2. ルールを見つけて、proptest に確かめてもらう

Section titled “2. ルールを見つけて、proptest に確かめてもらう”

ブログのタグ " Web Dev " を "web-dev" に変換する関数があります。

fn normalize_tag(tag: &str) -> String {
tag.to_lowercase().split_whitespace().collect::<Vec<_>>().join("-")
}

proptest でプロパティテストを2つ書いてください。1つは「すでに正規化されたタグをもう一度正規化しても何も変わらない」、もう1つは「結果に空白文字が含まれない」です。ヒント: ".*" というストラテジーでランダムな文字列が作れます(これは正規表現です)。「空白がない」のチェックには prop_assert!(!s.contains(char::is_whitespace)) が使えます。

解答を見る
fn normalize_tag(tag: &str) -> String {
tag.to_lowercase().split_whitespace().collect::<Vec<_>>().join("-")
}
fn main() {
for tag in ["Rust", " Web Dev ", "GAME dev"] {
println!("{tag:?} -> {:?}", normalize_tag(tag));
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn normalizing_twice_changes_nothing(tag in ".*") {
let once = normalize_tag(&tag);
prop_assert_eq!(normalize_tag(&once), once);
}
#[test]
fn result_has_no_whitespace(tag in ".*") {
let normalized = normalize_tag(&tag);
prop_assert!(!normalized.contains(char::is_whitespace));
}
}
}

実行結果:

"Rust" -> "rust"
" Web Dev " -> "web-dev"
"GAME dev" -> "game-dev"
▶ 実際に試してみよう

Playground では Run は動きますが、Test は動きません。Playground には proptest が入っていないからです。自分のプロジェクトで cargo add --dev proptest を実行して追加し、cargo test を実行してみましょう。

running 2 tests
test tests::result_has_no_whitespace ... ok
test tests::normalizing_twice_changes_nothing ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s

ランダムな文字列には、タブや改行、英語以外の文字も含まれます。手書きのテストにはまず入れないようなものばかりです。「2回やっても1回やったのと同じ」というのは、どんな整形用の関数でも確かめる価値のあるプロパティです。

  • 単体テストはコードの隣の #[cfg(test)] mod tests に、結合テストは tests/ に(共有のヘルパーは tests/common/mod.rs に)、ドキュメンテーションテストは /// コメントに書きます。
  • 遅いテストには #[ignore] を付けて cargo test -- --ignored で実行し、名前での絞り込みは cargo test some_name、println! の出力を見るには -- --nocapture を使います。
  • 外の世界とやり取りするコードをテストするには、その依存をトレイトの後ろに隠し、テストではフェイクを渡します。手書きのフェイクが面倒になってきたら mockall を使いましょう。
  • プロパティテスト(proptest)は、ルールを何百ものランダムな入力で確かめ、失敗をいちばん単純な例まで縮小してくれます。スナップショットテスト(insta)は、大きな出力のうっかりした変化を捕まえます。
  • 速度は criterion と cargo bench で測り、入力は std::hint::black_box で包みます。#[bench] には nightly が必要です。
  • cargo-nextest はテストを速く実行し、cargo-llvm-cov と cargo-tarpaulin はテストがどの行をカバーしているかを示します。

理解度チェック

1. tests/ の中の複数のファイルで、同じヘルパー関数が必要です。どこに置けばよいでしょう?

2. cargo test -- --ignored は何をしますか?

3. proptest のプロパティが失敗しました。proptest が「失敗する最小の入力」として報告するのは何ですか?

4. criterion のベンチマークで、値を std::hint::black_box で包むのはなぜですか?

スコア: 0 / 4