DIGGLE開発者ブログ

予実管理クラウドDIGGLEのプロダクトチームが、技術や製品開発について発信します

プロパティベーステスト (Property Based Testing) を Ruby で書き雰囲気を味わう

2023 年 10 月 30 日に『実践プロパティベーステスト ― PropErとErlang/Elixirではじめよう』(以下 実践プロパティベーステスト本)という本が出版されました。 プロパティベーステストというのは、テストの一手法なのですが、これまでとは違う範囲をカバーするテストです。 今回はそれを Ruby に適用するとどうなるか検証、また似ている既知との概念と対比して理解を深めました。

これは Ruby Advent Calendar 2023 15 日目の記事です。

実践プロパティベーステスト本は 2023 年 12 月現在、テストの一手法であるプロパティベーステストを理解することを主眼においた、日本語になっている唯一の商業本だろうと思っています。プロパティベーステストは、ユニットテストがそうであるように言語を問わない手法です。もちろん Ruby でもできます。

Ruby でも既にいくつかライブラリがあるようです

今回は、最近も更新されている点、README の参考文献に実践プロパティベーステスト本の原著が挙げられていた点をふまえて ruby-prop_check のほうを使いました。

TypeScript に馴染のある方は、TypeScript で実施された記事を書いた方がおられたので、こちらも参考になるかもしれません https://qiita.com/kiwa-y/items/354744ef7393d07a8928

プロパティベーステストとはどのような形式か

まずどんなものか触れてみましょう。 ruby-prop_check は Ruby のテスティングフレームワーク minitest や RSpec と統合して利用する例が公式の README に記載ありました。たぶん test-unit でもできると思います。ここでは RSpec を使いました。

このコードは https://gist.github.com/niku/3f445fa36d241724f84d2fecae6b5054 に置いてますので、お手元でも試せます。

今回、配列に入っている値の平均を取る、以下の関数について考えます。

# 全ての要素が整数の配列を引数にとり、その要素の合計を要素数で割った平均を整数で返す
def naive_average(array)
  array.sum / array.length
end

これに対応するプロパティベーステストは、

require 'rspec'

RSpec.describe "#naive_average" do
  G = PropCheck::Generators

  it "returns an integer for any input" do
    PropCheck.forall(G.array(G.integer)) do |numbers|
      result = naive_average(numbers)

      expect(result).to be_a(Integer)
    end
  end
end

となります。ふだんの rspec に存在するものと、見慣れないもの PropCheck::GeneratorsPropCheck.forall がいますね。

「任意の整数 ( G.integer ) を要素にもつ配列 ( G.array ) の全て ( PropCheck.forall ) は、 naive_average に引数として渡すと必ず Integer 型を返す」と読めたりするでしょうか。

実行は普通の RSpec と同じように bundle exec rspec naive_average_spec.rb といった形になります。実行してみると以下の結果が得られました

$ bundle exec rspec naive_average_spec.rb

#naive_average
  returns an integer for any input (FAILED - 1)

Failures:

  1) #naive_average returns an integer for any input
     Failure/Error: result = naive_average(numbers)

     ZeroDivisionError:

       (after 71 successful property test runs)
       Failed on:
       `[]`

       Exception message:
       ---
       divided by 0
       ---

       (shrinking impossible)
     # ./naive_average.rb:2:in `/'
     # ./naive_average.rb:2:in `naive_average'
     # ./naive_average_spec.rb:10:in `block (3 levels) in <top (required)>'
     # ./naive_average_spec.rb:9:in `block (2 levels) in <top (required)>'

