テストを実行してRubyの型情報を集めるやつを作った

イントロダクション

「テストを走らせて型情報を収集すればいいんじゃない?」そのアイデア自体は話題に上がることが多かったかと思われますが、観測範囲では前例がないように見えます。そこで、実際に作ってこそ見える世界があると思い動くものを実装してみました。

Orthoses::Trace

github.com

orthosesはRBSを生成するための機能を作るフレームワークで、この機能の一つとしてOrthoses::Traceというミドルウェアを実装しました。

例題として、rack-testというgemのRBSを生成したいとします。 その場合の生成コードをOrthoses::Traceを使って以下のように準備します。

https://github.com/ksss/orthoses/blob/db80d506c5fb02dadaa0ae303e0761ba0a543f6f/examples/rack-test/generate.rb

require 'pathname'
require 'orthoses'
require 'fileutils'

include FileUtils

out = Pathname('out')
out.rmtree rescue nil
Orthoses.logger.level = :warn
Orthoses::Builder.new do
  use Orthoses::CreateFileByName,
    base_dir: out.to_s
  use Orthoses::Filter do |name, content|
    name.start_with?("Rack::Test")
  end
  use Orthoses::Trace,
    patterns: ['Rack::Test*']
  run ->(){
    cd "src" do
      load "spec/all.rb"
      Minitest.run
      # avoid to run on at_exit
      module ::Minitest
        def self.run args = []
          0
        end
      end
    end
  }
end.call

このコードを実行すると、rack-testのリポジトリ内のテストコードを走らせ、RBSを生成することができます。

Orthoses::CreateFileByNameミドルウェアの働きによってclass毎にファイルができます。

その一部を見てみましょう。Rack::Test::UploadedFileの場合は以下のような出力を得ることができます。

class Rack::Test::UploadedFile
  attr_reader tempfile: Tempfile?
  attr_reader original_filename: String
  attr_accessor content_type: String
  def self.finalize: (Tempfile file) -> Proc
  private def initialize_from_file_path: (String path) -> nil
  private def initialize: (String content, ?String content_type, ?bool binary, ?original_filename: nil) -> void
                        | (StringIO content, ?String content_type, ?bool binary, ?original_filename: String) -> void
                        | (String content, ?String content_type, ?bool binary, ?original_filename: String) -> void
                        | (StringIO content, ?String content_type, ?bool binary, ?original_filename: nil) -> void
  private def respond_to_missing?: (Symbol method_name, ?bool include_private) -> bool
  def method_missing: (Symbol method_name, *Array[untyped] args) ?{ (*untyped) -> untyped } -> (Integer | String)
                    | (Symbol method_name, *Array[Encoding] args) ?{ (*untyped) -> untyped } -> File
  def append_to: (String buffer) -> nil
  def self.actually_finalize: (Tempfile file) -> bool
  private def initialize_from_stringio: (StringIO stringio) -> StringIO
  def path: () -> String
end

テストコードを走らせて、わりとそれっぽいRBSを得ることができました。

実装方法

TracePointを使いました。基本的にはTracePointのcallイベントで引数の型を、returnイベントで返り値の型を、それぞれテスト実行時に集めておくという発想です。

コード例で説明します。

def foo(a)
  a.id
end

みたいなコードがあったときに、このままではa.idが何を返すのかわかりません。 そこで、実際にコードを実行し、fooが呼ばれたときのaの値を見て引数の型を、返り値を見て返り値の型をfooの型として蓄積しておきます。

結果として

  • 引数aはRecord型, 返り値はIntegerだった。
  • 引数aはRecord型, 返り値はnilだった。

という情報を集めることができます。

これらの情報を統合して

def foo: (Record) -> Integer?

というRBSに落とし込む、というのが基本的な発想です。 コードの実行が必要ではありますが、コードを実行する最高のサンプルとしてテストコードを使用すれば、既存の資産を流用しつつ0からよりも遥かに効率的にRBSを記述することが期待できます。

既存の手法

