Rustによるパラメータ化テストの紹介
明けましておめでとうございます。今年もどうぞよろしくお願いいたします。
自分事で恐縮ですが、私はこれまでGo言語を使うことが多く、Rustはまだまだ勉強中の身であります。
Goにはテーブルテストというパラメータ化テストを簡潔に書くプラクティスがあります。テーブルテストはユニットテストとしての簡潔さもさることながら、仕様を伝える・把握するためのテストとしての側面からも気に入っており、よく書いていました。
Goのテーブルテストに関してはDave Cheney氏による Prefer table driven tests という有名なブログエントリがあり、これを読むとテーブルテストを書かずにはいられなくなります。
一方でRustにはrstestというパラメータ化テストを書くためのライブラリがあり、使い方についてはすでに各所で紹介されています。
- CADDi ENGINEER Tech Blog — Rustでパラメーター化テスト
- 気ままに技術勉強ブログ — Rustのrstestでパラメータテストをしてみる
- BioErrorLog Tech Blog — Rustでparameterized testを書く | rstest
そこでここではRustによるパラメータ化テストをDave Cheney氏のブログエントリをオマージュした形で紹介することで、パラメータ化テストを書くメリットを感じていただけたらと思います。
テスト対象の関数を定義
まずはテスト対象とする関数を定義します。
以下のように、与えられた引数で文字列を分割するsplit
関数を定義します。
pub fn split<'a>(s: &'a str, sep: &'a str) -> Vec<&'a str> {
let mut target = s;
let mut result = vec![];
while let Some(at) = target.find(sep) {
let (head, rest) = target.split_at(at + 1);
result.push(head.split_at(head.len() - 1).0);
target = rest;
}
result.push(target);
result
}
例えばa/b/c
という文字列に対し、/
をsepとして与えると["a", "b", "c"]
を結果として得ます。
ではこの関数に対しユニットテストを書いていきます。
シンプルなテスト
まず先ほどの例をテストするテストケースを記述してみます。
#[cfg(test)]
mod test {
use crate::split;
#[test]
fn test_split() {
let got = split("a/b/c", "/");
let want = vec!["a", "b", "c"];
assert_eq!(got, want, "expected: {want:?}, got: {got:?}");
}
}
実行してみると、テストが成功していることが確認できます。
$ cargo test
...
running 1 test
test test::test_split ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 7 filtered out; finished in 0.00s
同様に以下の二つのテストケースを追加します。
#[cfg(test)]
mod test {
...
#[test]
fn test_split_wrong_sep() {
let got = split("a/b/c", ",");
let want = vec!["a/b/c"];
assert_eq!(got, want, "expected: {want:?}, got: {got:?}");
}
#[test]
fn test_split_no_sep() {
let got = split("abc", "/");
let want = vec!["abc"];
assert_eq!(got, want, "expected: {want:?}, got: {got:?}");
}
...
}
こちらも問題なくテストがパスしているのではないかと思います。
$ cargo test
...
running 3 tests
test test::test_split_no_sep ... ok
test test::test_split ... ok
test test::test_split_wrong_sep ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
テーブルテストの導入
先のテストケースをよくみるとそれぞれ非常によく似ていることがわかります。具体的にはinput
, sep
, want
を変数として、以下の形になっています。
#[test]
fn pseudo_test_split() {
let got = split(input, sep);
assert_eq!(got, want, "expected: {want:?}, got: {got:?}");
}
そこで以下のようなTestCase
型を導入し、それぞれのテストを一つの配列にまとめ、for-loopで処理してみましょう。
struct TestCase<'a>(&'a str, &'a str, Vec<&'a str>);
#[cfg(test)]
mod test {
...
#[test]
fn test_split() {
let tests = [
TestCase("a/b/c", "/", vec!["a", "b", "c"]),
TestCase("a/b/c", ",", vec!["a/b/c"]),
TestCase("abc", "/", vec!["abc"]),
];
for TestCase(input, sep, want) in tests {
let got = split(input, sep);
assert_eq!(got, want, "expected: {want:?}, got: {got:?}");
}
}
...
}
テストも問題なく実行・成功しているかと思います。
これだけでも重複が減り、スッキリとした結果、入力と期待値の組を把握するのがかなり簡単になったのではないでしょうか。
rstestの導入
ではここにrstestを導入してみましょう。
$ cargo add --dev rstest
rstestで上記のテーブルテストを書き直したものがこちらです。
#[cfg(test)]
mod test {
use rstest::*;
...
#[rstest]
#[case("a/b/c", "/", vec!["a", "b", "c"])]
#[case("a/b/c", ",", vec!["a/b/c"])]
#[case("abc", "/", vec!["abc"])]
fn test_split_rstest(#[case] input: &str, #[case] sep: &str, #[case] want: Vec<&str>) {
let got = split(input, sep);
assert_eq!(got, want, "expected: {want:?}, got: {got:?}",);
}
...
}
もしかしたら個人差あるかもしれませんが、先ほどより、よりスッキリ書けている気がします。
rstestではさらに各テストケースに以下のように名前をつけることができます。
#[cfg(test)]
mod test {
use rstest::*;
...
#[rstest]
#[case::simple("a/b/c", "/", vec!["a", "b", "c"])]
#[case::wrong_sep("a/b/c", ",", vec!["a/b/c"])]
#[case::no_sep("abc", "/", vec!["abc"])]
fn test_split_rstest(#[case] input: &str, #[case] sep: &str, #[case] want: Vec<&str>) {
let got = split(input, sep);
assert_eq!(got, want, "expected: {want:?}, got: {got:?}",);
}
...
}
これにより先ほどのテーブルテストではtest::test_split ... ok
とまとめられていた結果の表示が、以下のように何が成功し、何が失敗したのかわかりやすくなります。
$ cargo test
...
running 3 tests
test test::test_split_rstest::case_3_no_sep ... ok
test test::test_split_rstest::case_2_wrong_sep ... ok
test test::test_split_rstest::case_1_simple ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
おまけ — 差分のハイライト
せっかくなので失敗するテストケースも追加してみます。
#[cfg(test)]
mod test {
use rstest::*;
...
#[rstest]
#[case::simple("a/b/c", "/", vec!["a", "b", "c"])]
#[case::wrong_sep("a/b/c", ",", vec!["a/b/c"])]
#[case::no_sep("abc", "/", vec!["abc"])]
#[case::trailing_sep("a/b/c/", "/", vec!["a", "b", "c"])]
fn test_split_rstest(#[case] input: &str, #[case] sep: &str, #[case] want: Vec<&str>) {
let got = split(input, sep);
assert_eq!(got, want, "expected: {want:?}, got: {got:?}",);
}
}
実行してみると、以下のように失敗しました。
$ cargo test
...
running 4 tests
test test::test_split_rstest::case_1_simple ... ok
test test::test_split_rstest::case_2_wrong_sep ... ok
test test::test_split_rstest::case_3_no_sep ... ok
test test::test_split_rstest::case_4_trailing_sep ... FAILED
failures:
---- test::test_split_rstest::case_4_trailing_sep stdout ----
thread 'test::test_split_rstest::case_4_trailing_sep' panicked at src/lib.rs:71:9:
assertion `left == right` failed: expected: ["a", "b", "c"], got: ["a", "b", "c", ""]
left: ["a", "b", "c", ""]
right: ["a", "b", "c"]
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
test::test_split_rstest::case_4_trailing_sep
test result: FAILED. 3 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
このままでも特に見にくいというほどではありませんが、期待する配列の要素が増えてくると何がミスマッチしているのかわかりにくくなりそうです。
そこでpretty_assersions
クレートを導入してみます。
$ cargo add --dev pretty_assersions
assert_eq!
のdrop-in replacement (置き換えるだけの変更) となっているので以下のようにpretty_assertions::assert_eq
マクロをインポートするだけでOKです。
#[cfg(test)]
mod test {
use pretty_assertions::assert_eq;
...
}
すると以下のように期待する配列との差分をハイライトしてくれるようになりました。
以上Rustでのパラメータ化テストの書き方を紹介しました。テーブルテストを書いてみよう、rstestを使ってみよう、という気持ちになってもらえたら大変嬉しいです。
rstestにはまだまだ便利な機能がありますので詳しくはドキュメントを参照してください。
Happy parameterised testing!
Discussion