🦀

Rust でパラメタライズドテスト

2021/04/09に公開5

今回、Rustでテストコードを書いていて、パラメタライズドテストを省エネで書きたくなった。テストコードにしたって、何度も同じタイピングなんてしたくない。コピペなんて言語同断。

かなしいコード

例えば、こんなコード。

pub fn add(a:i32, b:i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    #[test]
    fn test_add_1_1_returns_2() {
        assert_eq!(add(1, 1), 2);
    }
    #[test]
    fn test_add_0_100_returns_100() {
        assert_eq!(add(0, 100), 100);
    }
}

テストケースごとに似たような見た目のテストコードが増えていくことが目に見えている。

Go言語では、テストケースを構造体にまとめて、テストコードを見やすくすることを推奨している。
GitHub の Wiki にテーブル駆動テストという項目に掲載されている

また、Python の標準ライブラリの一つである unittest にも SubTest という似たような仕組みがある。

一方、Rustにはそのような仕組みがない。Rustは標準ライブラリが小さいことがよく知られているので仕方ない。だからといって、テストコードをコピペしてみっともない真似はしたくない。

Crates.io から探さずに自作する

プログラミング言語には、標準ライブラリ以外にサードパーティライブラリがあるため、さほど困らない。Rust には crates.io がある。JUnit にインスパイアされたという parameterized というライブラリを使うのが簡単そうだ。

Go や Python の例をみるとお分かりのとおり、シンプルな実装でできそうな気がしてくる。

Rust には強力な macro 機能がある。

マクロで作ってみた

#[cfg(test)]
mod tests {
   macro_rules! test_add {
        ($($name:ident: $value:expr, )*) => {
            $(
                #[test]
                fn $name() {
                    let (lhs, rhs, expected) = $value;
                    assert_eq!(crate::sample::add(lhs, rhs), expected);
                }
            )*
        };
    }

    test_add! {
        add1: (1, 1, 2),
        add2: (0, 100, 100),
    }
}

macro_rules を使って test_add マクロを生成する。今回は シンプルなコードなのでマクロを作る手間のほうが大きいが、このコードがサクッとかけるとライブラリに頼ることなくコードがかける。

関数単位のテストケースをマクロで生成しているので、テストが失敗してもほかのテストケースのテストは続行される。

なぜライブラリに頼らないか

ライブラリは便利だが、たくさんインストールすると依存性の解消や、脆弱性への対応などメンテナンスコストが高くなる。

ライブラリ選定に時間をかけるより、自分で書いたほうが早いということもある。盲目的に選択肢を絞るのではなく、いつでも多くの選択肢を見つけられるようにしておくとよい。

※とは言っても、マクロはデバッグが大変になるのであまりお勧めはしない。

実際にマクロを使ってコードを圧縮した事例

37行のコードが18行に圧縮された上、テストコードの意図が読み取りやすくなった。

https://github.com/tamanobi/rusty-journal/commit/baff80a05e19513a78316df3342b21c308ea6ddb

Discussion

kishigurokishiguro

テーブルで作ってみた

Go方面からきた私が通りますよ〜
この作法はBDDのDescribeTableが由来な気がしますね。

pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_add() {
        struct Test {
            input: (i32, i32),
            output: i32,
        }
        let tests = [
            Test {
                input: (1, 1),
                output: 2,
            },
            Test {
                input: (0, 100),
                output: 100,
            },
        ];
        for t in &tests {
            assert_eq!(add(t.input.0, t.input.1), t.output);
        }
    }
}

テーブル+ジェネリクスで作ってみた

pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_add() {
        struct Test<T> {
            input: (T, T),
            output: T,
        }
        let tests = [
            Test {
                input: (1, 1),
                output: 2,
            },
            Test {
                input: (0, 100),
                output: 100,
            },
        ];
        for t in &tests {
            assert_eq!(add(t.input.0, t.input.1), t.output);
        }
    }
}

tamanobitamanobi

@kishiguro さん、コメントとコードありがとうございます! 大変参考になります。

いま出先で確認できないのですが、おそらく kishiguro さんのコードだと test_add 実行中に add がパニックを起こしたとき、 tests のテストケース途中で止まってしまいそうです。

私の書いたマクロでは、関数をマクロで生成されているので、テストケースごとに関数が作られる仕組みになっています。(文中の「関数単位のテストケースをマクロで生成しているので……」という箇所)

Goでいう、 testing.T のような存在が Rust にあれば、それを使ってパニックを処理できそうに思えます。何かご存知でしょうか?

kishigurokishiguro

そうですね。assertでパニックした場合はそこで、test_add()止まっちゃいますね。止めたくない場合はやっぱり@tamanobiさんのようにマクロで書くのがいいと思います。
テーブルで書くメリットはその一覧性によって、人間がテストケース全体を一眼で把握できることにあるとすると、マクロで書くとしても、もうちょっと工夫したくなりますね。

tamanobitamanobi

@kishiguro さん、ありがとうございます! これ以上マクロを工夫しようとすると、短時間で書けなさそう&メンテナンスコストが高くなりそうなので、素直にクレートを使おうかなと思います!

eduidleduidl

forループ等により複数のassertionを繰り返すのは、(テストフレームワークにもよりますが)以下のような欠点があるため、Assertion Rouletteというアンチパターンだと言われています.

  • どこで落ちているかがわかりにい
  • 中断された場合それ以降のテストが実行されない

なので、マクロを使ったやり方の方が、その点では優れているのだと思います.

https://t-wada.hatenablog.jp/entry/design-for-testability#ポイント-アサーションルーレットAssertion-Roulette