Techouse Developers Blog

テックハウス開発者ブログ|マルチプロダクト型スタートアップ|エンジニアによる技術情報を発信|SaaS、求人プラットフォーム、DX推進

RubyKaigi 2024 - Unlocking Potential of Property Based testing with Ractor (Day1)

ogp

Unlocking Potential of Property Based Testing with Ractor

こんにちは、2024年に新卒で入社し、クラウドハウス採用でバックエンドエンジニアをしているrei_fujiseです。

本記事ではRubyKaigi 2024の1日目のMasato Ohba(@ohbarye)さんによるセッション Unlocking Potential of Property Based Testing with Ractor について紹介させていただきます。
セッションで使用されていたスライドはこちらに公開されています。また、デモに使用されたソースコードはこちらに公開されています。

このセッションでは「Property based testing が Ractor の良いユースケースとなる」という仮説の検証をしたことについて、Property based testing と PBT というgemについての解説を交えながら紹介されていました。 github.com

セッションの章立ては以下の通りです。

  1. What is Property based testing?
  2. How does Ractor unlock the potential?
  3. Testing tool implementation to verify the hypothesis
  4. Findings

What is Property based testing?

会場でのアンケートでは、Property based testing を知ってる人の割合は聴講者の2割程度でした。セッションではまず、Property based testing について紹介がありました。

いつものテストについて考える

まずはいつも書いているテストについて見てみます。次のような、ソートを行うメソッドについて考えます。

def sort(array)
  return array if array.size <= 1
  pivot, *rest = array
  left, right = rest.partition { |n| n <= pivot }
  sort(left) + [pivot] + sort(right)
end

RSpec ではこのメソッドのテストを次のように書くことができます。

RSpec.describe "sort" do
  it "sorts an array" do
    # 入力と出力の組み合わせ1
    expect(sort([3, 1, 2])).to eq([1, 2, 3]) 
    # 入力と出力の組み合わせ2
    expect(sort([3, 1])).to eq([1, 3])       
    # 入力と出力の組み合わせ3
    expect(sort([])).to eq([])               
  end
end

ここでは、以下のような流れでテストを行なっています。

  1. 入力と出力の組み合わせをプログラマーが考えて、プログラムを組み立てる。
  2. そのプログラムをコンピューターが実行してテストを行う。

これは、Property based testing と対比されるテスト手法で Example based testing と呼ばれるテスト手法になります。
Example based testing には次のようなメリットがあります。

  • 読みやすい
  • 書きやすい
  • テストの検証がしやすい

しかし、入力と出力の組み合わせをプログラマーが考えるという特性上、プログラマーが思いつかないケースはテストできないというデメリットがあります。もし、プログラマーが思いつかなかったケースが検証されないまま本番へリリースされてしまうと、テストされていない部分で不具合が発見される可能性があります。

こうした、Unexpected bugsの発生をなるべく減らしていくことが Property based testing のモチベーションの1つとなります。

Property based testing について

先ほどの sort メソッドに対する Property based testing を用いた場合のテストコードは以下のようになります。

require "pbt"

Pbt.assert(worker: :none) do
  Pbt.property(Pbt.array(Pbt.integer)) do |numbers|
    result = sort(numbers)
    result.each_cons(2) do |x, y|
      raise "Sort algorithm is wrong." unless x <= y
    end
  end
end

このテストでは、テスト対象の満たすべき性質をプログラマーが考えてプログラムを組み立てます。テストツールが数百から数千の入力値をランダムに生成し、出力が性質を満たしているかをテストします。RSpec を用いたテストよりも短いコードでより多くの入力値に対するテストが可能になります。

では、先ほどの sort メソッドを、ソート対象の配列の要素数が奇数の時はソートしないように変更してテストを実行してみましょう。

def sort(array)
  return array if array.size <= 1
  return array if array.size.odd? # Add bug: 要素の数が奇数の時はソートしない
  pivot, *rest = array
  left, right = rest.partition { |n| n <= pivot }
  sort(left) + [pivot] + sort(right)
end

実行結果は次のようになりました。

Property failed after 1 test(s) (Pbt::PropertyFailure)
  seed: 231063280440103058315824057631621178907
  counterexample: [0, 1, 0]
  Shrunk 61 time(s)
  Got RuntimeError: Sort algorithm is wrong.

ここでは以下の流れでテストが行われています。

  1. テストツールがランダムに値を生成する
  2. ランダムに生成された値を worker が1つずつとっていく
  3. 失敗するテストケースがあった場合、テストケースのとる値のパターンを狭める
  4. プログラマーに、バグが再現可能な最も最小なテストケースを提示する

