RBSをテストコードにする

DALL-Eより: Imagine a scene where the abstract concepts of Ruby programming and property-based testing blend harmoniously. Picture a large, glowing ruby crystal

まいどお馴染み、作ってみたシリーズです。

今回は、RaaP(ラープ)というツールを作りました。RBS as a PropertyでRaaPです。

github.com

RaaPはテスティングツールの一種で、RBSをそのままテストコードにみたてて実行してくれるツールです。

次のようなRBSがあったとして

class Foo
end

class Bar
  def initialize: (foo: Foo) -> void
  def f2s: (Float) -> String
end

つぎのようなテストコードを自動的に作って実行してくれるイメージです。

describe Bar do
  let(:foo) { Foo.new }
  let(:bar) { Bar.new(foo: foo) }

  it "#f2s" do
    100.times do |size|
      float = Random.rand * size
      expect(bar.f2s(float)).to be_a(String)
    end
  end
end

プロパティベーステストにおけるプロパティとは、どんなランダムな値が来ても共通で成功するテストケースのことです。

RBSをプロパティとして考える、"RBS as a Property"というわけです。

実際は追加のテストコードの記述は一切必要ありません。RBSを書くだけでテストコードとしても使えるし、書いたRBSはもちろんSteep等で利用できます。これが基本的なコンセプトです。

着想

最初の着想は、golangからでした。

私はrgotという誰も使っていないテスティングツールを愛用しているのですが、これはgolangの標準テスティングライブラリーであるtesting PackageRubyでそのまま書いてみたものです。RubyKaigiのLTでも発表したことがあるので、このときにテストの考え方については自分なりのモデルを作れたと思います。

久しぶりにrgotのメンテをしようとgolangのtesting Packageをのぞいてみると、rgot実装時にはなかったFuzzingという機能が追加されていました。なんの機能かもわからずとりあえずポートしてみると、なるほど、型を指定するとランダムな値を生成してテストを実行する機能だったようで、そういう考え方もあるのかと感心しました。

実践プロパティベーステスト

そこからさらに「実践プロパティベーステスト」を読んだことで新しいテストパラダイムがあることが非常に面白く感じました。

『実践プロパティベーステスト ― PropErとErlang/Elixirではじめよう』www.lambdanote.com

この本を読んでいる時に、いろいろな考えが浮かびます。

「これをRubyで表現したらどんな感じかなあ」

「QuickCheckは型が重要なHaskellでうまれてる。RBSを使って何かできないかなあ」

「このinteger()っていうジェネレーター、RBSから作れないかなあ」

RBSを書いたらテストコードを自動生成するのはどうだろう?」

「っていうかそのまま実行したらいいんじゃない?」

「そうすればテストコードの互換性とかで悩まなくても済むし、コードを書かなくてもいきなり使える手軽さもあるし、コードのテストもRBSのテストもできるし、最悪ツールを使わなくなってもRBSは残るんじゃない?」

「これやばくない?」

とそんな感じで開発してみたものになります。

実績からみる使用例

このツールは開発中でも多くの成果を出しています。

Symbol#=~

簡単な例から紹介しましょう。

https://github.com/ruby/rbs/pull/1704

ここではSymboldef =~: (untyped obj) -> Integer?を例に見てみます。

untypedはいわゆるany型で、全ての型がありえることを表します。

しかしながら、実際はなんでもいいわけではありません。

:sym =~ 1
# => undefined method `=~' for an instance of Integer (NoMethodError)

Integerを引数に渡しても絶対に成功しません。このままではSteepでチェックするとIntegerを渡しても型エラーになりません。このRBSは不十分であることがわかります。

これまでruby/rbsリポジトリーでは、RBSの記述の確かさはどのように確認されていたのでしょうか?

もちろん、テストが全く無かったわけではありません。

Symbol#=~のためには、以下のテストが用意されていました。

    assert_send_type "(Regexp) -> Integer",
                     :a, :=~, /a/
    assert_send_type "(nil) -> nil",
                     :a, :=~, nil

https://github.com/ruby/rbs/blob/03e1ad0d9925c4e107bed8859f26739cfd848ce8/test/stdlib/Symbol_test.rb#L40-L45

おそらくRBS明瞭期、大量にあるcoreメソッド一つ一つ確認していくことは難しく、メソッド名だけでもと型が用意されたのでしょう。それくらい正確なRBSを考えるのは人間には大変な作業なのです。

しかしながらこれでは、RBSの表現に対してテストパターンが足りなかったわけです。

RaaPで試してみましょう。

$ bundle exec raap 'Symbol#=~'
# Symbol

