RBSがテストになるとおもしろいんじゃないか日記4

前回までのあらすじ

RBSがテストになるとおもしろいんじゃないか日記1 - スペクトラム

RBSがテストになるとおもしろいんじゃないか日記2 - スペクトラム

RBSがテストになるとおもしろいんじゃないか日記3 - スペクトラム

間が開いた

結構間が開いてしまったがまだあきらめずに開発している。 サイドオーダーは全てのパレットをクリアして虹バッチとりました。

サイズ

プロパティベーステストの重要な概念にサイズがある。

サイズは簡単にいうと乱数に対する係数である。

Integerのサイズ0は0、Arrayのサイズ0は[]となる。

経験的にバグは0[]の場合に起きることが多いことから、サイズは0から始めて、少しずつ大きくしていく手法をQuickCheckをはじめとした多くのプロパティベーステストツールでとっている。簡単に示すとこんな感じだ。

100.times do |size|
  float = Random.rand * size
  integer = float.to_i
  array = Array.new(integer)
end

こうすることで単純なランダムよりも、不具合の起きやすいデータに集中してテストケースを作り出すことを目的としている。

このサイズを、これまで考えてきたものに実装するとどうなるか考えていく。

なんの引数?

これまで、Type.new("Integer").pickというAPIを考えてきた。「Integer型から、1つ具体的な値をとりだすよ」という意味を表している。

サイズの概念を導入した時、最初はこう考えていた。

Type.new("Integer", size: 10).pick

コンストラクターにサイズを設定した方が、内部のコード的には簡潔になるからだ。 しかしながらこれは間違いだと気がついた。概念的におかしいからだ。「サイズ10のInteger型から1つ具体的な値をとりだすよ」と読めるが、「サイズ10のInteger型」とはなんだろう?サイズは増加させてテストケースを作る。「サイズ11のInteger型」を作るにはまた別のオブジェクトが必要になるだろうか?なんかしっくりこない。

変更してこうした。

Type.new("Integer").pick(size: 10)

違いはわずかだが、「Integer型からサイズ10で具体的な値を取ってくる。」と考えれる。サイズが増加しても「Integer型からサイズ11で値を取ってくる。」と考えれるのでオブジェクトは使いまわせる。サイズは取得時の引数と考える方がしっくりきそうだ。

どこからやってくるのか

サイズはどこからやってくるのだろうか。RaaPでは、1つのメソッドのオーバーロード毎にこの0〜100とサイズを変化させてテストを実行させることにした。 このオーバーロード毎に0~100のサイズを発生させればよさそうだ。この単位を MethodProperty とした。

MethodProperty

MethodPropertyではこのサイズの変化を受け持つ他にも様々な役割を持たせた。ジェネレータからの値の具体化。メソッドの実行。返り値の型チェック。エラーハンドリング。タイムアウト。ループのスキップなどだ。

Result

ではMethodPropertyの実行結果について考える。 成功は考える余地がないほど単純だ。また、バグや型記述の不一致を見つけたいのだから当然失敗はほしい。

  • 成功: RBSに記述された型を引数に与えると期待した型が返ってきた。
  • 失敗: RBSに記述された型を引数に与えると期待しない値が返ってきた。

またなんらかの理由でどうしようもない場合がある。例えばNotImplementedErrorが出た時は、型も何もないと考えられるので、スキップしたい。

他にもInteger.sqrtの定義は(::int n) -> ::Integerとなっているが、実際はマイナスの数字を入力するとMath::DomainErrorとなる。

こういう場合はカスタムコードが必要となってくるが、現段階ではCLIだけの利用を考えているので、簡単に止まってしまっては困る。

というわけで、なんらかのエラーが出ても止まらず進めたい。この状態を例外としたい。

  • スキップ: 型に関係なくどうしようもない場合。
  • 例外: どうしようもなく例外が発生する場合。

TypeError

ではおもしろい考察として、TypeErrorはどう扱うかという問題がある。

例外だと単純に考えていいだろうか?しかしながらTypeErrorはたいてい引数の型が間違っている時に発生する。Integerを期待している箇所にStringがきた場合などだ。これって型エラーなのでRaaP的には検知したい型エラー、つまり失敗のケースなんじゃないかと私は考えた。

よってTypeErrorは例外の例外的に失敗に含めている。

bot

Kernel#raiseのような、絶対に返り値が発生しないメソッドも存在する。この場合はRBSではbotを使用する。 逆にいうと、botが設定されている場合は例外が発生することを期待しているとも言える。 MethodPropertyでは、このbotが返り値だったら例外が発生したら成功、しなければ失敗として扱っている。

再起型

RBSでは再起的な型を許可している。例えばリストにRBSを書いてみる。多分こんな感じ。

class List[T]
  attr_accessor head: T
  attr_accessor tail: List[T]
  def initialize: (head: T?, tail: List[T]?) -> void
end

このclassList[T]インスタンスを生成したい時、tailList[T]インスタンスが欲しい。 ではこのtail用のList[T]インスタンスを生成する時にもまたList[T]インスタンスが必要になってくる。 このような再起的な循環が簡単に起こり得るのでなんとかしたい。 今のところはアイデアはいくつかあるものの、実装に落とし込めてはいない。

ではどうするかというと、SystemStackErrorが発生したら「どうしようもない場合」としてスキップすることにした。

シンボリックコール

これは最近実装した概念で、QuickCheckをはじめとしたプロパティベーステストで使われているデータ構造である。 レシーバー、メソッド名、引数のタプルでできていて、メソッドを呼び出す直前の状態をひとまとめにしている。 シンボリックコールはネストしてよく、コードのメソッド呼び出しそのものの構造を作ることができる。

シンボリックコールの嬉しいところは、メソッドの呼び出し状況の可視化が簡単になる点だ。 AをつくるにはBが必要で、BをつくるにはCが必要で……といったような複雑なオブジェクトの場合、テストが落ちた時のデータの再現が難しかったりする。シンボリックコールを残しておけば、用意に再現手順が作れるし、最悪シンボリックコール自体をdumpすればやりたいことがわかるようになっている。

基本的にデータはこのシンボリックコールで組み立てて、最後にevalつまりはシンボリックコールを実行するようにした。