このテストの流れにはProperty based testing におけるエッセンスが3つあります。

  1. Generator (Arbitary)
    ランダムな値を生成し、入力値として渡す機能。上記の例では、テストコード内のPbt.array(Pbt.integer) がその役割を果たしており、整数の配列を入力値として与えて欲しいという指示になっている。
  2. Property
    テストの対象のコードが満たして欲しい性質を表現する。上記の例でいうところのunless x <= yに当たる部分で、ソートのアルゴリズムの満たしていなければならない性質としては、ソートされた結果を2つづつ取り出した時に、1つ目の値が2つ目の値以下であることが挙げられる。
  3. Shrinking
    失敗した場合にさらに小さい値で失敗する入力値を探索し提示する機能。失敗した時の実行結果に、バグを再現可能な最小の入力値が表示されていることで、プログラマーはデバッグ作業がしやすくなる。

Property based testing はこのような特性を持つため、プログラマーが想定できないような大量で多様な値を生成できます。これによりUnexpected bugsを発見できます。
一方で、Property based testing は、propertyの設定の難しさなどから習熟が難しかったり、大量のテストデータを用いて検証するのでテストの実行がExample based testing に比べて遅いという側面があります。
そのため、Example based testing と Property based testing は補完関係にあり、両方のテストを使い分けることが推奨されます。


以下に Example based testing と Property based testing の比較表を示します。

Example based testing Property based testing
コードの行数 ❌ 冗長 ✅ 簡潔
カバレッジ ❌ プログラマーに依存する ✅ より多様な入力値
習熟難度 ✅ わかりやすい ❌ 習熟は難しい
実行時間 ✅ 速い ❌ 更に時間を要する

Property based testing の実績や、Example based testing との詳しい使い分けに関しては公開されているスライドに記載があるためとても勉強になります。スライド

How does Ractor unlock the potential?

Property based testing では先に紹介した通り、多様な入力値で大量にテストを実行します。そして、その入力値は互いに影響しない独立した処理単位です。よってProperty based testing で実行するテストケースは Ractor による並列処理が可能です。このことから、Ractor による並列処理で Property based testing の弱点であった実行時間が遅い点を改善できるのではないかと考えられます。

Ractor の使い方とユースケースの簡単な紹介

Ractor は Ractor.new に対して与えたブロックを並列に処理できるといったものです。ユースケースはとしては CPU-bound な独立した処理というものがあげられます。Multi-threading ではGVLのために並列数が最大で1となってしまうためこれはできません。一方で Ractor であればCPUコア数分だけ並列に処理を実行できます。

Testing tool implementation to verify the hypothesis

セッションでは、「Property based testing が Ractor の良いユースケースとなる」という仮説を検証するために実装されたツールの紹介と、ベンチマークの結果を示していました。

PBT gem の使い方について

Property based testing ツールとして作成された PBTというgemが公開されています。ここではこのPBTの機能と内部動作について詳しく紹介されていました。ただし、PBTには以下の注意点が挙げられています。

  • このgemはすでに作成されている example based test の実行速度を上げるためのものではない
  • RSpec などのテストフレームワークを再実装するためのものではない

このgem に関しては property based testing の紹介部分ですでに使用していますが、ractor を使用するにはworker: :ractorを指定する必要があります。また、seed を指定することでテストケースの再現が可能です。verbose オプションを有効にすることで詳細なログを出力できるので、先ほどのバグを含むソートメソッドのテストのログをみてみましょう。

require 'pbt'

Pbt.assert(worker: :ractor, verbose: true, seed: 231_063_280_440_103_058_315_824_057_631_621_178_907) do
  Pbt.property(Pbt.array(Pbt.integer)) do |numbers|
    result = sort(numbers)
    result.each_cons(2) do |x, y|
      raise 'Sort algorithm is wrong.' unless x <= y
    end
  end
end

実行結果の全てのログの記載は控えますが、warning: Ractor is experimental, and the behavior may change in future versions of Ruby! Also there are many implementation issues. という、Ruby本体が出力している警告が出力されたことからRactorで実行できていることがわかります。
また、以下のような失敗したテストケース(Encountered failures) が出力されました。これをみると、入力値が徐々に小さくなっている様子が見て取れ、"Shrinking"が動作していることがわかります。

Encountered failures were:
- [528190, 769171, 875666, 254461, -88857]
- [769171, 875666, 254461]
- [384586, 875666, 254461]
- [192293, 875666, 254461]
- [96147, 875666, 254461]
- [48074, 875666, 254461]
- [24037, 875666, 254461]
- [12019, 875666, 254461]
- [6010, 875666, 254461]
- [3005, 875666, 254461]
- [1503, 875666, 254461]
- [752, 875666, 254461]
- [376, 875666, 254461]
- [188, 875666, 254461]
- [94, 875666, 254461]
- [47, 875666, 254461]
- [24, 875666, 254461]
- [12, 875666, 254461]
- [6, 875666, 254461]
- [3, 875666, 254461]
- [2, 875666, 254461]
- [1, 875666, 254461]
- [0, 875666, 254461]
- [0, 437833, 254461]
- [0, 437833, 127231]
- [0, 218917, 127231]
- [0, 218917, 63616]
- [0, 109459, 63616]
- [0, 109459, 31808]
- [0, 54730, 31808]
- [0, 54730, 15904]
- [0, 27365, 15904]
- [0, 27365, 7952]
- [0, 13683, 7952]
- [0, 13683, 3976]
- [0, 6842, 3976]
- [0, 6842, 1988]
- [0, 3421, 1988]
- [0, 3421, 994]
- [0, 1711, 994]
- [0, 1711, 497]
- [0, 856, 497]
- [0, 856, 249]
- [0, 428, 249]
- [0, 428, 125]
- [0, 214, 125]
- [0, 214, 63]
- [0, 107, 63]
- [0, 107, 32]
- [0, 54, 32]
- [0, 54, 16]
- [0, 27, 16]
- [0, 27, 8]
- [0, 14, 8]
- [0, 14, 4]
- [0, 7, 4]
- [0, 7, 2]
- [0, 4, 2]
- [0, 4, 1]
- [0, 2, 1]
- [0, 2, 0]
- [0, 1, 0]

