🎴

プロパティベーステストライブラリを作ってみた(ステートフルテスト編)

2024/07/04に公開

前回の 「プロパティベーステストライブラリを作ってみた」 の続きです。先日ステートフルテストを実装したので、その過程で得られた知見などを紹介したいと思います。

なお、本記事ではステートフルテストの学習を目的としません。ステートフルテストについて学びたい方は 実践プロパティベーステスト をぜひ読んでください。

おことわり

この記事の説明は拙作のkiri-checkをベースにしています。kiri-checkのステートフルテストはScalaCheckなどのメジャーなライブラリを参考にしており、他のライブラリと大きな違いは少ないはずです。

kiri-check独自の要素はできるだけ作らないようにしていますが、開発言語(Dart)の違いもあって他のライブラリを完全に模倣するのは難しい面があります。ステートフルテストを試す際は、お使いのライブラリのドキュメントを優先してください。

ステートフルテストとは

ステートフルテストは、その名の通り「状態を持つ」テストです。同じプロパティベーステストでも、ランダム生成のデータを使う基本的なテストは、状態を持たないのでステートレステストと呼ばれます。ステートレステストは単体のテストで完結できる処理に向いており、ステートフルテストは複雑な状態遷移を考慮する必要のある処理に向いています。

ステートフルテストの実施方法は、あるべき振る舞いを表すモデルと実運用されるシステムの2つの実装を用意し、任意の操作を行う前後の状態を比較する方法が広く使われています。この方法はモデルベーステストと呼ばれることがあります。ステートレスプロパティテストと比べて複雑で労力がかかるのが難点ですが、より実運用に近いシミュレーションのテストが可能です。

ライブラリによってステートフルテストのサポートは異なります。メジャーなライブラリはだいたいサポートしているようです。DartのいくつかあるPBTライブラリでは、ステートフルテストを実装しているのはkiri-checkのみかと思われます。

サンプルコード

kiri-checkのサンプルコードを紹介します。このコードは リポジトリに含まれています。 また、ドキュメントの クイックスタート でも解説しているので、よかったらそちらも読んでみてください。

import 'package:kiri_check/kiri_check.dart';
import 'package:kiri_check/stateful_test.dart';

// モデルの実装
final class CounterModel {
  int count = 0;

  void reset() {
    count = 0;
  }

  void increment() {
    count++;
  }

  void decrement() {
    count--;
  }
}

// 実システムの実装
final class CounterSystem {
  // このサンプルでは JSON でデータを保持するとする
  Map<String, int> data = {'count': 0};

  int get count => data['count']!;

  set count(int value) {
    data['count'] = value;
  }

  void reset() {
    data['count'] = 0;
  }

  void increment() {
    data['count'] = data['count']! + 1;
  }

  void decrement() {
    data['count'] = data['count']! - 1;
  }
}

// テストの仕様
final class CounterBehavior extends Behavior<CounterModel, CounterSystem> {
  
  CounterModel initialState() {
    return CounterModel();
  }

  
  CounterSystem createSystem(CounterModel s) {
    return CounterSystem();
  }

  
  List<Command<CounterModel, CounterSystem>> generateCommands(CounterModel s) {
    return [
      Action0(
        'reset',
        nextState: (s) => s.reset(),
        run: (system) {
          system.reset();
          return system.count;
        },
        postcondition: (s, count) => count == 0,
      ),
      Action0(
        'increment',
        nextState: (s) => s.increment(),
        run: (system) {
          system.increment();
          return system.count;
        },
        postcondition: (s, count) => s.count + 1 == count,
      ),
      Action0(
        'decrement',
        nextState: (s) => s.decrement(),
        run: (system) {
          system.decrement();
          return system.count;
        },
        postcondition: (s, count) => s.count - 1 == count,
      ),
    ];
  }

  
  void destroySystem(CounterSystem system) {}
}

void main() {
  property('counter', () {
    // ステートフルテストを実行する
    runBehavior(CounterBehavior());
  });
}

以上のコードは、シンプルなカウンターのテストです。このカウンターはカウント値の整数をプロパティに持ちます。可能な操作は、増加、減少、リセット(0に戻す)です。

