Rustでパラメーター化テスト

こんにちは!@ryokotmngです🙋🏻‍♀️ 本記事は、キャディ Advent Calendar 2020 – Qiita の13日目の記事です。昨日の記事は@kuwana-kbさんの、DDD のパターンを Rust で表現する ~ Entity 編 ~ でした。 今日は主に、rstestというcrateを使い、Rustでパラメーター化テストをするときに便利な書き方について説明します。

パラメーター化テストとは

パラメーター化テストとは、検証対象のソースコードが想定した挙動になることを保証できるような入力パラメーターの値と結果を設定し、同じロジックのテストをそれぞれのパラメーターに対して行うテストのことです。通常、関数やメソッドに対して行われます。 ある関数をテストするとき、定義域 (インプットの取り得る値の範囲) も値域 (アウトプットが取り得る値の範囲) も非常に限定されているという場合もありますが、抽象度が高いオブジェクトを扱うソースコードになってくると、定義域も値域も広範囲に渡り、複雑な条件によってアウトプットが変化するようなものが増えてきます。 パラメーター化テストは境界テストに適しているため、そのようなある程度複雑化したアプリケーションにおいて、最もよく見られるタイプのテストのひとつだと思います。 一方で、パラメーター化テストはプロパティベース・テストと違い、網羅性を保証するものではありません。定義域と値域の特徴を表す境界値を明記することでソースコードの挙動の特徴を表すものであり、そのためエンジニアにとってドキュメントのような役割も果たします。

rstestというcrateについて

crates.ioの説明を拝借すると、rstestは、フィクスチャベースのテストフレームワークで、フィクスチャとテーブルベースのテストを書くためのツールです。 フィクスチャとは、テストを実行、成功させるために必要な状態や前提条件の集合のことを言います。 また、テーブルベースのテストとは、一般的にテーブル駆動テストと呼ばれるもので、テストの入力と期待値をテーブルの行に記載し、テーブルを走査しながら実行していくテストのことです。 Goの公式ドキュメントで言及されていることから、Goコミュニティではよく使われますよね。

rstestの使い方については、主にこちらのドキュメントを参考に書きました。 このドキュメントは非常にわかりやすく書かれているため、このセクションはドキュメントの和訳のような内容になっている箇所も多くあります。 個人的にはこのcrateはかなり使いやすく、テストの可読性を高めコード量を減らすことに貢献すると思っているので、テストに悩む人の目に止まったら良いなと思って取り上げた次第です。 2020年12月10日時点の情報として、crates.ioでのダウンロード数は合計11万程度、GithubのStarは145です。

rstestのバージョンは0.6.4を用います。 (他の大多数のcrate同様、rstestもバージョン1に至っていません😇)

パラメーター化テスト

既存のフィボナッチ数を返す関数に対してテストを書くと仮定します。 まず、Rustでテストを書く際、The Bookにもあるように、このような書き方をするのが一般的かと思います。

普通のテスト

#[test]
fn fibonacci_test() {
    assert_eq!(fibonacci(0), 0);
    assert_eq!(fibonacci(1), 1);
    assert_eq!(fibonacci(2), 1);
    assert_eq!(fibonacci(3), 2);
    assert_eq!(fibonacci(4), 3);
    assert_ne!(fibonacci(0), 1); // not equalのテスト
}

これが、rstestを使うと、このように書くことができます。

rstestを使ったテスト

use rstest::rstest;

#[rstest(input, expected,
    case(0, 0),
    case(1, 1),
    case(2, 1),
    case(3, 2),
    case(4, 3),
    #[should_panic]
    case(0, 1), // not equalのテスト
)]
fn fibonacci_test(input: u32, expected: u32) {
    assert_eq!(expected, fibonacci(input));
}

上記のソースコードは、inputexpectedという変数を定義し、caseの引数をそれぞれ代入してテストを実行してくれます。 この程度の簡単なロジックだとありがたみがわかりづらいかもしれませんが、例えば、もしinputexpectedの値が複数の型を取り得るものだった場合などを考えると、fibonacchi_test関数の中で様々な値の初期化を行う必要があり、あっという間にテストコードが読みづらくなることが想像できます。後ほど、これよりはやや複雑なコードのテストを例示します。 テーブル形式でパラメーターを書くと、インプットとインプットの値がひと目でわかるため、「どのようなテストパターンを行っていて、どのようなテストパターンをやっていないか」がテスト内のロジックから切り離されて記述され、理解しやすいですね。

上記のテストを実行すると、下記の通り、指定したcaseの数だけテストが実行されます。

running 5 tests
test fibonacci_test::case_1 ... ok
test fibonacci_test::case_2 ... ok
test fibonacci_test::case_3 ... ok
test fibonacci_test::case_4 ... ok
test fibonacci_test::case_5 ... ok
test fibonacci_test::case_6 ... ok

test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

使い方はとても簡単ですね。 このパラメータ化の機能を使うことで、テストケースの意図がわかりやすくなります。