PBT gem内部の実装について

PBT gem の内部の実装について詳しく解説がありました。こちらのスライドに示されている構成となっています。それぞれ次のような役割を持っています。

  1. Runner, RunnerIterator
    PBT.assert を実行すると Runner#assertが呼び出されます。
    RunnerIterator では大量のテストケースの作成とイテレーション、失敗時のShrink時の処理などが行われます。
  2. RunExecution, RunDetailsReporter
    プログラマーに対して失敗した結果を報告する。先ほどのログはここで生成されています。
  3. Property
    テスト対象に対して、どういう性質を持っていて欲しいのかを記述する部分。プログラマーが定義したテスト対象の性質である、predicate と arbitrary をインスタンス変数として持ちます。また、テストの実行する部分でもあり、Ractor で実行する場合は Ractor のブロック引数として @predicate を渡しています。
  4. Arbitrary
    テスト対象に与える入力値の種類を記述する部分。Pbt.integerを呼び出す際に必要に応じて最大値・最小値を指定でき、その間からランダムな値を返すShrink の際は0に向かって近づけていく処理などが含まれています。Integer 以外にも、symbol や ascii_char など30種類ほどのArbitraryが提供されています。
    また、この Arbitrary は組み合わせることができるため "Integer を持つ Array" などをランダムに生成することが可能となっています。さらに、Map/Filter arbitrary を用いることで、ある Arbitrary の生成した値にある関数を実行した結果を生成できます。

他にもProperty based testing を行う Ruby のツールは他にもありますが、部分的に並列処理をすることが難しい構造となっていたためこのようなデザインで作成されました。

PBT gem の良い点

  1. テストを再現できること
    seed を固定することにより、生成される値を決定的にできます。
  2. verbose モード
    ログを詳細に出力できるため、Shrinkによって入力値が徐々に減っていく様子をプログラマーが確認できます。
  3. 複数の並行・並列処理をサポートしている
  4. RSpec の matcher を動かせるようにしている
    Ractor でこれを動かすにはexperimental_ractor_rspec_integration: true を指定する必要があります。ただし、危険な手法を用いてこれを実現しているため、この機能は削除される可能性があります。

ベンチマークの結果について

ベンチマークの結果は公開されているスライドのようになり、この結果からPBTのデフォルトのワーカーは sequential なものが採用されることとなっています。背景としてはほとんどのケースにおいて sequential が最も早かったことと Ractor が使用できるかどうかを考慮する必要がない点が挙げられていました。

findings

セッションの最後では、今回のPBT作成から検証までの取り組みを通して、以下のような知見が得られたと述べられていました。

  1. ベンチマークの結果から、CPU-bound な処理においてのみ Ractor による並列処理が Property based testing の実行時間の改善に有効であったこと。
  2. Ractor を実際に使うときの難しさについて
    Ruby のエコシステムで用いられているライブラリに Ractor との互換性がなく、普段利用しているライブラリを Ractor のエコシステム上で用いることが難しい、といった問題が生じたそうです。

「Property based testing が Ractor の良いユースケースとなる」という仮説の検証結果としては、部分的にそうであるという結論で、CPU-bound かつ Ractor で動くコードのみでできているという条件が揃っていれば sequential な処理に比べて実行時間が5倍ほど速くなる結果となっていました。

感想

RubyKaigi 2024 の初日のセッションでしたが、Property based testing と Ractor をすぐに使ってみたくなる内容で、早速参加して良かったなと感じました。PBT gem はテストケースの作成やログを簡単に確認できることから Property based testing を始める上でとても強力なツールであることを感じましたが、property を期待通りに生成することが難しいため思った通りのテストをするにはツールに対する慣れが必要だと感じました。まずは簡単なメソッドのテストから徐々に使ってみようと思います。解説も非常にわかりやすく、Ruby初学者の私でも自然と内容を理解できたので、発表の手本としたいと思える、本当に学ぶところの多い講演でした。

Techouseでは、社会課題の解決に一緒に取り組むエンジニアを募集しております。 ご応募お待ちしております。 jp.techouse.com