Rustについて基本からしっかり学んでいく本連載。第14回は、Rustの備える自動テスト機能である単体テストと統合テスト、ドックテストについて。
この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。
Rustは、標準で自動テスト機能をサポートしています。自動テスト機能とは、文字通りプログラムの正当性を自動でテストする機能で、バグのない安全なプログラムの開発には必須です。テストを自動化することで、コードを改変した際の動作検証の手間も最低限に抑えられます。
Rustの備える自動テスト機能には、大きく分けて以下の3つがあります。
単体テストは、ユニットテストとも呼ばれ、テストの基本です。関数やメソッド単位で実行し、与えた引数に対して期待する値が返るかなど、基本的な動作を検証するために用いられます。
結合テストは、複数の関数やメソッド、モジュールやクレートにまたがったテストです。単体テストはあくまでも関数、メソッドの単体での動作をテストするものですが、それらが組み合わさった機能としての挙動をテストするのが結合テストです。
ドックテストのドックとは、ドキュメントのことです。ソースファイルのドキュメンテーションコメントに含まれるコードをテストするために用いられます。ドキュメンテーションコメントにはコード例が記載されることが多いですが、それをコメントとしたままでテストできるので便利です。
テストには、ほかにシステムテスト(ユーザーの操作などの外部入力に対して期待する結果が得られるかどうかのテスト)などがありますが、Rustでは単体テスト、結合テストを中心にサポートします。今回は、この2つにドックテストを加えて紹介していきます。なお、今回のサンプルはtestingsパッケージとして作成していきます。
適切なテストのためには、テストプロセスの設計から始まる作業の策定やテスト項目、テストケースの作成が必要となってきます。また、仕様書を基にするブラックボックステスト、内部設計を基にするホワイトボックステストの分類もあります。それらをここで全て紹介することはできないため、ごくシンプルなテストケースに限定し、サンプルを紹介していくことにします。
単体テストの対象とする関数やメソッドは、通常はライブラリクレートとして独立させておきます。ここでは、ライブラリクレートを1個作成し、その中に関数を1個作成して、その関数を単体テストする過程を通じて、単体テストを学んでいきましょう。
ライブラリクレートの作成については、これまで何度か触れました。cargo newコマンドで--libオプションを付けてパッケージを作成すると、ライブラリクレートが作成されます。
% cargo new --lib testings Created library `testings` package
以下は、作成されたライブラリクレートです。
#[cfg(test)] (1) mod tests { (2) #[test] (3) fn it_works() { (4) assert_eq!(2 + 2, 4); (5) } }
これは自動生成されたライブラリクレートの中身ですが、単体テストのために必要な特徴的な構文がたくさん詰まっています。自分でテストのためのコードを書くためにも必要なものですから、しっかりと見ておきましょう。
(1)は、test構成でのみコンパイルされるコードの指定です。他の構成(debug構成、release構成などテストコードを必要としない構成)においてテストのためのコードがコンパイルされ、結果としてのバイナリに含まれてしまう無駄を省くための注釈(アノテーション)です。
(2)は、テストのコードのためのモジュールの宣言です。このモジュール名は、常にtestsとなることに注意してください。
(3)は、続く関数の宣言がテストメソッドであることを示す注釈です。コンパイラは、この注釈が付いた関数をテスト関数として認識し、実行します。それぞれのテスト関数で共有するサブ関数など、テスト関数として実行しない関数にはこの注釈は不要です。
(4)は、テスト関数の宣言です。名前は自由で構いませんが、テストを実行するとこの名前で経過が出力されますから、テスト関数の目的が分かる名前を付けます。例えば、この場合はit_worksですから「正しく動く」ことになるでしょう。ここに、失敗するテストコードを書くのは意味的に不適切です。
(5)は、テスト関数の内容です。ここでは、assert_eq!マクロを呼び出して2つの引数の値が等しいことを判定しているだけですが、実際のテストでは対象の関数やメソッドを呼び出すコードを記述します。assert_eq!マクロをはじめとする、テスト関数で有用なマクロについては別途紹介します。
自動で作成されたコードをそのまま使って、テストを実行してみましょう。テストは、cargoコマンドの引数にtestを指定して実行します。
% cargo test Compiling tests v0.1.0 (/Users/nao/Documents/atmarkit_rust/testings) Finished test [unoptimized + debuginfo] target(s) in 6.38s Running unittests (target/debug/deps/testings-f2ec298dff73e4f5) running 1 test (1) test tests::it_works ... ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s (2) Doc-tests tests (3) running 0 tests test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
見ての通り、ずらっとテストの結果が出力されます。
(1)は、単体テストの実行状況です。1個のテストを実行し、個別のテスト(ここではtests::it_works)の結果はokだったという意味です。
(2)は、単体テストの実行サマリーです。ここでは成功(ok)とされていて、その内容は通過(passed)、失敗(failed)、無視(ignored)、計測(measured)、除外(filtered out)のそれぞれで出力されます。ここでは、okという結果だけを確認してください。
(3)以降は、ドックテストの結果ですが、ドキュメンテーションコメントを入れていないのでテスト結果はゼロになります。実行サマリーも成功(ok)になります。
ここまでは、既定で作成されるコードを見て実行しただけですので、そこにはテスト対象の関数はありませんでした。実際のテストは、対象の関数があってのものですから、次はこれを作成して、テストしてみましょう。
関数の処理内容はシンプルなものにします。2つの整数型の引数を加えて、それを返すadder2関数です。関数の処理内容に問題がある場合のテストもしたいので、正しくない結果を返すadder2_bad関数も用意します。これを、先ほどの既定の内容の前に記述します。
// 正しい動きをする関数 pub fn adder2(a: i32, b:i32) -> i32 { a + b } // 正しくない動きをする関数 pub fn adder2_bad(a: i32, b:i32) -> i32 { a + b + 1 }
これを、正しい結果が得られる引数の組み合わせでそれぞれテストします。既定のテストコードの後に、2つのテストメソッドを挿入します。
…略… mod tests { …略… // 正しく動作する関数をテストする #[test] fn add_success() { assert_eq!(crate::adder2(5, 4), 9) } // 正しくない動作する関数をテストする #[test] fn add_fail() { assert_eq!(crate::adder2_bad(5, 4), 9) } }
それぞれ、adder2関数とadder2_bad関数を同じ引数で呼び出していますが、モジュールからの呼び出しになるのでクレートルート(crate)を明示して呼び出していることに注意してください。引数は同じなので、片方は成功、片方は失敗となるはずです。実行してみましょう。
% cargo test …略… running 3 tests (1) test tests::it_works ... ok test tests::add_success ... ok test tests::add_fail ... FAILED failures: (2) ---- tests::add_fail stdout ---- thread 'tests::add_fail' panicked at 'assertion failed: `(left == right)` left: `8`, right: `7`', src/lib.rs:17:9 …略… test result: FAILED. 2 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s (3)
(1)を見ると、3つのテストが実行され、そのうち3つ目(add_failテスト関数)が失敗(FAILED)となっていることが分かります。さらに、(2)以降には失敗したテストの内容が出力されます。最初に出力されているのは、add_failメソッドの標準出力です。よく読むと、スレッド'tests::add_fail'でpanicが起きたと出力されているように、assert_eq!マクロは引数が等しくないとpanicを発生させます。これから分かるように、テスト関数ではpanicの発生の有無で問題の有無を判定しているのです。
最後の(3)では、FAILEDとはっきりとテスト失敗であることが示されます。
ここまで、テスト関数にはassert_eq!マクロを使用してきました。このマクロは、2つの引数の値が等しいか判定し、等しくない場合にはpanicを発生させます。このようなマクロはアサーションマクロといい、ほかにassert_ne!(引数が等しい場合にpanicを発生)、assert!(引数がfalseである場合にpanicを発生)があります。目的により使い分けられますが、多くの場合はassert_eq!マクロで事足りるでしょう。
assert_eq!マクロは2つの引数が等しいか判定するだけなので、assert!マクロを使って「assert!(a == b)」のようにも書けます。しかし、上記の出力結果(leftとrightの値)のように、assert_eq!マクロはどのような引数が渡されたか出力してくれますので、単にfalseであったと出力するassert!マクロよりはデバッグに有用です。
なお、assert!マクロにはpanic時に出力するメッセージをオプションで指定することもできますので、カスタム化されたメッセージが必要な場合には、assert!マクロの利用を検討しましょう。
これまで紹介した例は、assert_eq!マクロなどを使って関数から正しい値が返ってくるかを検証していました。ただし、場合によっては関数内でpanicを起こすことが正常というケースも考えられます。つまり、関数が常に何らかの値を返すとは限らない、というケースです。このような場合には、should_panic注釈を使って関数がpanicを起こすことを期待する、といったことを指定できます。
これまでと同様に、src/lib.rsファイルに関数を2個追加します。この関数は、引数が0ならpanicを発生させ、そうでない場合には引数の逆数を返します。これにも、意図的にpanicを発生させない関数を用意しています。
pub fn reciprocal(n: f64) -> f64 { // nが0ならpanicとするコード if n == 0.0 { panic!("0の逆数は計算できません!"); } 1.0 / n } pub fn reciprocal_bad(n: f64) -> f64 { // nが100ならpanicとする間違ったコード if n == 100.0 { panic!("0の逆数は計算できません!"); } 1.0 / n }
そして、テスト関数も2個追加します。
…略… mod tests { …略… #[test] #[should_panic] (1) fn reciprocal_success() { crate::reciprocal(0.0); (2) } #[test] #[should_panic] fn reciprocal_fail() { crate::reciprocal_bad(0.0); (3) } }
ここで注目するのは、(1)のshould_panic注釈です。この指定により、テスト関数でpanicが発生することを期待します。(2)のreciprocal関数の呼び出しでは、期待通りにpanicが起きるはずです。そして(3)のreciprocal_bad関数の呼び出しでは、panicは発生しないはずです。テストを実行して、これを確かめてみましょう。
% cargo test …略… running 5 tests test tests::add_success ... ok test tests::reciprocal_fail ... FAILED (1) test tests::reciprocal_success ... ok test tests::it_works ... ok test tests::add_fail ... FAILED failures: ---- tests::reciprocal_fail stdout ---- note: test did not panic as expected (2) …略… failures: tests::add_fail tests::reciprocal_fail …略…
この出力の見方もこなれてきたのではないでしょうか? (1)は、reciprocal_failテスト関数が失敗したことを表しています。この関数は、意図的にpanicを起こさない関数を呼んでいるので、失敗したわけです。(2)はテストが期待通りにpanicしなかったと出力しています。
このように、単体テストでは関数が意図した値を返すか、意図した通りにpanicを起こすかなどを検証することができます。
結合テストは、単体テストとは異なった場所にテスト関数を用意します。パッケージの直下、すなわちsrcフォルダと同じ階層にtestsフォルダを作成し、そこにテスト関数のためのクレートと関数本体を用意します。testsフォルダはテスト実行時にのみ参照される特別なフォルダです。テストのみに使われることが分かっているので、cfg注釈は不要です。
testsフォルダを作成して、そこにintegration_test.rsファイルを作成しましょう。内容は、単体テストで取り上げたadder2関数をテストするものとします。
#[test] fn add_success() { assert_eq!(tests::adder2(5, 4), 9) (1) }
既述の通り、[#cfg(test)]注釈が存在せず、スッキリとした構造になっています。テスト関数に#[test]注釈を付記するのは同様です。テスト関数の書き方も同様ですが、基本的に外部クレートにある関数の呼び出しとなるので、クレートルートにあるモジュールtestsで修飾して関数を呼び出す必要があります。use文を使って名前空間をインポートして修飾を省くこともできます。
実行してみましょう。ここで、--testオプションを使ってみます。このオプションは、テストする対象のクレートを指定できます。
% cargo test --test integration_test Compiling tests v0.1.0 (/Users/nao/Documents/atmarkit_rust/testings) Finished test [unoptimized + debuginfo] target(s) in 1.36s Running tests/integration_test.rs (target/debug/deps/integration_test-778908a769026070) running 1 test test add_success ... ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
テストを絞りましたので、単体テストは実行されません。結果も、1個のテストを実行して成功したというシンプルなものです。ここでは、統合テストといいながらも実際には1個の関数を呼び出しただけでしたが、実際には複数クレートにまたがったテストコードを記述していくことになります。
最後にドックテストを紹介します。既述の通り、ドックテストとはソースファイル中のドキュメントテーションコメントに書かれたコードをテストします。Rustには、便利なドキュメント自動作成機能が組み込まれており、ドキュメントテーションコメントを適切に記述することで、簡単にHTMLによるドキュメントページを作成できます。ここではドキュメンテーションコメントの詳細は省きますが、ドックテストの簡単な例を紹介しましょう。
作成済みのadder2関数に、以下のようにドキュメンテーションコメントを追加します。
/// Adds two number given. /// /// # Arguments /// * `a` - 1st number /// * `b` - 2nd number /// /// # Examples /// /// ``` /// let a = 5; /// let b = 4; /// assert_eq!(9, testings::adder2(a, b)); /// ``` pub fn adder2(a: i32, b:i32) -> i32 { …略…
ここでテストを実行します。なお、自動テストではある段階のテストに失敗すると次の段階のテストは実行されません。単体テストの結果にかかわらずドックテストが実行されるように、--docオプションを付けてドックテストのみを実行します。なお、ドキュメントファイルの作成のためにcargo docコマンドを先だって実行しています。
% cardo doc Documenting testings v0.1.0 (/Users/nao/Documents/atmarkit_rust/testings) Finished dev [unoptimized + debuginfo] target(s) in 0.64s % cargo test --doc Finished test [unoptimized + debuginfo] target(s) in 0.00s Doc-tests testings (1) running 1 test test src/lib.rs - adder2 (line 10) ... ok (2) …略…
(1)においてドックテストが実行されることが示され、(2)に結果が出力されました。このようにドックテストは、ドキュメンテーションコメント中のコードのテストも実行できますので、ドキュメント中のコードがいつの間にか動かなくなっていた、という問題を回避できます。
今回は、Rustの備える自動テストの機能として、単体テストと結合テスト、そしてドックテストの方法を紹介しました。サンプル程度の小さな関数では実感できないと思いますが、たくさんの関数を作ってメンテナンスしていく場合、自動的にテストができるのは非常に便利です。
次回は、Rustの入出力をファイルシステムと絡めて紹介する予定です。
WINGSプロジェクト
有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティー(代表山田祥寛)。主にWeb開発分野の書籍/記事執筆、翻訳、講演等を幅広く手掛ける。2021年10月時点での登録メンバーは55人で、現在も執筆メンバーを募集中。興味のある方は、どしどし応募頂きたい。著書、記事多数。
・サーバーサイド技術の学び舎 - WINGS(https://wings.msn.to/)
・RSS(https://wings.msn.to/contents/rss.php)
・Twitter: @yyamada(https://twitter.com/yyamada)
・Facebook(https://www.facebook.com/WINGSProject)
Copyright © ITmedia, Inc. All Rights Reserved.