小ネタですが、traceアトリビュートを追加することで、テストが失敗した際にインプットの変数を全て表示してくれます。

#[rstest(
    number, name, tuple,
    case(42, "FortyTwo", ("minus twelve", -12)),
    case(24, "TwentyFour", ("minus twentyfour", -24))
    ::trace // このアトリビュートを追加すると、トレースできます
)]
fn should_fail(number: u32, name: &str, tuple: (&str, i32)) {
    assert!(false); // テストが失敗した時のみ標準出力に値が表示されます
}

上記のテストはどちらも失敗し、出力はこのようになります。

running 2 tests
test should_fail::case_1 ... FAILED
test should_fail::case_2 ... FAILED

failures:

---- should_fail::case_1 stdout ----
------------ TEST ARGUMENTS ------------
number = 42
name = "FortyTwo"
tuple = ("minus twelve", -12)
-------------- TEST START --------------
thread 'should_fail::case_1' panicked at 'assertion failed: false', src/main.rs:64:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.

---- should_fail::case_2 stdout ----
------------ TEST ARGUMENTS ------------
number = 24
name = "TwentyFour"
tuple = ("minus twentyfour", -24)
-------------- TEST START --------------
thread 'should_fail::case_2' panicked at 'assertion failed: false', src/main.rs:64:5


failures:
    should_fail::case_1
    should_fail::case_2

test result: FAILED. 0 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out

境界値のテストの例

やや実践的な例でテストを書いてみます。例として、以下2つの型を作るファイルにテストを書きます。 1. コンストラクタで国番号と電話番号を受け取り、海外の場合は国際番号付きの電話番号を返してくれるPhoneNumber型 2. ユーザーの連絡先を表す、Contact型。文字列をラップしており、日本国内のユーザーで電話番号を持つ場合は電話番号を、電話番号のないユーザーまたは国外のユーザーの場合はメールアドレスを返す

テストは下記のようになります。オブジェクトの関数が実装されていませんので、このコードを動かすことはできません。書き方の参考に読んでみてください。

// 電話番号が国番号を付与された状態で生成されることのテスト
#[rstest(country_code, number, expected,
    // パラメーターテーブルには型を用いたオブジェクトを入れることもできます
    case(Country::Japan, "08031704919", "08031704919"),
    case(Country::US, "99999999", "+199999999"),
)]
fn phone_number_test(country_code: String, number: &str, expected: &str) {
    let phone_number = PhoneNumber::new(country_code, number).to_str();
    assert_eq!(phone_number, expected);
}

// コンタクト情報が正しく生成されることのテスト
// 1. 日本の場合は電話番号を返す
// 2. 電話番号の登録がない場合、もしくは海外の場合はメールアドレスを返す
#[rstest(country_code, number, email, expected,
    case(Country::Japan, "08031704919", "jp@gmail.com", "08031704919"),
    case(Country::Japan, "", "test@gmail.com", "test@gmail.com"),
    case(Country::US, "+499999999", "us@gmail.com", "us@gmail.com"),
)]
fn primary_contact_test(country_code: String, number: &str, email: &str, expected: &str) {
    let phone_number = PhoneNumber::new(number);
    // primary_contactメソッドでContact型の値を返すと想定しています
    let contact_str = Contact::new(country_code, phone_number, email).primary_contact().to_str();
    assert_eq!(contact_str, expected);
}

マトリックス・テスト

複数の引数を掛け合わせて検証するマトリクス・テストをするときは、このように書きます。

#[rstest(
    first => [1, 3, 5],
    second => [2, 4, 6]
)]
fn equal_test(first: u32, second: u32) {
    assert_eq!(first, second)
}

上記のテストは、assert_eq!で引数firstsecondの値が同じであるかどうかを検証しています。rstestを使ってfirstsecondという変数を指定していて、テストを実行するとfirstsecondそれぞれの配列から一つずつ値を取り出し、組み合わせてくれます。 firstsecondは奇数と偶数の配列であるため、それぞれの要素に等しいものはなく、テストは全て失敗します。組み合わせなので、テストは9回実行されます。

running 9 tests
equal_test::first_2::second_3 ... FAILED
equal_test::first_1::second_3 ... FAILED
equal_test::first_3::second_2 ... FAILED
equal_test::first_1::second_1 ... FAILED
equal_test::first_2::second_2 ... FAILED
equal_test::first_3::second_1 ... FAILED
equal_test::first_3::second_3 ... FAILED
equal_test::first_2::second_1 ... FAILED
equal_test::first_1::second_2 ... FAILED

フィクスチャを作る

#[fixture] をつけることによって、フィクスチャを作成し、複数のテストで使い回すことができます。 依存関係のあるオブジェクトをたくさん生成しないとテストできない場合などに便利です。

use rstest::*;

#[fixture]
pub fn fixture() -> u32 { 42 }

#[rstest]
fn should_success(fixture: u32) {
    assert_eq!(fixture, 42);
}

#[rstest]
fn should_fail(fixture: u32) {
    assert_ne!(fixture, 42);
}

フィクスチャにデフォルト値を持たせることもできます。