## def =~: (untyped obj) -> ::Integer?
SE, [2024-03-22T22:45:57.257849 #92819] ERROR -- : [TypeError] type mismatch: String given
F
Failed in case of `:v.=~(:r) -> nil[NilClass]`

### call stack:

```
:v.=~(:r)
```

success: 0, skip: 1, exception: 0

Fail:
def =~: (untyped obj) -> ::Integer?

:v.=~(:r)というケースで、TypeErrorが起きたと報告しています。

untypedという表現から、様々なオブジェクトで呼び出しを試み、TypeErrorが起きるパターンを、再現コード付きで教えてくれました。

機械的にテストケースを生成して試すことで、人間には大変だったテストケースの生成を大量に行うことができ、RBSの不十分な点がCLIコマンド一発で見つかりました。

Integer#pow

もう少し大きい例を紹介します。

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

これは以前の開発日記でも紹介したように、型の専門家であっても気付けない難しい型です。

RaaPなら簡単に再現コード付きで型の間違いを教えてくれます。

$ bundle exec raap 'Integer#pow'
# Integer

## def pow: (::Integer other, ?::Integer modulo) -> ::Integer
EI, [2024-03-22T22:53:31.836090 #93160]  INFO -- : Exception: [ZeroDivisionError] divided by 0
..EI, [2024-03-22T22:53:31.836288 #93160]  INFO -- : Exception: [ZeroDivisionError] divided by 0
..F
Failed in case of `2.pow(-1) -> (1/2)[Rational]`

### call stack:

```
2.pow(-1)
```

success: 4, skip: 0, exception: 2

## def pow: (::Float) -> ::Float
...F
Failed in case of `-2.pow(-4.25) -> (0.037162722343835025-0.037162722343835025i)[Complex]`

### call stack:

```
-2.pow(-4.25)
```

success: 3, skip: 0, exception: 0

## def pow: (::Rational) -> ::Rational
..F
Failed in case of `3.pow((3/11)) -> 1.3493480275940617[Float]`

### call stack:

```
rational = Rational(-3, -11)
3.pow(rational)
```

success: 2, skip: 0, exception: 0

## def pow: (::Complex) -> ::Complex
....................................................................................................
success: 100, skip: 0, exception: 0

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

3つのケースで間違いがあることを、再現コード付きで教えてくれました。 Rationalを使用したケースではcall stackという再現コードが2行に分かれています。 これをそのまま実行すれば、型がおかしかったパターンを再現できるわけです。

String#initialize

https://bugs.ruby-lang.org/issues/20292

開発途中でRubyのバグも見つけ報告しています。こんなコード人間はまず書かないと思いますが、ランダムな値によるテストならみつけ出せたので可能性を感じます。現在のRaaPでは、publicメソッドのみ確認するように変更したので再現コードは作り出せません。privateメソッドはオプショナルで実行できてもいいのかな……?

どうやって見つけたの?

RaaPではルートとなるmodule/class名を指定すれば、その名前で定義されているメソッド全てでテストできるようになっています。

出力が小さそうな既知のclassで試してみます。

$ bundle exec raap 'TrueClass'
# ::TrueClass#!

## def !: () -> false
....................................................................................................
success: 100, skip: 0, exception: 0

# ::TrueClass#===

## def ===: (true) -> true
....................................................................................................
success: 100, skip: 0, exception: 0

## def ===: (untyped obj) -> bool
....................................................................................................
success: 100, skip: 0, exception: 0

# ::TrueClass#inspect

## def inspect: () -> "true"
....................................................................................................
success: 100, skip: 0, exception: 0

# ::TrueClass#to_s

## def to_s: () -> "true"
....................................................................................................
success: 100, skip: 0, exception: 0

# ::TrueClass#to_json

## def to_json: (?::JSON::State state) -> ::String
....................................................................................................
success: 100, skip: 0, exception: 0

# ::TrueClass#&

## def &: (false | nil) -> false
....................................................................................................
success: 100, skip: 0, exception: 0

## def &: (untyped obj) -> bool
....................................................................................................
success: 100, skip: 0, exception: 0

# ::TrueClass#^

## def ^: (false | nil) -> true
....................................................................................................
success: 100, skip: 0, exception: 0

## def ^: (untyped obj) -> bool
....................................................................................................
success: 100, skip: 0, exception: 0

# ::TrueClass#|

## def |: (untyped obj) -> true
....................................................................................................
success: 100, skip: 0, exception: 0

複数のメソッドを横断的に確認することができました。

他にもいくつかruby/rbsRBSでの間違いをみつけています。

これから

このツールには可能性を感じているので、さらなる有用性を探究していきたいと思います。

RBSからのランダム生成だと、どうしてもカスタムなコードがないと無理なケースも多く存在します。 そのため、あくまで第一優先はCLIからの利用と考えつつも、既存のテストコードに組み込める表現も考えています。

これまではCRubyのcoreなclassに対して実験してきたので、実装はかなり信頼におけるケースが多かったのですが、 実際のアプリケーションでどのような有用性を発揮できるか確認できていないので、その辺も探っていきたいと思います。

無理矢理いいところを探すんじゃなくて課題ベースで考えたらって?

はは。