C# でプロパティベーステスト

鉛筆を転がしてテストに答える人のイラスト

HashHubのジョーです。前は「宮本です」と名乗っていましたが、わかりづらいのでジョーと名乗ってくれと言われたのでこれからジョーで統一します。

先日 NBitcoin に Property Based Test を導入したので、今日はそれについて書きます。 (あまり Bitcoin 自体には関係ないですが、ブロックチェーンアプリケーションはセキュリティ上テストが極めて重要な役割を占めるので許してください 🙏)

Property Based Testing とは

テストは重要です。長期的に使うコードの場合、テスト対象のコードそのものよりも重要です。 しかしテスト可能な設計になっていることはテストそのものよりもさらに大切です。

Property based testing とは、一言で言うとテストデータをランダムに生成するようなテストです。これにより

  1. リグレッションを防ぎやすくなる
  2. 境界値のテストが容易になる
  3. テストが普遍的な性質の説明として機能するようになる
  4. 副作用のない(テスト可能な)設計を心がけざるを得なくなる
  5. (おまけ) パフォーマンステストにも移行しやすい

といったメリットが得られます。

例えば整数をラップするデータ型を自分で定義したとして(仮にこれを MyInt とします)、これの足し算を XUnit でテストする場合

[Theory]
[InlineData(1, 1, 2)]
[InlineData(2, 2, 4)]
public void ShouldAddMyIntProperly (int x, int y, int expected)
{
Assert.Equal(new MyInt(x) + new MyInt(y), new MyInt(result))
}

と言う風に、適当な正解データでテストするのが普通です。

しかし、これでテストが通ったからといって、実装が意図したものになっているかはわかりません。例えば以下のコードでも上のテストは通ってしまいます。

MyInt
{
public static MyInt operator +(MyInt left, MyInt right)
=> left * 2;
}

この例は極端ですが、複雑な処理をTDDで書くと似たようなことが起こります。マジで起こります。テストを増やしながら実装をアップデートしていくと、最初に書いたテストの内容を忘れてリグレッションしたことに気づかないことが多々あるためです。

そこで、 FsCheck を使ってランダムにデータを生成します。

[Trait]
[Property(MaxTest = 10)]
public void ShouldAddMyIntProperly (int x, int y)
{
Assert.Equal(new MyInt(x) + new MyInt(y), new MyInt(x + y))
}

こうすると、 x と y にランダムな値を入れて10回テストを実行してくれるので、上の問題を回避できます。

もう一つ嬉しいのは、「MyInt 同士の足し算は int 同士の足し算と全く同じように振る舞う」と言うのが一目でわかる点です。テストがより仕様そのものに近づいているので、使い方を知りたいユーザーへのメッセージになります。

また、それぞれのデータ型では例えば以下のような境界値が存在しますが、これらのテスト忘れを防げます。

  1. int … 0
  2. float … NaN
  3. 配列 … 空の配列
  4. 参照型 … null

ジェネレータを定義する

プロパティベーステストには、 Generator と Shrinker と言う概念が出てきます。

  • Generator<T> … T型のデータをランダムに生成するモナド
  • Shrinker … テストに失敗した際に、「失敗する最小の条件」を同定するための機能

標準以外のデータ型をランダムに生成したい場合 (あるいは生成範囲を指定したい場合) 、自分で Generator を定義する必要があります。NBitcoin 用に定義したものはこちらにあります。

Shrinker は、ライブラリ側が自動的にうまいこと調整してくれるので基本的に自分で定義する必要はありません。

Generator<T> と Shrinker がセットになったデータ型を Arbitrary<T> と呼び、これを登録することで、メソッド中で使用できるようになります。

以下がコード例です。

using XUnit;
using FsCheck;
using FsCheck.XUnit;
public class LessThenHundredMyIntGenerator
{
// 0 から 100 の間の MyInt を生成するGeneratorを作成
public static Gen<MyInt> MyIntGen =>
from i in Gen.Choose(0, 100)
select MyInt(i)
  // その Generator を内部にもつArbitrary 型を作成
public static Arbitrary<MyInt> MyIntArb () => Arb.From(MyIntGen())
}
public class Tests
{
public Tests()
{
// 作成した Arbitrary 型を登録(これでメソッドの引数として使えるようになる。)
    Arb.Register<LessThenHundredMyIntGenerator>();
}

// 交換法則を満たすことをテスト
[Property]
[Trait]
public void ShouldBeCummutative(MyInt x, MyInt y)
=> x + y = y + x
}

プロパティテストは通常のテストを完全に置き換えるものではなく、副作用のある統合テストや適切な Generator を作りづらいデータ型には向きませんが、まずプロパティテストを書き、うまくいかない場合に通常のテストを書くようにすることで、より堅牢なアプリケーションを作ることができます。ぜひ試してみましょう。

お知らせ
■HashHubでは入居者募集中です!
HashHubは、ブロックチェーン業界で働いている人のためのコワーキングスペースを運営しています。ご利用をご検討の方は、下記のWEBサイトからお問い合わせください。また、最新情報はTwitterで発信中です。

HashHub:https://hashhub.tokyo/
Twitter:https://twitter.com/HashHub_Tokyo

■ブロックチェーンエンジニア集中講座開講中!
HashHubではブロックチェーンエンジニアを育成するための短期集中講座を開講しています。お申込み、詳細は下記のページをご覧ください。

ブロックチェーンエンジニア集中講座:https://www.blockchain-edu.jp/

■HashHubでは下記のポジションを積極採用中です!
・コミュニティマネージャー
・ブロックチェーン技術者・開発者
・ビジネスディベロップメント

詳細は下記Wantedlyのページをご覧ください。

Wantedly:https://www.wantedly.com/companies/hashhub/projects