静的な型ファイルを自動生成する既存の手法としては以下の前例があります。 これらの前例と今回の手法を比較し、どのようなメリットがあるのかを考えます。

rbs prototype

その名の通り、RBSのプロトタイプを作成するためのコマンドです。 静的解析と動的解析の両方があり、コマンドひとつで生成できるので、RBSのプロトタイプ生成として最も有名な方法だと思われます。

typeprof

静的解析によってできるだけ型情報を収集する手法です。かなり精度も高く「そんなことまで分かるのか!」と驚きます。

rbs-dynamic

RubyKaigi2022で発表されたツールで、コードを実行して型情報を得るものです。 今回実施した手法にかなり近いのですが、今回のように既存のテストコードをまるごと回す用途ではエラーが発生し動かせなかったので今回は使用していません。

比較

rbs prototype rb

ソースコードに対して実行します。簡略化のためコメントは除きます。

$ rbs --version
rbs 3.0.4

$ rbs prototype rb src/lib/rack/test/uploaded_file.rb | grep -v '#'
module Rack
  module Test
    class UploadedFile
      attr_reader original_filename: untyped

      attr_reader tempfile: untyped

      attr_accessor content_type: untyped

      def initialize: (untyped content, ?::String content_type, ?bool binary, ?original_filename: untyped?) -> void

      def path: () -> untyped

      alias local_path path

      def method_missing: (untyped method_name, *untyped args) { () -> untyped } -> untyped

      def append_to: (untyped buffer) -> nil

      def respond_to_missing?: (untyped method_name, ?bool include_private) -> untyped

      def self.finalize: (untyped file) -> untyped

      def self.actually_finalize: (untyped file) -> untyped

      private

      def initialize_from_stringio: (untyped stringio) -> untyped

      def initialize_from_file_path: (untyped path) -> untyped
    end
  end
end

rbs prototype runtime

$ rbs prototype runtime -r rack/test/uploaded_file 'Rack::Test::UploadedFile'

rbs prototype rbとほぼ変わらない結果となったので割愛

typeprof

$ typeprof --version
typeprof 0.21.7

$ typeprof src/lib/rack/test.rb src/spec/rack/test/uploaded_file_spec.rb
# ...snip...
class UploadedFile
  attr_reader original_filename: String?
  attr_reader tempfile: String | StringIO
  attr_accessor content_type: String
  def initialize: (String | StringIO content, ?String content_type, ?bool binary, ?original_filename: String?) -> void
  def path: -> untyped
  alias local_path path
  def method_missing: (:must_respond_to | :pos | :read method_name, *Symbol args) -> untyped
  def append_to: (untyped buffer) -> nil
  def respond_to_missing?: (untyped method_name, ?false include_private) -> true
  def self.finalize: (String | StringIO file) -> ^-> untyped
  def self.actually_finalize: (untyped file) -> untyped

  private
  def initialize_from_stringio: (String | StringIO stringio) -> (String | StringIO)
  def initialize_from_file_path: (String | StringIO path) -> void
end
# ...snip...

比較考察

出力は一部しか示していませんが、全体的な比較として以下のようになりました。

Tool メリット デメリット 備考
rbs prototype 実行が非常に手軽だった ほとんどがuntypedだった なし
typeprof 実行が手軽、かつある程度型がある。インスタンス変数も対応している。 untypedはある rbs collectionで型情報を追加できる可能性がある
Orthoses::Trace 型情報が多い 実行のためにコードを書く必要がある rbs collectionで型情報を追加できる可能性がある

手軽さと正確性にある程度のトレード・オフの関係があるように見えます。

もう少し細かく比較してみましょう。

実行の手軽さ

圧倒的にrbs prototypeやtypeprofが手軽です。Orthoses::Traceではちょっとしたスクリプトレベルのコードを書く必要があり手軽とは言えません。

型の正確性

方法によって考慮されている型情報に差はありますが、どの手法でも最終的な型情報はプログラマーの理想のものになるはずです。よっていずれの手法でもある程度の手直しは必要そうです。

対応範囲

