ククログ

株式会社クリアコード > ククログ > RubyKaigi Takeout 2021とtest-unit - Ractor対応とdebug.rb対応とCoreAssertionsの置き換え #rubykaigi

RubyKaigi Takeout 2021とtest-unit - Ractor対応とdebug.rb対応とCoreAssertionsの置き換え #rubykaigi

test-unitをメンテナンスしている須藤です。RubyKaigi Takeout 2021でtest-unitの開発が進んだので紹介します。

Ractor対応

Ruby 3.0にRactorが入りましたが、Ractorを使うためにはいくつか制限があります。多くのライブラリーはその制限にひっかかるためRactorと一緒に使うことができません。私がメンテナンスしているcsvも制限にひっかかっているライブラリーの1つです。 https://github.com/ruby/csv/pull/218 で対応作業中です。

Ruby本体で使っているminitestベースの独自テスティングフレームワークにはRactor用のアサーションがありますが、test-unitにはありませんでした。柴田さんのスライドにもある通り、csvも含めて https://github.com/ruby/ 以下のライブラリーはminitestベースの独自テスティングフレームワークからtest-unitに移行しています。そのため、test-unitにもRactorをテストするための便利機能が必要でした。

Ruby本体にあるRactor用のアサーションは別プロセスでRubyを立ち上げてその中でRactorを使ったコードを実行してその結果をアサートします。こんな感じです。

assert_ractor(<<~"end;", require: 'csv')
  r = Ractor.new do
    rows = []
    CSV.foreach('#{@input.path}', col_sep: "\\t", row_sep: "\\r\\n").each do |row|
      rows << row
    end
    Ractor.yield rows
  end
  rows = [
    ["1", "2", "3"],
    ["4", "5"],
  ]
  assert_equal(rows, r.take)
end;

別プロセスで実行するのでRubyスクリプトを文字列で指定しないといけないのですが、私はそれがイヤな感じだと思っていました。そのため、test-unitではなにか別のアプローチで対応したかったのです。

そもそもどうして別プロセスで実行しないといけなかったのでしょうか。それは https://github.com/ruby/ruby/commit/bc23216e5a4204b8e626704c7277e9edc1708189 のコミットメッセージに書いてありました。Ractorを使うとRubyインタプリター内の実行モードが変わってRactorがいなくなってもそのモードは元に戻らず他のテストに影響があるからだそうです。

であれば、Ractor関連のテストを最後に実行すれば別プロセスに分けなくてもよさそうです。Ractorを使って実行モードが変わったとしてもその状態で他のテストを実行しなければ他のテストに影響はないからです。ということをRubyKaigi Takeout 2021のRuby Committers vs the Worldの延長戦の時間にささださんに聞いたらそれで大丈夫そうということでした。ということで、test-unitでは次のようなAPIでRactorのテストを書けるようにしました。

ractor
def test_ractor_xxx
  r = Ractor.new(@input.path) do |path|
    rows = []
    CSV.foreach(path, col_sep: "\t", row_sep: "\r\n").each do |row|
      rows << row
    end
    Ractor.yield rows
  end
  rows = [
    ["1", "2", "3"],
    ["4", "5"],
  ]
  assert_equal(rows, r.take)
end

テストの前にpublic/privateのようにractorをつけるだけです。これでそのテストはRactorを使っていると判断して最後に実行するようになります。

なお、Ruby 3.1までにはRactorを使ってもそのRactorが終了すれば実行モードを元に戻すようにするということなので、Ruby 3.1ではこの機能を使わなくても問題なくなるはずです。

debug.rb対応

ささださんがdebug.rbを自慢していたのでtest-unitでも使えないか考えてみました。test-unitを使っていてデバッガーがあると嬉しいケースは次の2つです。

  1. アサーションが失敗した時
  2. 意図しない例外が発生した時

前者については実装しました。gem install debugした状態で--debug-on-failure付きでテストを実行すると、アサーションが失敗したフレームでデバッガーが起動します。アサーションが失敗したときはそのときの環境がどうなっているか(対象オブジェクトのインスタンス変数とか)を調べてなにが期待しない結果を引き起こしているかを調べたくなるので、このときにデバッガーが動くと便利そうです。

