🤺

proptestによるテストケースの自動生成

2023/06/30に公開

この記事は、Rust言語のテストケース自動生成ライブラリであるproptestクレートの紹介記事です。

proptestとは

コードのテストを書くとき、一般的には代表的な値や境界値を用いて、具体的な値を使ってテストを書くと思います。

fn to_zero_only_100(x: u128) -> u128 {
    if x == 100 {
        0
    } else {
        x
    }
}

#[test]
fn test_to_zero_only_100() {
    assert_eq!(to_zero_only_100(50), 50);
    assert_eq!(to_zero_only_100(100), 0);
}

しかし、現実には境界値などがハッキリしているケースばかりではなく、わずかな代表値によるテストだけでは挙動が保証されているという確信が持てないという事がよくあります。例えば、以下のようなto_zero_only_nabeatsu_number関数のテストでは、あまり安心できなさそうです。

fn to_zero_only_nabeatsu_number(x: u128) -> u128 {
    if is_nabeatsu_number(x) {
        0
    } else {
        x
    }
}

fn is_nabeatsu_number(x: u128) -> bool {
    // 3の倍数と3がつく数字

    if x % 3 == 0 {
        return true;
    }

    let mut tmp = x;
    while tmp != 0 {
        if tmp % 10 == 3 {
            return true;
        }
        tmp /= 10;
    }

    false
}

#[test]
fn test_to_zero_only_nabeatsu_number() {
    assert_eq!(to_zero_only_nabeatsu_number(3), 0);
    assert_eq!(to_zero_only_nabeatsu_number(6), 0);
    assert_eq!(to_zero_only_nabeatsu_number(10), 10);
}

このto_zero_only_nabeatsu_numberは、
引数xがナベアツ数なら0、そうでないならxをそのまま返すという性質を持ちます。このような関数の性質に着目したテストをProperty based testing といい、proptest クレートを利用することで、簡単にそのようなテストを実現することができます。

to_zero_only_nabeatsu_numberのテストを、proptestを使って記述すると以下のようになります。

use proptest::prelude::*;
 
proptest! {
    #[test]
    fn test(x in any::<u128>()) {
        if is_nabeatsu_number(x) {
            assert_eq!(to_zero_only_nabeatsu_number(x),0);
        }else {
            assert_eq!(to_zero_only_nabeatsu_number(x),x);
        }
    }
}

上記のtest関数の引数部分に、x in any::<u128>()という記述があります。これはu128型の何かしらの値xという意味です。

proptestは、ランダムなxの値を使ってテストを何度も実行することで、記述された性質が満たされていることを確認します。使われるxのセットは、テストの実行のたびに毎回異なります。

ランダムな値を用いるため、どのようなxに対しても性質が満たされることを保証するわけではありませんが、最初のテストと比べるとかなり強力なテストになっていることが実感できると思います。(ランダムな値と書きましたが、厳密には完全にランダムな値を使うわけではなく、二分探索の要領でテストが失敗する境界を探すようにxの値が選ばれます。)

テストに使用する値の生成

テストに使用する値の最もシンプルな生成方法はany::<型>()という記述ですが、proptestにはそれ以外にも柔軟な値の生成方法が用意されています。

rangeによる値の生成

fn test(x in 0u128..100) {
	// xの値は0以上100未満
}

複数の値を使う

fn test(x in any::<u128>(), y in any::<u128>()) {
  // x, yの値は独立に選ばれる
}

正規表現による文字列の生成

fn test(x in "[a-zA-Z@~^]{3,6}") {
    // xは正規表現にマッチする何らかの文字列
}

確率によるブーリアンの生成

fn test(x in prop::bool::weighted(0.3)) {
    // xは30%の確率でtrue
}

固定長配列の生成

fn test(x in prop::array::uniform32(any::<u128>())) {
    // xはany::<u128>()を要素とする長さ32の配列。uniform1からuniform32まで存在
}

HashSetの生成

HashSet以外にも、rustのstd::collectionに準ずる様々な型が生成可能。

fn test(x in prop::collection::hash_set(any::<u128>(), 30)) {
    // xはany::<u128>()を要素とする要素数30のHashSet
}

値から任意の型を生成

.prop_mapを使うことで、ある値から別の値を生成できる。

fn test(x in any::<u128>().prop_map(|number| number as f64 / 3.0)) {
	...
}

タプルを使うことで複数の値から一つの値を生成することも可能。

fn test(x in (any::<u128>(), any::<u128>()).prop_map(|(a,b)|{a.max(b)-a.min(b)})) {
  ...
}

.prop_map(...)で変換した値をさらに.prop_map(...)で変換することもできる。

fn test(x in any::<u64>().prop_map(|n| n as u128 * 2).prop_map(|n| n / 3)) {
  ...
}

Enumの生成

EnumにDebug, Cloneトレイトが実装されている必要があります。

#[derive(Debug, Clone)]
enum SmartPhone {
    iOS,
    Android,
    Other(String),
}
    
fn test(x in prop_oneof![
    Just(SmartPhone::iOS),
    Just(SmartPhone::Android),
    "[A-Z]{5,7}".prop_map(|model_name|{SmartPhone::Other(model_name)})]
) {
  ...
}

値のフィルター

ランダムに生成された複数の値の間に何らかの前提を置きたい場合、以下のように書くことで前提条件を満たすケースのみを使ってテストすることができます。

fn test(x in any::<u128>(),y in any::<u128>()) {
    prop_assume!(x != y);

    // x != yを満たすケースのみが使われる
}
fn test(x in any::<u128>().prop_filter("x is even number", |value|{ value %2 ==0})) {
    // xは偶数であることが保証される
}

ただし、あまりにたくさんのケースがキャンセルされてしまったり、値の分布が大きく歪んでしまったりすると、テスト失敗の境界値を見つける機構が機能しなくなります。

テストが失敗した場合の挙動

proptestを用いたテストが失敗した場合、その時に使われたseed値がproptest-regressionsというディレクトリ配下に保存されます。
この記録が存在する場合、新しいテストの前にそのseed値によるテストが実行されるため、テストが失敗した時の状況を簡単に再現することができます。

最後に

株式会社クロスビットでは、デスクレスワーカーのためのHR管理プラットフォームを開発しています。
一緒に開発を行ってくれる各ポジションのエンジニアを募集中です。
https://x-bit.co.jp/recruit/
https://herp.careers/v1/xbit
https://note.com/xbit_recruit

クロスビットテックブログ

Discussion