Don't Repeat Yourself

Don't Repeat Yourself (DRY) is a principle of software development aimed at reducing repetition of all kinds. -- wikipedia

複数のテストケースをまとめたい際に使える test_case クレート

test_case というクレートを使用すると、複数のパターンのテストを1つのコードでまとめて記述できるようになります。実際に私もこのクレートを使用して、いくつかのテストをまとめて書いてみました。メリットは、重複コードを減らしつつ、テストがどこで失敗したかをひと目でわかるようにできるという点です。デメリットは、テストケースの入力値のコードが多いと、アトリビュートの可読性が下がりそうに見えるという点です。ただ、テストケースの可読性はそこまで重視されない傾向にはあると思うので、あまり気にするほどのことでもないかもしれません。

下記がクレートの GitHub あるいは crates.io のページです。

github.com

https://crates.io/crates/test-case

私が実際に test_case クレートを使ってみた例は下記です。

github.com

できること

複数のテストケースを1つの関数にまとめて assertion できるようになります。専用のマクロが用意されているので、そのマクロに入力の値と期待値を記述し、テストケースに名前をつければ実装できます。正常系のテストはもちろんのこと、専用の構文を利用すると異常系(panic するケース)にも対応できるように作られています。

正常系のテスト

下記のように、いくつかの入力パターンに対して加算のテストを行い、期待値をそれぞれのパターンに対して用意しておくというテストを書くものとします。10+20, 10+0, 0+10 など複数のパターンが考えられます。それぞれに対して assertion を行います。

通常の Rust のテストコードであれば、こうした複数入力パターンのテストについては、関数を別々に用意してテストケースをそれぞれ書くか、あるいは assertion を1つの関数内に複数個用意して対応します。下記の例では複数関数に分けることによって、テストの名前空間を分けるという意図で実装しています。関数を1つにして assertion を複数書く場合は、名前空間は分かれませんが関数がひとつで済みます。

    #[test]
    fn test_10_plus_20_should_work() {
        let e = add(integer(10), integer(20));
        assert_eq!(30, interpreter().interpret(e).unwrap());
    }

    #[test]
    fn test_10_plus_0_should_work() {
        let e = add(integer(10), integer(0));
        assert_eq!(10, interpreter().interpret(e).unwrap());
    }

    #[test]
    fn test_0_plus_10_should_work() {
        let e = add(integer(0), integer(10));
        assert_eq!(10, interpreter().interpret(e).unwrap());
    }

test_case クレートを使用すると、一つの関数にまとめつつ名前空間を分けてテストケースを用意できます。専用のマクロに入力値と期待値、テストケース名を記述することでそれを実現できます。

    use test_case::test_case;

    #[test_case(10, 20 => 30; "10_plus_20")]
    #[test_case(10, 0 => 10; "10_plus_0")]
    #[test_case(0, 10 => 10; "0_plus_10")]
    fn test_plus_should_work(lhs: i32, rhs: i32) -> i32 {
        let e = add(integer(lhs), integer(rhs));
        interpreter().interpret(e).unwrap()
    }

実装の内訳としては、

  • 10, 20 で入力の数値を記述しています。これが、関数の lhsrhs という仮引数に与えられます。
  • => 30 は期待値です。
  • ; の横の "10_plus_20" は、生成する関数名です。このマクロは interpreter::test::test_plus_should_work::_10_plus_20 という名前空間を裏で自動生成します。

となっています。

このテストは単純な数値以外にも、String などの別の組み込み型や、自身で用意した独自のデータ構造あるいは関数であっても適用できます。裏ではマクロによって各テストケースに対するコード生成が走っているだけなようなので、use してインポートしているモジュールについては、通常のコードを書くかのように使用できます。

下記は、別のモジュールにあるデータ構造と関数を使用したテストを記述している例です。add, integer, Expressionast というモジュールの配下にいるものとします。

    use crate::ast::*;

    #[test_case("idnta", add(integer(10), integer(20)) => 30; "assign_addition")]
    fn test_assignment_should_work(name: impl Into<String>, expression: Expression) -> i32 {
        let e = assignment(name, expression);
        let mut interpreter = interpreter();
        interpreter.interpret(e).unwrap())
    }