後者については実装できませんでした。発生した例外が「意図しない」かどうかを判断できるタイミングがデバッガーを起動するには遅すぎるからです。

たとえば、次のテストはArgumentErrorが発生しますがこれは「意図しない」例外ではありません。そのため、このテストではデバッガーは起動しなくていいです。

def string_to_number(string, default)
  begin
    Integer(string, 10)
  rescue ArgumentError
    default
  end
end

def test_string_to_number
  assert_equal(29, string_to_number("invalid", 29))
end

一方、次のテストで発生するArgumentErrorは「意図しない」例外です。この場合は例外が発生したところでデバッガーが起動して欲しいです。

def string_to_number(string)
  Integer(string, 10)
end

def test_string_to_number
  assert_equal(29, string_to_number("invalid"))
end

このような「意図しない」かどうかの判断はどこでできるかというと、test_string_to_numberの外なんですよね。。。

def run_test
  begin
    test_string_to_number
  rescue
    # ここに来たら「意図しない」例外
  end
end

しかし、rescueの中ではすでに例外が発生したフレームは失われているのでデバッガーでそのフレームに移動できません。

試しに次のようにして例外発生時のbindingを保持しておいて無理やりrescueのタイミングでデバッガーを起動しようとしてみます。

require "debug"

exception_bindings = {}

trace = TracePoint.new(:raise) do |tp|
  exception_bindings[tp.exception] = tp.binding
end
trace.enable

def internal
  raise "Run debugger here!"
end

begin
  internal
rescue => error
  exception_bindings[error].break
end

しかし、これは次のようにエラーになります。

/tmp/x.rb: exception reentered (fatal)

例外発生時にデバッガーが起動するのは便利だと思うんですが、どうにかして実現できないものかしら。

CoreAssertionsの置き換え

Ruby本体の独自テスティングフレームワークにはRuby本体のテストで使うための便利アサーション集CoreAssertionsが入っています。おそらくRubyのコア用のアサーション集なのでCoreAssertionsなのだろうと思います。

CoreAssertionshttps://github.com/ruby/ruby に入っているので https://github.com/ruby/ 以下の他のリポジトリー(たとえば https://github.com/ruby/csv とか)で使おうと思ったらどうにかして(コピーするとかsubmoduleするとかして)同期しないといけません。そんなメンテナンスはしたくないのでできるだけtest-unitで同じ機能を提供し始めています。

たとえば、assert_no_memory_leakというメモリーリークしていないかをチェックするアサーションはassert_nothing_leaked_memoryとしてtest-unitが提供するようになっています。CoreAssertionsにあるassert_no_memory_leakは別プロセスでクリーンな状態で実行するとかいろんな環境でメモリー使用量を確認できるとかすごくがんばっていますが、test-unitassert_nothing_leaked_memoryは同一プロセスかつLinuxだけサポートという簡易版です。私がメンテナンスしているFiddleでもassert_no_memory_leakを使っていたのですが、Fiddleのケースではtest-unit実装でも十分だった(どのプラットフォームでも同じ実装を使うのでLinuxでだけ確認できれば大丈夫とか)ので、簡易版実装になっています。同一プロセスだとメモリー使用量の計測が安定しないというのはあるのですが、デバッグするときは同一プロセスの方が便利とかトレードオフがあるので、test-unitではできるだけ同一プロセスでの実装に寄せています。

今後も、私が必要に迫られたやつから順にCoreAssertionsの代替をtest-unitに追加していく予定です。

CoreAssertionsが依存しているEnvUtilという便利ツール集みたいなやつがあるのですが、それにも依存せずに済むようになにかしらがんばっていこうとは思っています。が、EnvUtil#.under_gc_stressは失敗しました。残念。くじけずにがんばろう。

まとめ

RubyKaigi Takeout 2021きっかけで改良したtest-unitの機能を紹介しました。

次はマルチプロセスでの並列テスト実行機能を実装する気がします。テスティングフレームワークの開発に興味がある人は@ktouに声をかけてね!