typeprofは定数やインスタンス変数も対応しているのに対して、Orthoses::TraceではTracePoint軸なのでフックが設定でず対応できません。typeprofすごい。 また、Orthoses::Traceでは、呼ばれていないメソッドは情報がないので型生成できません。typeprofは呼ばれてなくても類推できます。typeprofすごい。

テストツールごとの対応

Orthoses::Traceではコードを書くことでテストを走らせ、型情報を得ることができることがわかりました。 テストツールは様々にあり、minitestやtest-unit、rspecなどが有名です。コードを書く以上、それぞれのツールに対応したコードを書く必要があります。 ここではOrthoses::Traceを使った場合のテストツール毎のコードの書き方を示しておきます。

まずテストコードを含むソースコード全体が必要なので、ソースコードgit clone等でダウンロードしておきます。 以後説明のため、このソースコード置き場のディレクトリー名を仮にsrcとします。

minitest

最初に示したrack-testがminitestを使っています。 minitestを使っている場合、autorunを使っていなければload "test/run.rb"みたいな感じでテストコードを読み、Minitest.runでテストを走らせることができます。しかし、大抵はautorunを使っているので工夫が必要です。 autorunはat_exitで実行されるので、そのタイミングだとorthosesで解析するProcの外になってしまいます。 なのでProc内でテストを実行しつつ、autorunを消すためにメソッドをまるごと上書きしています。Minitest.runはexit codeを返すインターフェースなので0を返すのがミソです。

test-unit

多分こんな感じだと思います。

Orthoses::Builder.new do
  use Orthoses::Trace, patterns: ['Foo*']
  run ->(){
    cd "src" do
      load "test/foo_test.rb"
      Test::Unit::AutoRunner.run

      class ::Test::Unit::AutoRunner
        def self.run(*, **)
          0
        end
      end
    end
  }
end

rspec

あんまりrspecを使ったライブラリーを見つけられなかったのですが、おそらく以下で動くと思います。 ただし、テストが成功しないとexitで強制終了してしまうのですが、テストが失敗しているということは型は間違っている可能性が高いのでこれでいいのかも。

require "rspec/core"
Orthoses::Builder.new do
  use Orthoses::Trace, patterns: ['Foo*']
  run ->(){
    cd "src" do
      load "spec/foo.rb"
      RSpec::Core::Runner.invoke
    end
  }
end

めんどい

テストライブラリーに合わせて柔軟に対応できる分、自分でコードを書かなければいけないのがめんどいので、もう少しサポート方法を考えたいところですね。。。 しかしながらリポジトリ毎にテストの走らせ方は千差万別で、gemの依存関係もそれぞれ異なってくるので統一された方法 は難しいと考えています。

思うところ

TracePoint#enable(target: )問題

attr_accessor系のメソッドがRubyVM::InstructionSequence.ofに対応してくれるともう少しコードが簡単になるのですがレアケースそう……。

実装の詳細

実装面はかなり端折りましたが、実は面白い工夫が盛りだくさんです。TracePointに興味がある人は覗いてみてください。

typeprofすごい

ということでOrthoses内でtypeprofを呼ぶことができる拡張も作りました。

https://github.com/ksss/orthoses-typeprof

これまでにOrthoses内で蓄積した情報をtypeprofに渡して型の参考値にすることができます。

ちなみに今回の出力結果は以下にまとめたので比較してみてください。

Orthoses::Trace: https://github.com/ksss/orthoses/tree/main/examples/rack-test/out/rack typeprof: https://github.com/ksss/orthoses-typeprof/tree/main/examples/rack-test/out/rack

感想

これでライブラリーのRBS追加が少し楽になるかも……?どうだろう、typeprofで十分なのかもしれない……。

とりあえず作ってみる事はできたので、今後は使ってみて便利かどうか確かめる。ですかねえ……。

そもそも何かしらやりたいことがあって、そのときに思いついた手法なのですが、思いついて実装してブログを書くのに5ヶ月ぐらいかかってしまい本来の目的を見失いました。お後がよろしいようで。

思いついたとき。