Finished in 0.01036 seconds (files took 0.10241 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./naive_average_spec.rb:8 # #naive_average returns an integer for any input

71 回プロパティベーステストが成功した ( after 71 successful property test runs ) あとに、 [] を渡したとき ( Failed on [] ) に ZeroDivisionError で失敗したようですね。ひとまず 70 回テストするのに 0.01 秒で実施できるなら、100 回以上のテスト生成でも実用に耐えそうです。

さて、ほんとうに [] で失敗するでしょうか。確かめてみましょう。 リポジトリを Gist にするためにディレクトリ構成がいつもと違って lib/ にいないので -I . というのをコマンドに足していますが、プロパティベーステストには関係ないところなので、あまり気にしないでください。

確かに [] を渡すと divided by 0 (ZeroDivisionError) でエラーになっていますね。空配列は #length0 になるため 0 / 0 となり起きたようです。

$ bundle exec irb -I .
irb(main):001> require 'naive_average'
=> true
irb(main):002> naive_average([])
/Users/niku/src/property_based_testing_with_ruby_sample/naive_average.rb:2:in `/': divided by 0 (ZeroDivisionError)
        from /Users/niku/src/property_based_testing_with_ruby_sample/naive_average.rb:2:in `naive_average'
        from (irb):2:in `<main>'
        from <internal:kernel>:187:in `loop'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/lib/ruby/gems/3.3.0+0/gems/irb-1.9.0/exe/irb:9:in `<top (required)>'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/bin/irb:25:in `load'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/bin/irb:25:in `<top (required)>'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/lib/ruby/3.3.0+0/bundler/cli/exec.rb:58:in `load'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/lib/ruby/3.3.0+0/bundler/cli/exec.rb:58:in `kernel_load'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/lib/ruby/3.3.0+0/bundler/cli/exec.rb:23:in `run'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/lib/ruby/3.3.0+0/bundler/cli.rb:492:in `exec'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/lib/ruby/3.3.0+0/bundler/vendor/thor/lib/thor/command.rb:28:in `run'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/lib/ruby/3.3.0+0/bundler/vendor/thor/lib/thor/invocation.rb:127:in `invoke_command'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/lib/ruby/3.3.0+0/bundler/vendor/thor/lib/thor.rb:527:in `dispatch'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/lib/ruby/3.3.0+0/bundler/cli.rb:34:in `dispatch'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/lib/ruby/3.3.0+0/bundler/vendor/thor/lib/thor/base.rb:584:in `start'
        from /Users/niku/.asdf/installs/ruby/3.3.0-preview3/lib/ruby/3.3.0+0/bundler/cli.rb:28:in `start'
        ... 5 levels...

さて、仕様では「全ての要素が整数の配列を引数にとり、その要素の合計を要素数で割った平均を整数で返す」ということを考えていました。空配列は「全ての要素が整数の配列」といえるでしょうか?言葉の意味的にどうかはさておき、このプログラムを提供する側と、利用する側で考えが異なるとトラブルになるので、未然に防ぎたいところです。プロパティベーステストは、こういった開発時に意識から外れている範囲のことに気づくきっかけを得られるのがいいところですね。

今回は仕様を「全ての要素が整数の、 要素を 1 つ以上持つ 配列を引数にとり、その要素の合計を要素数で割った平均を整数で返す」へと更新して、そのテストを行うことにします。

PropCheck::Generators.array をみると、こういった指定にもオプションで対応できるようになっています。 最低要素数、最大要素数、空配列を許可するか、ユニークであることが指定できそうですね。 G.array の引数に empty: false を追加して、以下のコードになりました。

require 'naive_average'
require 'prop_check'
require 'rspec'

RSpec.describe "#naive_average" do
  G = PropCheck::Generators

  it "returns an integer for any input except empty array" do
    PropCheck.forall(G.array(G.integer, empty: false)) do |numbers|
      result = naive_average(numbers)

      expect(result).to be_a(Integer)
    end
  end
end

実行しましょう。

$ bundle exec rspec naive_average_spec.rb

#naive_average
  returns an integer for any input except empty array

Finished in 0.017 seconds (files took 0.10255 seconds to load)
1 example, 0 failures

うまくいったみたいですね。 (デフォルトではテストは forall あたり 100 回実施する設定になっていました。n_runs という設定で変えられます

既知の概念との対比

ここらへん、私の理解や解釈が書いてあるので、まちがっているかもしれないです。そのときはやさしく教えていただけると感謝しつつ直します。

以下に列挙するように、他の概念と重なりつつも、異なるアプローチでソフトウェアを形づくろうとしています。だからこそ今までにない知見が得られて有用で、だからこそ今までの知見が使いにくく書きにくいのだと思っています。

普段書いているテストと、プロパティベーステスト

プロパティベーステストの立場にいるときに、ふだん私たちが行っているテストを何とよぶのが適切でしょうか。 英語だと "example based testing" と呼ぶようです、実践プロパティベーステスト本だと「事例ベースのテスト」など、事例ベースという表現の訳になっていました。

事例ベースのテストでは、具体的な事例を扱います。たとえば "abc" + "d""abcd" になるといった形ですね。

プロパティベースのテストでは、プロパティを扱います。プロパティというのは何でしょうか。1 章(p.8) には

「どのような入力値を与えても常に同じであるような振る舞い」を記述するルールを見つけて、それを実行可能なコードとして書き表すのです。

とありました。ここまで見てきたような、常に真となるようなルールを並べながら仕様を形作っていくことになります。 たとえば

  • (ascii なら) 足す前の左側、右側の文字数合計と、足した後の文字数は等しい
  • 左側の文字は、足したあとの文字に前方一致する。右側の文字は、足したあとの文字に後方一致する

などです。

1 章(p.11) には

事例ベーステストは「コードが自分たちの想定した通りに実行されるかを確信する」ための助けになり、プロパティベーステストは「プログラムに何ができて何ができないかを確認するためにその振る舞いを探索し、そもそも自分たちの想定が正しいかどうかを判断する」ための助けになります

とあり、私はこの例えがしっくりきました。プログラマの想像外を見つける手助けになるのがプロパティベーステストなんですね。助けになる種類が違うので、どちらかだけだけでなく、両方行うことが肝要だと感じました。

データ(テーブル)駆動テストと、プロパティベーステスト

test-unit だと、こんな感じに test_plus で利用する引数と期待する結果を定義して、一つのテストに複数のデータを流してテストできます。この例だと 4 = 3 + 1 と -1 = 1 + (-2) ですね。

    class TestDataSet < TestCalc
      data("positive positive" => {:expected => 4, :augend => 3, :addend => 1},
           "positive negative" => {:expected => -1, :augend => 1, :addend => -2})
      def test_plus(data)
        assert_equal(data[:expected],
                     @calc.plus(data[:augend], data[:addend]))
      end
    end

Go でも公式の Wiki に項目があるくらい有名な手法です。

var flagtests = []struct {
    in  string
    out string
}{
    {"%a", "[%a]"},
    {"%-a", "[%-a]"},
    {"%+a", "[%+a]"},
    {"%#a", "[%#a]"},
    {"% a", "[% a]"},
    {"%0a", "[%0a]"},
    {"%1.2a", "[%1.2a]"},
    {"%-1.2a", "[%-1.2a]"},
    {"%+1.2a", "[%+1.2a]"},
    {"%-+1.2a", "[%+-1.2a]"},
    {"%-+1.2abc", "[%+-1.2a]bc"},
    {"%-1.2abc", "[%-1.2a]bc"},
}
func TestFlagParser(t *testing.T) {
    var flagprinter flagPrinter
    for _, tt := range flagtests {
        t.Run(tt.in, func(t *testing.T) {
            s := Sprintf(tt.in, &flagprinter)
            if s != tt.out {
                t.Errorf("got %q, want %q", s, tt.out)
            }
        })
    }
}

このテスト形式と、プロパティベーステストは、一つのテストに対して多数のデータを入力して検証するというところは似ています。 ただそれでも、データ駆動テスト形式はあらかじめプログラマが定めた値そのものを検証するという部分がプロパティベーステストとは異なります。

ファジングと、プロパティベーステスト

Go には標準機能でファジングが行え、テスティングフレームワークに統合されるぐらい、ファジングは市民権を得ているようです。 ファジングと、プロパティベーステストはどう違うのでしょうか。https://go.dev/doc/tutorial/fuzz#write-the-code-2 を読むと、

The unit test has limitations, namely that each input must be added to the test by the developer. One benefit of fuzzing is that it comes up with inputs for your code, and may identify edge cases that the test cases you came up with didn’t reach.

「unit テストは開発者によって入力が加えられなくてはならないのに対し、ファジングはコードへの入力を生成してくれる」とあり、似ているように見えます。

わたしもここの理解があやふやな所もありますが、値生成によるテストで検証するという手法は同じでも、ファジングはセキュリティの担保や脆弱性の検知といったように、壊れない頑健さを確かめるもので、プロパティベーステストは期待の動作が行われることを確かめるものと、目的が異なるのかなと思っています。

プロパティベーステストではファジングのようなことも行うので、プロパティベーステストの方がより広い概念を扱っていると言えそうです。

訳者まえがきにも

なお、プログラムがクラッシュするよう なテストケースに関して「ファジング」と呼ばれる手法を耳にしたことがある方もいるかもしれませんが、プロパティベーステストはファジングよりも広範囲のシナリオで活用できる仕組みといえます。

とありました。

型と、プロパティベーステスト

最初に挙げていた例の「全ての要素が整数の、要素を 1 つ以上持つ配列を引数にとり、その要素の合計を要素数で割った平均を整数で返す」は静的な型で表現できそうです。 強い制約を持つ静的な型があれば、プロパティベーステストの意義というのは低いでしょうか。 確かにこの本のターゲットである Erlang, Elixir 共に動的型付言語ですし、Ruby もそうですね。

実際には、プロパティベーステストは Haskell の QuickCheck から発生したもので、強い制約を持つ静的な型があるシステムでも有用です。 私が最初に挙げていた例は、簡素化のために型で表現していましたが、プロパティベーステストは型をチェックする他にも様々な検証方法があります。 検証方法は、本の 3 章「プロパティで考える」を読むと理解が深まります。

強い制約を持つ静的な型があれば、プロパティベーステストの一部は省略できるけれども、プロパティベーステストはより広い範囲の事象を扱うと言えそうです。

まとめ

  • プロパティベーステストを Ruby に適用するとどうなるか、コードを動かしながらお知らせしました
  • プロパティベーステストと似ている既知の概念と、プロパティベーステストはどこが同じで、どこが違うのかお知らせしました

DIGGLE でもプロパティベーステストを導入して役に立つか検証を行う段階です。 Ruby に限らず、フロントエンド側の TypeScript でも始めようとしています。 もしこの記事を読んで興味を持った方がおられれば、一緒にやりませんか。

ご応募をぜひご検討ください。お待ちしています!

herp.careers