今回は意図的に実システムの仕様を複雑にしており、カウント値をJSONに変換可能なデータで保持します。あえて複雑にして、モデルと実システムの実装に差をつけています。

このコードでは3つのクラスを定義しています。

  • CounterModel: モデル
  • CounterSystem: 実システム
  • CounterBehavior: テストの仕様。各種コールバックを含みます

CounterModelはカウンターの仕様を表すモデルです。モデルはステートフルテストの基準となるので、仕様の正確さを最優先として実装します。パフォーマンスや拡張性は二の次三の次であり、できるだけミスしにくい単純な実装が望ましいです。

CounterSystemは実システムの実装です。今回はサンプル向けのシンプルなカウンターなので、モデルとの差が小さいです。

CounterBehaviorはモデルや実システムの生成と、実行するコマンドの生成を担当します。 Behavior という名前のクラスはkiri-check固有ですが、他のライブラリで使われるAPIを一ヶ所にまとめたものです。

主なメソッドを以下に示します:

  • initialState: 初期状態のモデルを生成する
  • createState: 実システムを生成する。 initialState で生成されたモデルを引数に取ります
  • generateCommands: 実行するコマンドのリストを生成する

コマンド

「コマンド」は、モデルや実システムに対する処理の単位です。kiri-checkでは、ユーザーが処理を指定可能なコマンドが Action コマンドです。 Action はアービトラリーを利用してランダムな値を生成できます。 Action0 はランダムな値が必要ない処理に使うコマンドです。

今回のテストでは、増加、減少、リセットの3つのコマンドを用意します。いずれもほとんど同じなので、増加のコマンドだけ見ておきます。

Action0(
  'increment', // コマンド内容説明
  nextState: (s) => s.increment(),
  run: (system) {
    system.increment();
    return system.count;
  },
  postcondition: (s, count) => s.count + 1 == count,
),

引数は以下の通りです。今回は使っていませんが、事前条件を表す precondition もあります。

  • nextState: モデルの状態を更新する
  • run: 実システムに対して処理を行う
  • postcondition: run 実行後の事後条件

「事前条件のチェック precondition -> 実システムの処理 run -> 事後条件のチェック postcondition -> モデルの更新 nextState 」がコマンドのコールバックの流れです。実行順序に癖があるので、次節で詳しく触れます。

実行

テストを実行するには dart test を実行します。

dart test example/stateful_counter.dart

KiriCheck.verbosity をセットすると、実行プロセスが表示されます。

void main() {
  // 詳細を表示する
  KiriCheck.verbosity = Verbosity.verbose;

  property('counter', () {
    runBehavior(CounterBehavior());
  });
}

出力例:

...
Cycle 10
Generate commands...
Create state: CounterModel
Create system: CounterSystem
Step 1: reset
Step 2: decrement
Step 3: increment
Step 4: increment
Step 5: increment
Step 6: decrement
Step 7: increment
Step 8: reset
Step 9: reset
Step 10: decrement
...

デフォルトの設定では、ランダムなコマンドを連続して50回実行するサイクルを100回繰り返します。

シュリンク

ステートレステストと同様、ステートフルテストでもテストに失敗するとシュリンクを行います。実システムにバグを仕込んで試してみましょう。 CounterSystem.decrement を、「カウント値が6以上だとカウントが減らなくなる」とします。

void decrement() {
  if (data['count']! > 5) {
    return;
  }
  data['count'] = data['count']! - 1;
}

変更後にテストを実行すると、失敗したコマンド列が最後に表示されます:

Falsifying example sequence:
Step 1: increment
Step 2: increment
Step 3: increment
Step 4: decrement
Step 5: increment
Step 6: increment
Step 7: increment
Step 8: increment
Step 9: increment
Step 10: increment
Step 11: decrement

このコマンド列は最初に失敗したオリジナルのパターンではなく、シュリンクによってオリジナルが縮小されたものです。以下にオリジナルを示します:

エラーが発生したオリジナルのコマンド列
Cycle 8
Generate commands...
Create state: CounterModel
Create system: CounterSystem
Step 1: increment
Step 2: decrement
Step 3: decrement
Step 4: increment
Step 5: decrement
Step 6: decrement
Step 7: reset
Step 8: increment
Step 9: decrement
Step 10: decrement
Step 11: reset
Step 12: increment
Step 13: increment
Step 14: increment
Step 15: decrement
Step 16: reset
Step 17: decrement
Step 18: increment
Step 19: increment
Step 20: reset
Step 21: increment
Step 22: decrement
Step 23: decrement
Step 24: decrement
Step 25: decrement
Step 26: decrement
Step 27: decrement
Step 28: reset
Step 29: increment
Step 30: decrement
Step 31: reset
Step 32: decrement
Step 33: increment
Step 34: increment
Step 35: reset
Step 36: increment
Step 37: increment
Step 38: increment
Step 39: reset
Step 40: reset
Step 41: decrement
Step 42: reset
Step 43: increment
Step 44: increment
Step 45: increment
Step 46: increment
Step 47: increment
Step 48: increment
Step 49: decrement
  Error: postcondition is not satisfied

オリジナルでは49ステップ目で失敗していますが、シュリンクによって11ステップまで短縮できました。オリジナルに含まれるリセットコマンドはすべてカットされ、カウント値の増減コマンドのみで失敗を再現できています。

シュリンク後のコマンド列ではステップ5からステップ10まで連続してカウント値が増加され、この間にカウント値が6以上になっていることがわかります。ステップ11の時点のカウント値は8なので、バグを誘発する最小値の特定まで至りませんでしたが、それでもステップ数を約1/5まで簡略化できればデバッグがぐっと楽になります。

実行モデル

一般的に、ステートフルテストの実行は2つのフェーズに分けられます。最初のフェーズはコマンドの生成を行い、次のフェーズでコマンドを含む諸々の処理を実行します。一部のコールバックは両方のフェーズで共通していますが、呼び出し後の挙動が異なります。

この節で触れるいずれのコールバックもユーザーが定義すべき処理です。

コマンド生成フェーズ

最初のフェーズでは、使用するコマンドをランダムに生成します(実行は次のフェーズで行われます)。この段階ではモデルのみ生成されます。実システムは生成されません。

以下にコマンド生成フェーズの流れを示します:

まず、 Behavior.initializeState() でモデルを生成します。生成したモデルは初期状態として扱われます。次に Behavior.initializePrecondition(State) が呼ばれ、生成したモデルの初期条件のチェックが行われます。戻り値が false の場合、テストは失敗となります。

次に Behavior.generateCommands(State) が呼ばれ、次のフェーズで実行されるコマンドの候補のリストを生成します。その後、コマンド選択ループに入ります。

コマンド選択ループでは、リストからコマンドがランダムに選択されます。選択したコマンドに対して Command.precondition(State) が実行されます。戻り値が true であれば採用され、 false であれば不採用になり、次のコマンドの選択に移ります。

事前条件のチェックをパスすると Command.nextState(State) が実行され、コマンドに従ってモデルの状態を変更します。必要なコマンド数に達しなければ選択を続けます。

実行フェーズ

実行フェーズでは、前のフェーズで生成したコマンドを実システムに適用します。モデルは再生成されるので、コマンド生成フェーズで生成された情報は持ち越されません。基本的にシュリンクも実行フェーズと同じ流れで実行します。

以下に実行フェーズの流れを示します:

Behavior.initializePrecondition(State) までのモデル生成の処理は前のフェーズと同じです。実行フェーズでは、この次に Behavior.createSystem(State) で実システムを生成します。生成されたモデルが引数に渡され、そのモデルに対応する実システムを生成します。

ここから、実システムに対するコマンド実行ループが開始されます。各コマンドの実行時、最初に Command.precondition(State) を呼んで事前条件をチェックします。ただし、コマンド生成フェーズと異なり、戻り値が false であればシュリンクを開始します。即時に失敗とはみなされません。

事前条件に問題がなければ、 Command.run(System) を呼んで実システムに対する処理を実行します。アービトラリーを使うコマンド (Action) であれば、ここでランダムな値を生成します。処理の実行中に何らかのエラーが発生するとシュリンクを開始します。 run は任意の値を返すことができ、その値は次に呼ばれる postcondition に渡されます。

