🦀

Rustでライブラリを使わずにTable Driven Testを書いてみる

2021/09/23に公開

この記事の概要

Golang の Table Driven Test のような形式[1]でテストを書きたいと考え、工夫してみた話です。

最初にライブラリを探しましたが、Rust でよく使われているライブラリはなさそうに見えたので、ライブラリなしでそれっぽく書いてみることにしました。

Rust 超絶初心者でして、Rust の常識的にヤバそうなことをしていたら教えていただけるとありがたいです。

Table Driven Test で書きたい理由

自分が思う最も重要な Pros だけを言うと、「いちテストケースを理解するにあたり、読む必要があるコードが集中する。」という点です。
ある関数の入力と期待値をほぼリテラル記述でまとめて変数として宣言する形式なので、その変数だけを読めばいちテストケースが理解できるようになります。

一方で Cons もあり、特に「いちテストケースあたりのコード量がとても多くなる。」という点が顕著だと思います。

また、ある関数の実行が複雑すぎるデータに依存しているときは、この手法は使えないこともあります。[2]

基本の書き方

TestCase インスタンスをひとつのテストケースとして定義します。
ご覧の通りですが、その「テストケース」は cargo test 上ではテストケースとして認識されません。今のところは特に問題ないので、諦めています。

関数が引数に参照を要求するときは、assertion の呼び出し時に & を付与する方が楽だと思います。

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

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

    #[derive(Debug)]
    struct TestCase {
        args: (i32, i32),
	expected: i32,
    }

    #[test]
    fn it_works() {
        let table = [
            TestCase {
                args: (1, 2),
                expected: 3,
            },
            TestCase {
                args: (-1, -2),
                expected: -4,
            },
        ];
        for test_case in table {
            assert_eq!(
                add(test_case.args.0, test_case.args.1),
                test_case.expected,
                "Failed in the {:?}.",
                test_case,
            );
        }
    }
}

テストが失敗したときは、以下のような出力を行います。

cargo test
(...中略...)
failures:

---- utils::tests::it_works stdout ----
thread 'utils::tests::it_works' panicked at 'assertion failed: `(left == right)`
  left: `-3`,
 right: `-4`: Failed in the TestCase { args: (-1, -2), expected: -4 }.', src/utils.rs:587:13


failures:
    utils::tests::it_works

test result: FAILED. 35 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

error: test failed, to rerun pass '--lib'

テストケース名を付ける

テストケースの意図がデータから伝わりにくいときに補助する効果を期待しています。

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

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

    #[derive(Debug)]
    struct TestCase {
        args: (i32, i32),
	expected: i32,
        name: String,
    }

    #[test]
    fn it_works() {
        let table = [
            TestCase {
                name: String::from("it can calculate positive numbers"),
                args: (1, 2),
                expected: 3,
            },
            TestCase {
                name: String::from("it can calculate negative numbers"),
                args: (-1, -2),
                expected: -3,
            },
        ];
        for test_case in table {
            assert_eq!(
                add(test_case.args.0, test_case.args.1),
                test_case.expected,
                "Failed in the \"{}\".",
                test_case.name,
            );
        }
    }
}

構造体

自身を更新する構造体のメソッドを例にしましたが、このときはメソッドの戻り値をテストの期待値として直接利用できないので、「テストデータだけを見るとテスト内容がわかる」とも言い難くなっています。

また、主体となる構造体を生成する必要があるので、コードの量がより増えています。

#[derive(Debug)]
struct Coordinates {
    x: i32,
    y: i32,
}

impl Coordinates {
    fn translate(&mut self, x: i32, y: i32) {
        self.x += x;
        self.y += y;
    } 
}

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

    #[derive(Debug)]
    struct TestCase {
        args: (i32, i32),
        expected_x: i32,
        expected_y: i32,
        instance: Coordinates,
    }

    fn create_test_instance() -> Coordinates {
        Coordinates {
            x: 0,
            y: 0,
        }
    }

    #[test]
    fn translate_works() {
        let mut table = [
            TestCase {
                instance: create_test_instance(),
                args: (1, 2),
                expected_x: 1,
                expected_y: 2,
            },
            TestCase {
                instance: Coordinates {
                    x: -1,
                    ..create_test_instance()
                },
                args: (-1, -2),
                expected_x: -2,
                expected_y: -2,
            },
        ];
        for test_case in &mut table {
            test_case.instance.translate(test_case.args.0, test_case.args.1);
            assert_eq!(
                test_case.instance.x,
                test_case.expected_x,
                "Failed in the {:?}.",
                test_case,
            );
            assert_eq!(
                test_case.instance.y,
                test_case.expected_y,
                "Failed in the {:?}.",
                test_case,
            );
        }
    }
}

OptionResult を返す関数

以下は Option を返す場合の例ですが、戻り値の型が異なるときにそれを吸収できるような expected の書き方が思いつかず、戻り値の型毎にテストケースの集合を分けるようにしました。
Result のときも、OkErr で分けました。

もしかしたら解決できる方法があるのかもしれないのですが、このためにテスト用の無理したロジックが増えると本来の目的に合わなくなりそうでした。

(なお、本筋ではない話ですが、テスト用モジュールの中に更にサブモジュールを作って階層化するのはやってもいいことなのでしょうか・・・?)

fn find_first_number_greater_than_zero(list: &Vec<i32>) -> Option<i32> {
    list.iter().map(|&e| e).find(|&e| e > 0)
}

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

    mod when_it_returns_some_value {
        use super::*;

        #[derive(Debug)]
        struct TestCase {
            args: (Vec<i32>,),
            expected: i32,
        }

        #[test]
        fn it_works() {
            let table = [
                TestCase {
                    args: (vec![-1, 0, 1, 2],),
                    expected: 1,
                },
            ];
            for test_case in table {
                assert_eq!(
                    find_first_number_greater_than_zero(&test_case.args.0).unwrap(),
                    test_case.expected,
                    "Failed in the {:?}.",
                    test_case,
                );
            }
        }
    }

    mod when_it_returns_none {
        use super::*;

        #[derive(Debug)]
        struct TestCase {
            args: (Vec<i32>,),
        }

        #[test]
        fn it_works() {
            let table = [
                TestCase {
                    args: (vec![-2, -1, 0],),
                },
            ];
            for test_case in table {
                assert_eq!(
                    find_first_number_greater_than_zero(&test_case.args.0).is_none(),
                    true,
                    "Failed in the {:?}.",
                    test_case,
                );
            }
        }
    }
}
脚注
  1. "Data Driven Test" もしくは "Parameterized Test" とも言えるのかもしれませんが、それらの中で最も把握していると思っている用語を使いました。 ↩︎

  2. ただこのときは、関数や構造体の設計に工夫の余地がある可能性が高いと思います。 ↩︎

Discussion