パニック時のテスト

通常の Rust のパニック時のテストの場合、正常時と同様に複数関数に分けつつ、#[should_panic] というアトリビュートをつけて実行します。このアトリビュートを使用すると裏でパニックが起きることを期待値としてテストが処理され、パニックに関するテストが実行可能です。

   #[test]
    #[should_panic]
    fn test_function_call_check_disable_while() {
        let mut parser = super::function_call();
        let actual = parser.parse("add(while (i > 0) { i; })");
        actual.unwrap();
    }

    #[test]
    #[should_panic]
    fn test_function_call_check_disable_if() {
        let mut parser = super::function_call();
        let actual = parser.parse("add(if (n > 1) { 1; } else { 0; })");
        actual.unwrap();
    }

    #[test]
    #[should_panic]
    fn test_function_call_check_disable_assignment() {
        let mut parser = super::function_call();
        let actual = parser.parse("add(n = 1)");
        actual.unwrap();
    }

このタイプのテストについても、test_case クレートの panics オプションを用いることで、正常系と同じように複数のテストケースをまとめて記述できるようになります。=> panics という記述を入力値の後ろに付け加えることで、実質裏で #[should_panic] による検知を行ってくれるという仕組みになっています。

また、panic の後ろには、パニック時に出力される文字列を記述し、その期待値を照合することもできます。この機能を使うと、いくつかパニックする可能性があるけれど、それらのうちのひとつを検出するテストを書きたい、というユースケースに対応することができます。

    #[test_case("add(while (i > 0) { i; })" => panics)]
    #[test_case("add(if (n > 1) { 1; } else { 0; })" => panics)]
    #[test_case("add(n = 1)" => panics)]
    fn test_function_call_disability<'a>(source: &'a str) {
        let mut parser = crate::parser::function_call();
        let actual = parser.parse(source);
        actual.unwrap();
    }

ユースケース、デメリット

今回私は言語処理系の入力値の検査に使用しました。言語処理系は、ちょっと形が違うだけの複数入力をいくつかテストするというケースがあるのですが、それらを効率よくまとめるのに大きく役立ちました。デメリットが強いてあるとすれば、アトリビュートに記述する入力値のコード量が増えると、その分アトリビュートのコード上に占める領域が広がり、認知負荷が上がりそうだという点でした。

ユースケース

言語処理系では、入力値に対する期待値をいくつかまとめてテストしたいというケースが発生します。具体的には先ほどのような足し算であったり、あるいは文字列の長さが違うだけのケースをそれぞれ通るか確かめたい、といったニーズが考えられます。

その際通常の Rust のテスト機能では、関数を複数分けるか、1つの関数の中に複数 assertion を設置するかという選択を取ることになります。関数を複数分けた場合には、コードの重複が多く発生することになり、若干メンテナンスコストが発生します。また、1つの関数の中に複数 assertion を設置すると、どこでテストが失敗したかが瞬時にはわかりにくいというデメリットがあると思います。

今回紹介した test_case クレートは、関数を複数に分けつつもコードの重複を発生させないテストケースの記述を可能にしてくれます。これを利用することにより、コードの量が増えてメンテナンスコストがかさむことと、テスト失敗時にどこで落ちたかわかりにくいという問題の両方をよい形で解決できるようになります。

感じたデメリットと回避方法

ただ注意点は可読性を高く保つのが困難だということです。test_case のアトリビュートの入力時の記述に通常の Rust コードを使用するのですが、1行に収まらない構造体の生成のようなコードを書いてしまうと、アトリビュートの記述が複数行に渡ってしまうことになるため、少し読みづらくなりそうだなと思いました。

もちろん、可読性の担保のためにテスト用の独自関数を用意して、可能な限りアトリビュートの記述を1行で済ませられるように頑張るという方法も考えられます。たとえば構造体の生成には new 関数をテスト用に特別に用意するなどです。が、テストコードのために通常のコードを増やすのは考える余地はありそうに見えます。一方で、テストコードの可読性は現場ではそこまで重視されない傾向にはあると思うので、そこまでがんばる必要はないという意見も十分ありえます。

使い所を選んで使う必要はありますが、全般的には記述量を減らせてよいクレートだと思いました。