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

class Foo
  def bar: (Integer) -> String
end

みたいなRBSがあったとして、このRBSを使って勝手にテストしてくれるツールがあるとおもしろいんじゃない? というアイデアでプロダクトを作ってみている。

プロジェクト名は適当にRaaP(RBS as a Property)とした。

先ほどのRBSをRaaPで実行すると、

foo = Foo.new
assert_kind_of String, foo.bar(0)
assert_kind_of String, foo.bar(-1)
assert_kind_of String, foo.bar(3)
assert_kind_of String, foo.bar(-4)
assert_kind_of String, foo.bar(100)

みたいなテストを実行してくれるというものだ。

例が簡単すぎたので実践的な例を出すと、Integer#powがある。

Integer#powの例

少し前まで、Integer#powRBSはこうだった。

  def pow: (Integer other, ?Integer modulo) -> Integer
         | (Float) -> Float
         | (Rational) -> Rational
         | (Complex) -> Complex

RBSを少し説明しておくと、このRBSInteger#powには引数と返り値の型の組み合わせが4パターンあるという意味になる。

  • 引数が(Integer other, ?Integer modulo)だったら返り値はIntegerになる
  • 引数が(Float)だったら返り値はFloatになる
  • 引数が(Rational)だったら返り値はRationalになる
  • 引数が(Complex)だったら返り値はComplexになる
2.pow(2) #=> 4[Integer]
2.pow(2.0) #=> 4.0[Float]
2.pow(2r) #=> (4/1)[Rational]
2.pow(2i) #=> (0.18345697474330172+0.9830277404112437i)[Complex]

なるほど確かにそうなった。

しかし、実は以下のようにパターンがあることがわかった。

-2.pow(-1) #=> (-1/2)[Rational]
-9.pow(0.5) #=> (0.0+3.0i)[Complex]
2.pow(1/2r) #=> 1.4142135623730951[Float]
-1.pow(1/2r) #=> (0.0+1.0i)[Complex]

調査してみると、より正しい型としては以下のようになることが分かった。

  def pow: (Integer other) -> (Integer | Rational)
         | (Integer other, Integer modulo) -> Integer
         | (Float) -> (Float | Complex)
         | (Rational) -> (Float | Rational | Complex)
         | (Complex) -> Complex
  • 引数が(Integer other)なら返り値はIntegerもしくはRationalになる
  • 引数が(Integer other, Integer modulo)なら返り値はIntegerになる
  • 引数が(Float)だったら返り値はFloatもしくはComplexになる
  • 引数が(Rational)だったら返り値はFloatもしくはRationalもしくはComplexになる
  • 引数が(Complex)だったら返り値はComplexになる

PRはすでにmerge済みだ。

https://github.com/ruby/rbs/pull/1706/files

私は実を言うとInteger#powが何なのか全くわかっていない数学もよく分からない。

そんな私がどうやってこんなパターンを発見できたのだろうか?

ものすごく簡単なモデルで示すと、 ランダムなIntegerのレシーバーに対して、100回ランダムな値を入れてみたのだ。

100.times do
  receiver = Random.rand(-10..10)
  arg = Random.rand(-10.0..10.0)
  result = receiver.pow(arg)
  unless result.kind_of?(Float)
    p "Fail #{receiver}.pow(#{arg}) -> #{result}"
  end
end

#=> "Fail -6.pow(-2.3382898246090544) -> 0.007370540726186491-0.01323798966778574i"
#=> "Fail -1.pow(-0.2237157347318508) -> 0.7630200574290098-0.6463748076472586i"
#=> "Fail -9.pow(6.264880066044544) -> 640350.5389016571+703203.5975286782i"
#=> "Fail -5.pow(9.672387114373539) -> 2971132.3702771068-4938997.434107209i"
#=> "Fail -1.pow(-9.99330836294536) -> 0.9997790375330122+0.021020849401575725i"
#=> "Fail -1.pow(6.7379598219388726) -> -0.6798608195419452+0.7333411662055762i"
#=> "Fail -8.pow(6.374746550914516) -> 219100.4837162987+527766.3899187194i"
...(snip)...

簡単に想定外のパターンを見つけることができた。

変更前の型を書いた人は誰か。 mame さんである。早まらないでほしい。私が言いたいのは、 「あのmameさんが型を間違えるなら、全人類が間違える。」という点だ。(ここで全人類がうなずく)

ゆえにこの手法には価値がある。

このような手法をファジングとかプロパティベーステストとか言ったりするようだ。

プロパティベーステスト

この辺は実践プロパティベーステストが詳しい。

『実践プロパティベーステスト ― PropErとErlang/Elixirではじめよう』 – 技術書出版と販売のラムダノート

プロパティベーステストはHaskellから生まれたもので、型を利用してランダムな値を生成するらしい。

ではRubyならRBSを利用してランダムな値を生成できるんじゃないか?

テストケースをRBSから生成できたらいいのではないか?

何ならそのまま実行しちゃえばいいのでは?

と、いうわけで今回のアイデアを閃いた。

RBS as a Propertyというわけだ。

Integer#powの例も、RBSを元にランダムな値を生成して、返り値をチェックする簡単なスクリプトを作って、coreのRBSでいろいろ試して見た結果発見したケースだった。

RBSを実行する

RBSをテストケースとして実行して、間違っていたらRBSを直す。あるいは実装を直す。 これを繰り返していくと、RBSが完成していくと言うわけだ。

入念にケースを確認して、動作が保証された、新鮮なRBSが保たれる。

これって結構いいのでは……?

例えば以下のようなイメージだ。

# 失敗する場合
$ raap 'Integer#pow'
(Integer other, ?Integer modulo) -> Integer
........F
Fail in case of `-2.pow(-1) #=> (-1/2)[Rational]`

# 成功する場合
$ raap 'Integer#pow'
(Integer other, ?Integer modulo) -> Integer
.............

👌

おもしろい気がする。