Command.postcondition(State, Result) は事後条件をチェックします。戻り値が false であればシュリンクを開始します。 postcondition の引数は run 実行前に相当する一つ前の状態のモデルと、 run の戻り値です。事後条件の戻り値が true であれば、 Command.nextState(State) を呼んでモデルの状態を更新します。

これらの引数を使った postcondition の基本的な実装は、「一つ前の状態のモデル + 次の状態への差分 = 実システムの次の状態を表す任意の値 (run の戻り値) 」になります。たとえば、カウンター値を1増加するコマンドであれば、 run で実システムのカウンター値増加後の値を返し、 postcondition は「増加前のモデルのカウンター値 + 1 = 実システムの増加後のカウンター値」とします。

ここで注意したいのは postcondition が呼ばれるタイミングです。素朴に考えると runnextStatepostcondition の順に呼ばれそうに見えます。 nextState でモデルの状態を更新し、操作の回数をモデルと実システムで一致させてから postcondition を呼ぶのでは?と。しかし、kiri-checkを含む一般的なステートフルテストでは、 run の次に postcondition が呼ばれ、引数には run の戻り値と一つ前の状態のモデルが渡されるという、ちょっとひねくれた流れになります。しかも実システムそのものは渡されません。

なぜこのような回りくどい方法をとるのかというと、ステートフルテストの主体がモデルだからです。注力すべきはモデルの実装であり、モデルが正しい仕様を表すことが何よりも重要です。モデルがきちんと実装されていれば、実システムの不備は自動的に炙り出されます。それがステートフルテストの強みであり難しさでもあります。実システムと異なる実装をわざわざ用意するコストがかかる上、モデルの設計はステートレステストよりも複雑になります。

nextState でエラーが発生しなければ次のコマンドの実行に移ります。すべてのコマンドの実行が終了したか、シュリンクが完了したら実行フェーズは終了です。その後は destroySystem(System) を呼び、実システムの終了処理を行ってテスト終了です。

シュリンク

実行フェーズでエラーが発生するとシュリンクを開始します。ステートフルテストのシュリンクの目的は、エラーが発生するコマンドの最小の組み合わせを発見することです。もしコマンドがランダムな値を使っているのであれば、その値もシュリンクして最小値を探します。

kiri-checkでは、シュリンクは3段階のフェーズで行います。他のライブラリでも多かれ少なかれ同じ感じです。

最初のフェーズでは、オリジナルのコマンド列を複数の部分列に分割します。単純に位置と数で分割するので、先頭のコマンドを含まない部分列や終端のコマンドを含まない部分列も含まれます。それぞれの部分列を個別に実行し、失敗した部分列を一つ選んで次のフェーズに進みます。カウンターの例では、ステップ36からの部分列でエラーが発生したので選ばれています。

次のフェーズでは、選択された部分列から特定のコマンドを一つずつ削除して試します。カウンターの例では「増加」「減少」「リセット」のコマンドをそれぞれ削除し、エラーの発生に必要(不要)なコマンドをチェックします。このプロセスにより、リセットがあってもなくてもエラーの発生に影響がないと判明したのでカットされています。

最後のフェーズでは、コマンドが生成したランダムな値を(ステートレステストと同様に)シュリンクします。カウンターの例ではランダムな値を利用していないので、このフェーズでは何も実行されません。

以上の流れを以下に示します:

まとめ

プロパティベーステストは、ステートレステストにしろステートフルテストにしろ、ユーザーからするとブラックボックスに見えやすいテストです。どのような値でテストされているのか(明示的に表示させなければ)わかりませんし、制御可能な範囲はライブラリによって異なります。特にステートフルテストは、アルゴリズムを知らなければ魔法のように感じます。

しかし、魔法のようなアルゴリズムは意外と単純なもので、わかってしまえば難しくありません。基本的なAPIも確立されており、他のライブラリを参考にすれば実装しやすいと思います。夏休みの自由研究として自作してみるのも悪くないのではないでしょうか?

よかったらバッジをいただけると嬉しいです。 kiri-check を使ってみて意見をいただけるのも嬉しいです。コーヒー代とモチベの足しにします。

参考リンク

Discussion