#[fixture(name="Alice", age=22)]
fn user(name: &str, age: u8) -> User {
    User::new(name, age)
}

#[rstest]
fn is_alice(user: User) {
    assert_eq!(user.name(), "Alice")
}

#[rstest]
fn is_22(user: User) {
    assert_eq!(user.age(), 22)
}

#[rstest(user("Bob"))]
fn is_bob(user: User) {
    assert_eq!(user.name(), "Bob")
}

#[rstest(user("", 42))]
fn is_42(user: User) {
    assert_eq!(user.age(), 42)
}

Ruby on RailsでのFactoryBotに近い感じに見えますが、パラメーターテーブル内ではデフォルト値が使えないなど、使い勝手に関してはもう少しかなと思うところもあります。そこまでの柔軟性を求めるのも酷な気はするので全然いいのですが。

test_reuseクレートを併用し、テストのインプット/アウトプット値を複数のテストで使い回すこともできます。この機能については使い道がまだよくわかっていません。

use rstest::rstest;
use rstest_reuse::{self, *};

#[template]
#[rstest(a,  b,
    case(2, 2),
    case(4/2, 2),
    )
]
// ここではシグネチャだけを定義
fn two_simple_cases(a: u32, b: u32) {}

// テンプレートに指定したcaseを用いてテストが実行される
#[apply(two_simple_cases)]
fn it_works(a: u32, b: u32) {
    assert!(a == b);
}

rstestに関する説明は以上になります。 どうでしょうか、結構便利そうだなと思っていただけましたか?

Rustにおけるテストの難しさ

ここまでは課題の解決というよりはややテクニカルな話をしましたが、最後にRustでテストを書くことの難しさについて、特に感じていることを書いてみます。 まず前提として、Rustにおいて、単体テスト結合テストは記載される場所が異なり、単体テストはテスト対象モジュールの中で記述されるか、テスト用のモジュールを作成して記載します。 そのため、単体テストにおいて他crateに依存するオブジェクトを必要とする際、テストと関係の薄い依存データまでテスト内で作成しなければなりません。多少なら問題ありませんが、アプリケーションが大きくなり依存関係が複雑になるほど、テストを書くことが苦痛になってきます。 このフィクスチャ問題を克服するため、私のチームでは下記3つの方法を検討しています。

  1. テスト用のフィクスチャを作成する関数を作る

テストで使うデータを作成するための関数を作り、依存先のcrateでテストを行う場合もその依存元にあるフィクスチャ作成用の関数を用いるという方法です。 この方法のデメリットとしては、他のcrateから参照できる関数を作ってしまった場合、テスト以外においてもその関数を使用できてしまうことです。プロダクションコードに突然大量のデータを作成するメソッドを使用するコードが紛れ込んでしまうのは大きなリスクと言えます。 また、テストでしか使わないはずの関数もビルド対象となってしまうことで、ビルド時間が大きく伸びてしまいます。 これら2つのデメリットがかなり致命的なことから、この選択肢は検討しないことにしました。

  1. 複雑な構造体でも、文字列からパースして作る

先ほどご紹介したフィクスチャを作る手法を活用し、単体テスト内で使いまわせる擬似データを作ってしまうこともできますが、初期化の処理を延々と書くくらいなら、JSONや文字列からパースできるようにしてしまうという手もあります。 serde_json::from_valueを使えば、指定された型に向かって文字列をデシリアライズしてくれます。 この方法は後から読んでもデータ構造もわかりやすいですし、書き方も簡単ですが、複雑な構造体を使う場合にテストに不必要なデータを大量に書かなければならないという問題が残ります。 使用するオブジェクトが文字列からパースできる状態になっていれば特別な準備が必要ないため、現状、この方法で乗り切っている箇所もあります。

  1. フェイクデータを作成してくれるテスト用マクロを準備する

Ruby on Railsでよく使用されるFactoryBotのように、その場限りで使えるフェイクデータを作ってくれるテスト用のマクロを準備するというやり方も考えられます。rstestも、内部的にはマクロを使って実装されています。 この方法は、準備がある程度大規模になることが予想されますが、作ることができれば無駄なコードを書く必要もなくなり、テストに本当に必要な情報だけを記載できるという、非常に便利なものになりそうだと考えられます。


キャディでは大事なロジックに漏れがないようテストカバレッジや書き方を意識してはいますが、満足に書けていると言える状態ではありません。 やはり、複雑なデータ構造であればあるほど結合テストに頼る形になり、部分的なコードの修正をカバーするテストを書くことが困難になってくるという問題があります。 おすすめのテストの書き方がある方、是非教えてください!!

また、キャディでは一緒に素敵なプロダクトを開発してくれる仲間を探しています!ご興味をお持ちいただけた方は、是非お話しに来てください。お待ちしております🙌

長文お読みいただきありがとうございました😃 明日は和田さんによる、「Rust と nalgebra で MLP を実装した話」です。お楽しみに❣️ みなさま、良い年の瀬をお過ごしください〜🎄