テストの書き方
テスト とは、テスト対象ではないコードが期待どおりに機能していることを検証する Rust の関数です。テスト関数の本体は、通常、次の 3 つの動作を行います。
- 必要なデータや状態をセットアップする。
- テストしたいコードを実行する。
- 結果が期待どおりであることをアサートする。
これらの動作を行うテストを書くために Rust が特別に提供している機能を見ていきましょう。これには、test 属性、いくつかのマクロ、そして should_panic 属性が含まれます。
テスト関数の構成
最も単純な形では、Rust のテストは test 属性が付けられた関数です。属性は Rust コードの各要素に付加されるメタデータです。その一例が、第 5 章で構造体とともに使った derive 属性です。関数をテスト関数に変えるには、fn の前の行に #[test] を追加します。cargo test コマンドでテストを実行すると、Rust は注釈付きの関数を実行するテストランナーバイナリをビルドし、各テスト関数が成功したか失敗したかを報告します。
Cargo で新しいライブラリプロジェクトを作ると、テスト関数を含むテストモジュールが自動的に生成されます。このモジュールはテストを書くためのテンプレートを提供してくれるので、新しいプロジェクトを始めるたびに正確な構造や構文を調べ直す必要がありません。追加のテスト関数やテストモジュールはいくつでも追加できます。
実際にコードをテストする前に、まずはテンプレートのテストを試しながら、テストがどのように動作するのかのいくつかの側面を見ていきましょう。その後、自分たちで書いたコードを呼び出し、その振る舞いが正しいことをアサートする実践的なテストを書きます。
2 つの数値を加算する adder という新しいライブラリプロジェクトを作成しましょう。
$ cargo new adder --lib
Created library `adder` project
$ cd adder
あなたの adder ライブラリの src/lib.rs ファイルの内容は、リスト 11-1 のようになるはずです。
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
このファイルは、テストする対象を用意するためのサンプル add 関数で始まっています。
今のところは、it_works 関数だけに注目しましょう。#[test] アノテーションに注目してください。この属性は、この関数がテスト関数であることを示します。そのため、テストランナーはこの関数をテストとして扱うべきだと分かります。共通のシナリオをセットアップしたり、共通の操作を実行したりするのを助けるために、tests モジュールの中にテストではない関数を置くこともあるので、どの関数がテストなのかを常に明示する必要があります。
このサンプル関数の本体では、assert_eq! マクロを使って、add を 2 と 2 で呼び出した結果を含む result が 4 に等しいことをアサートしています。このアサーションは、典型的なテストの形式の例になっています。このテストが成功することを確認するために、実行してみましょう。
cargo test コマンドは、リスト 11-2 に示すように、プロジェクト内のすべてのテストを実行します。
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.57s
Running unittests src/lib.rs (target/debug/deps/adder-01ad14159ff659ab)
running 1 test
test tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Cargo はテストをコンパイルして実行しました。running 1 test という行が表示されています。次の行には、自動生成された tests::it_works という名前のテスト関数と、そのテストを実行した結果が ok であることが示されています。全体の要約 test result: ok. は、すべてのテストが成功したことを意味し、1 passed; 0 failed という部分は成功したテストと失敗したテストの件数を示しています。
特定の場合に実行されないように、テストを無視対象としてマークすることもできます。これについては、この章の後半にある 「明示的に要求された場合を除いてテストを無視する」 節で扱います。ここではまだそうしていないので、要約には 0 ignored と表示されています。また、cargo test コマンドに引数を渡して、名前がある文字列に一致するテストだけを実行することもできます。これは フィルタリング と呼ばれ、この章の 「名前でテストの一部を実行する」 節で扱います。ここでは実行するテストを絞り込んでいないので、要約の最後には 0 filtered out と表示されています。
0 measured という統計は、性能を測定するベンチマークテストのためのものです。本書執筆時点では、ベンチマークテストは nightly Rust でのみ利用できます。詳しくは、ベンチマークテストに関するドキュメントを参照してください。
Doc-tests adder から始まるテスト出力の次の部分は、ドキュメンテーションテストの結果です。まだドキュメンテーションテストはありませんが、Rust は API ドキュメントに含まれるコード例をコンパイルできます。この機能は、ドキュメントとコードの同期を保つのに役立ちます。ドキュメンテーションテストの書き方については、第 14 章の 「テストとしてのドキュメンテーションコメント」 節で説明します。今のところは、Doc-tests の出力は無視しましょう。
では、テストを自分たちのニーズに合わせてカスタマイズしていきましょう。まず、it_works 関数の名前を exploration のような別の名前に変更します。
ファイル名: src/lib.rs
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exploration() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
次に、もう一度 cargo test を実行します。出力には、it_works ではなく exploration が表示されるようになります。
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.59s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 1 test
test tests::exploration ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
次に、別のテストを追加しますが、今回は失敗するテストを作ります。テスト関数の中で何かがパニックを起こすと、テストは失敗します。各テストは新しいスレッドで実行され、メインスレッドがテストスレッドがパニックによって終了したことを検知すると、そのテストは失敗としてマークされます。第 9 章で説明したように、パニックを起こす最も単純な方法は panic! マクロを呼び出すことです。新しいテストを another という名前の関数として追加すると、src/lib.rs ファイルはリスト 11-3 のようになります。
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exploration() {
let result = add(2, 2);
assert_eq!(result, 4);
}
#[test]
fn another() {
panic!("Make this test fail");
}
}
cargo test を使って再びテストを実行します。出力はリスト 11-4 のようになるはずで、exploration テストは成功し、another は失敗したことが分かります。
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.72s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 2 tests
test tests::another ... FAILED
test tests::exploration ... ok
failures:
---- tests::another stdout ----
thread 'tests::another' panicked at src/lib.rs:17:9:
Make this test fail
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::another
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
ok の代わりに、test tests::another という行には FAILED と表示されています。個々の結果と要約の間には、新たに2つのセクションが現れます。1つ目は、各テスト失敗の詳細な理由を表示します。この場合、tests::another が失敗したのは、src/lib.rs ファイルの17行目で Make this test fail というメッセージとともにパニックしたためだ、という詳細が表示されます。次のセクションには、失敗したすべてのテストの名前だけが一覧表示されます。これは、テストが大量にあり、失敗時の詳細な出力も大量にある場合に便利です。失敗したテストの名前を使って、そのテストだけを実行し、より簡単にデバッグできます。テストの実行方法については、「テストの実行方法を制御する」節でもう少し詳しく説明します。
最後に表示される要約行は次のとおりです。全体として、テスト結果は FAILED です。1つのテストは通り、1つのテストは失敗しました。
これで、さまざまな状況でテスト結果がどのように見えるかがわかったので、次は panic! 以外でテストに役立つマクロをいくつか見ていきましょう。
assert! で結果を確認する
標準ライブラリが提供する assert! マクロは、テスト内のある条件が true と評価されることを確認したい場合に便利です。assert! マクロには、評価結果が真偽値になる引数を渡します。値が true であれば何も起こらず、テストは成功します。値が false であれば、assert! マクロは panic! を呼び出してテストを失敗させます。assert! マクロを使うと、コードが意図したとおりに動作しているかを確認しやすくなります。
第5章のリスト5-15では、Rectangle 構造体と can_hold メソッドを使いましたが、それらをここでリスト11-5として再掲します。このコードを src/lib.rs ファイルに入れ、その後 assert! マクロを使っていくつかテストを書いてみましょう。
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
can_hold メソッドは真偽値を返すので、assert! マクロにはまさにうってつけのユースケースです。リスト11-6では、幅8、高さ7の Rectangle インスタンスを作成し、それが幅5、高さ1の別の Rectangle インスタンスを収められることをアサートすることで、can_hold メソッドをテストします。
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn larger_can_hold_smaller() {
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(larger.can_hold(&smaller));
}
}
tests モジュール内の use super::*; という行に注目してください。tests モジュールは通常のモジュールであり、第7章の「モジュールツリー内の要素を参照するためのパス」
節で扱った通常の可視性ルールに従います。tests モジュールは内部モジュールなので、外側のモジュールにあるテスト対象コードを内部モジュールのスコープに持ち込む必要があります。ここではグロブを使っているため、外側のモジュールで定義したものはすべてこの tests モジュールから利用できます。
このテストには larger_can_hold_smaller という名前を付け、必要な2つの Rectangle インスタンスを作成しています。次に、assert! マクロを呼び出し、larger.can_hold(&smaller) を呼び出した結果を渡しました。この式は true を返すはずなので、テストは成功するはずです。確認してみましょう!
$ cargo test
Compiling rectangle v0.1.0 (file:///projects/rectangle)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)
running 1 test
test tests::larger_can_hold_smaller ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests rectangle
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
確かに通りました! 今度は、小さい長方形が大きい長方形を収められないことをアサートする別のテストを追加してみましょう。
ファイル名: src/lib.rs
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn larger_can_hold_smaller() {
// --snip--
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(larger.can_hold(&smaller));
}
#[test]
fn smaller_cannot_hold_larger() {
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(!smaller.can_hold(&larger));
}
}
この場合、can_hold 関数の正しい結果は false なので、その結果を assert! マクロに渡す前に否定する必要があります。そのため、can_hold が false を返せば、このテストは成功します。
$ cargo test
Compiling rectangle v0.1.0 (file:///projects/rectangle)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)
running 2 tests
test tests::larger_can_hold_smaller ... ok
test tests::smaller_cannot_hold_larger ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests rectangle
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
2つのテストが通りました! では次に、コードにバグを入れたときにテスト結果がどうなるかを見てみましょう。can_hold メソッドの実装を変更し、幅を比較している箇所で大なり記号(>)を小なり記号(<)に置き換えます。
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
// --snip--
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width < other.width && self.height > other.height
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn larger_can_hold_smaller() {
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(larger.can_hold(&smaller));
}
#[test]
fn smaller_cannot_hold_larger() {
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(!smaller.can_hold(&larger));
}
}
ここでテストを実行すると、次のようになります。
$ cargo test
Compiling rectangle v0.1.0 (file:///projects/rectangle)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)
running 2 tests
test tests::larger_can_hold_smaller ... FAILED
test tests::smaller_cannot_hold_larger ... ok
failures:
---- tests::larger_can_hold_smaller stdout ----
thread 'tests::larger_can_hold_smaller' panicked at src/lib.rs:28:9:
assertion failed: larger.can_hold(&smaller)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::larger_can_hold_smaller
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
テストがバグを見つけました! larger.width は 8、smaller.width は 5 なので、can_hold における幅の比較は теперь false を返します。8 は 5 より小さくないからです。
assert_eq! と assert_ne! で等価性をテストする
機能を検証する一般的な方法は、テスト対象コードの結果と、そのコードが返すと期待している値とが等しいかどうかをテストすることです。これは assert! マクロを使い、== 演算子を使った式を渡すことでも行えます。しかし、これは非常によくあるテストなので、標準ライブラリには、このテストをより便利に行うための一対のマクロ、assert_eq! と assert_ne! が用意されています。これらのマクロは、それぞれ2つの引数を等価または非等価で比較します。また、アサーションが失敗した場合には2つの値も表示してくれるため、テストが なぜ 失敗したのかを把握しやすくなります。これに対して assert! マクロは、== 式の評価結果が false だったことしか示さず、その false に至った値は表示しません。
リスト11-7では、引数に 2 を足す add_two という関数を書き、その関数を assert_eq! マクロを使ってテストします。
pub fn add_two(a: u64) -> u64 {
a + 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_adds_two() {
let result = add_two(2);
assert_eq!(result, 4);
}
}
これが通ることを確認しましょう!
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 1 test
test tests::it_adds_two ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
まず、add_two(2) を呼び出した結果を保持する result という名前の変数を作成します。次に、result と 4 を assert_eq! マクロの引数として渡します。このテストの出力行は test tests::it_adds_two ... ok であり、この ok という表示がテストに成功したことを示しています。
では、assert_eq! が失敗したときにどのように見えるかを知るために、コードにバグを入れてみましょう。add_two 関数の実装を変更して、代わりに 3 を足すようにします。
pub fn add_two(a: u64) -> u64 {
a + 3
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_adds_two() {
let result = add_two(2);
assert_eq!(result, 4);
}
}
もう一度テストを実行します。
$ cargo test
Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)
running 1 test
test tests::it_adds_two ... FAILED
failures:
---- tests::it_adds_two stdout ----
thread 'tests::it_adds_two' panicked at src/lib.rs:12:9:
assertion `left == right` failed
left: 5
right: 4
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::it_adds_two
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
テストによってバグを検出できました! tests::it_adds_two テストは失敗し、メッセージは失敗したアサーションが left == right であったこと、そして left と right の値がそれぞれ何であるかを教えてくれます。このメッセージは、デバッグを始める助けになります。left 引数、つまり add_two(2) を呼び出した結果を入れていた側は 5 でしたが、right 引数は 4 でした。たくさんのテストを実行しているときには、これが特に役に立つことが想像できるでしょう。
一部の言語やテストフレームワークでは、等価性を検証するアサーション関数のパラメータは expected と actual と呼ばれ、引数を指定する順序が重要になります。しかし、Rust ではそれらは left と right と呼ばれ、期待する値とコードが生成した値を指定する順序は重要ではありません。このテストのアサーションを assert_eq!(4, result) と書いても、assertion `left == right` failed と表示される同じ失敗メッセージになります。
assert_ne! マクロは、渡した 2 つの値が等しくない場合に成功し、等しい場合に失敗します。このマクロは、ある値が最終的にどうなるかは分からないものの、その値が絶対に なってはいけない ものは分かっている、という場合に最も役立ちます。たとえば、入力を何らかの形で変更することが保証されている関数をテストしているものの、その変更のされ方がテストを実行する曜日によって変わる場合、最善のアサーションは、その関数の出力が入力と等しくないことを確認することかもしれません。
内部的には、assert_eq! マクロと assert_ne! マクロは、それぞれ == 演算子と != 演算子を使用しています。アサーションが失敗すると、これらのマクロは debug フォーマットを使って引数を出力します。つまり、比較される値は PartialEq トレイトと Debug トレイトを実装していなければなりません。すべてのプリミティブ型と標準ライブラリのほとんどの型は、これらのトレイトを実装しています。自分で定義する構造体や列挙型については、それらの型の等価性をアサートするために PartialEq を実装する必要があります。また、アサーションが失敗したときに値を出力するために Debug も実装する必要があります。どちらのトレイトも導出可能なトレイトであり、第 5 章のリスト 5-12 で述べたように、通常は構造体または列挙型の定義に #[derive(PartialEq, Debug)] アノテーションを追加するだけで済みます。これらやその他の導出可能なトレイトの詳細については、付録 C の 「導出可能なトレイト」 を参照してください。
カスタム失敗メッセージの追加
assert!、assert_eq!、assert_ne! マクロには、失敗メッセージとともに出力されるカスタムメッセージを、オプション引数として追加することもできます。必須引数の後に指定した引数はすべて format! マクロに渡されます(第 8 章の 「+ または format! による連結」 で説明しました)。そのため、{} プレースホルダーを含むフォーマット文字列と、そのプレースホルダーに入る値を渡すことができます。カスタムメッセージは、アサーションが何を意味しているのかを文書化するのに役立ちます。テストが失敗したとき、コードのどこに問題があるのかをより把握しやすくなります。
たとえば、人に名前であいさつする関数があり、その関数に渡した名前が出力に現れることをテストしたいとしましょう。
ファイル名: src/lib.rs
pub fn greeting(name: &str) -> String {
format!("Hello {name}!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn greeting_contains_name() {
let result = greeting("Carol");
assert!(result.contains("Carol"));
}
}
このプログラムの要件はまだ合意されておらず、あいさつの先頭にある Hello というテキストは変更される可能性が高いと考えています。要件が変わるたびにテストを更新したくないので、greeting 関数から返される値との完全一致を確認する代わりに、出力に入力パラメータのテキストが含まれていることだけをアサートすることにしました。
では、このコードにバグを入れて、greeting から name を除外するように変更し、デフォルトのテスト失敗がどのように見えるかを確認してみましょう。
pub fn greeting(name: &str) -> String {
String::from("Hello!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn greeting_contains_name() {
let result = greeting("Carol");
assert!(result.contains("Carol"));
}
}
このテストを実行すると、次のようになります。
$ cargo test
Compiling greeter v0.1.0 (file:///projects/greeter)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)
running 1 test
test tests::greeting_contains_name ... FAILED
failures:
---- tests::greeting_contains_name stdout ----
thread 'tests::greeting_contains_name' panicked at src/lib.rs:12:9:
assertion failed: result.contains("Carol")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::greeting_contains_name
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
この結果は、アサーションが失敗したことと、そのアサーションが何行目にあるかを示しているだけです。もっと役に立つ失敗メッセージであれば、greeting 関数からの値を出力してくれるでしょう。実際に greeting 関数から得られた値をプレースホルダーに埋め込むフォーマット文字列からなる、カスタム失敗メッセージを追加してみましょう。
pub fn greeting(name: &str) -> String {
String::from("Hello!")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn greeting_contains_name() {
let result = greeting("Carol");
assert!(
result.contains("Carol"),
"Greeting did not contain name, value was `{result}`"
);
}
}
これでテストを実行すると、より多くの情報を含むエラーメッセージが得られます。
$ cargo test
Compiling greeter v0.1.0 (file:///projects/greeter)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.93s
Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)
running 1 test
test tests::greeting_contains_name ... FAILED
failures:
---- tests::greeting_contains_name stdout ----
thread 'tests::greeting_contains_name' panicked at src/lib.rs:12:9:
Greeting did not contain name, value was `Hello!`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::greeting_contains_name
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
テスト出力には実際に得られた値が表示されるので、何が起きると期待していたかではなく、実際に何が起きたのかをデバッグする助けになります。
should_panic による panic の確認
戻り値を確認することに加えて、コードがエラー条件を期待どおりに処理することを確認することも重要です。たとえば、第 9 章のリスト 9-13 で作成した Guess 型を考えてみましょう。Guess を使う他のコードは、Guess のインスタンスが 1 から 100 の値だけを含むという保証に依存しています。その範囲外の値で Guess インスタンスを作成しようとすると panic することを保証するテストを書くことができます。
これを行うには、テスト関数に should_panic 属性を追加します。関数内のコードが panic すればテストは成功し、panic しなければテストは失敗します。
リスト 11-8 は、Guess::new のエラー条件が期待どおりに発生することを確認するテストを示しています。
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 || value > 100 {
panic!("Guess value must be between 1 and 100, got {value}.");
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn greater_than_100() {
Guess::new(200);
}
}
#[should_panic] 属性は、#[test] 属性の後で、それが適用されるテスト関数の前に置きます。このテストが成功したときの結果を見てみましょう。
$ cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)
running 1 test
test tests::greater_than_100 - should panic ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests guessing_game
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
よさそうです! では、値が 100 より大きい場合に new 関数が panic するという条件を削除して、コードにバグを入れてみましょう。
pub struct Guess {
value: i32,
}
// --snip--
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!("Guess value must be between 1 and 100, got {value}.");
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn greater_than_100() {
Guess::new(200);
}
}
リスト 11-8 のテストを実行すると、失敗します。
$ cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.62s
Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)
running 1 test
test tests::greater_than_100 - should panic ... FAILED
failures:
---- tests::greater_than_100 stdout ----
note: test did not panic as expected at src/lib.rs:21:8
failures:
tests::greater_than_100
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
この場合、あまり役に立つメッセージは得られませんが、テスト関数を見ると、#[should_panic] が付いていることがわかります。ここで得られた失敗は、テスト関数内のコードがパニックを起こさなかったことを意味します。
should_panic を使うテストは、不正確になることがあります。should_panic テストは、期待していたものとは別の理由でテストがパニックを起こした場合でも成功してしまいます。should_panic テストをより正確にするために、should_panic 属性にオプションの expected パラメータを追加できます。テストハーネスは、失敗メッセージに指定したテキストが含まれていることを確認します。たとえば、リスト11-9にある Guess の修正後のコードを考えてみましょう。このコードでは、new 関数は値が小さすぎるか大きすぎるかに応じて異なるメッセージでパニックを起こします。
pub struct Guess {
value: i32,
}
// --snip--
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!(
"Guess value must be greater than or equal to 1, got {value}."
);
} else if value > 100 {
panic!(
"Guess value must be less than or equal to 100, got {value}."
);
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "less than or equal to 100")]
fn greater_than_100() {
Guess::new(200);
}
}
このテストは成功します。というのも、should_panic 属性の expected パラメータに指定した値が、Guess::new 関数がパニック時に出力するメッセージの部分文字列になっているからです。期待するパニックメッセージ全体を指定することもでき、その場合は Guess value must be less than or equal to 100, got 200 になります。何を指定するかは、パニックメッセージのどの程度が一意であるか、あるいは動的であるか、そしてテストをどの程度正確にしたいかによって決まります。この場合、テスト関数内のコードが else if value > 100 のケースを実行していることを保証するには、パニックメッセージの部分文字列で十分です。
expected メッセージ付きの should_panic テストが失敗するとどうなるかを見るために、もう一度コードにバグを入れてみましょう。if value < 1 と else if value > 100 のブロック本体を入れ替えます。
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!(
"Guess value must be less than or equal to 100, got {value}."
);
} else if value > 100 {
panic!(
"Guess value must be greater than or equal to 1, got {value}."
);
}
Guess { value }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "less than or equal to 100")]
fn greater_than_100() {
Guess::new(200);
}
}
今度 should_panic テストを実行すると、失敗します。
$ cargo test
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)
running 1 test
test tests::greater_than_100 - should panic ... FAILED
failures:
---- tests::greater_than_100 stdout ----
thread 'tests::greater_than_100' panicked at src/lib.rs:12:13:
Guess value must be greater than or equal to 1, got 200.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
note: panic did not contain expected string
panic message: "Guess value must be greater than or equal to 1, got 200."
expected substring: "less than or equal to 100"
failures:
tests::greater_than_100
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
失敗メッセージは、このテストが実際に期待どおりパニックを起こしたものの、そのパニックメッセージに期待していた文字列 less than or equal to 100 が含まれていなかったことを示しています。この場合に実際に得られたパニックメッセージは Guess value must be greater than or equal to 1, got 200 でした。これで、バグがどこにあるのかを調べ始めることができます。
テストで Result<T, E> を使う
これまでのテストは、失敗するとすべてパニックを起こしていました。Result<T, E> を使うテストを書くこともできます! リスト11-1のテストを、Result<T, E> を使い、パニックする代わりに Err を返すように書き換えたものを示します。
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() -> Result<(), String> {
let result = add(2, 2);
if result == 4 {
Ok(())
} else {
Err(String::from("two plus two does not equal four"))
}
}
}
it_works 関数は、現在 Result<(), String> の戻り値型を持っています。関数本体では、assert_eq! マクロを呼び出す代わりに、テストが成功したときは Ok(()) を返し、失敗したときは内部に String を持つ Err を返します。
テストが Result<T, E> を返すように書くと、テスト本体で疑問符演算子を使えるようになります。これは、その内部のいずれかの操作が Err バリアントを返した場合に失敗すべきテストを書くうえで便利な方法です。
Result<T, E> を使うテストでは、#[should_panic] アノテーションは使えません。ある操作が Err バリアントを返すことを検証するには、Result<T, E> の値に対して疑問符演算子を使っては いけません。代わりに、assert!(value.is_err()) を使ってください。
テストを書くいくつかの方法がわかったので、次はテストを実行するときに何が起きているのかを見ていき、cargo test で使えるさまざまなオプションを探っていきましょう。