Ruby で Property-Based Testing を試してみる

:::note info
この記事は、[LITALICO Engineers Advent Calender 2023](https://qiita.com/advent-calendar/2023/litalico) シリーズ2の14日目の記事です。前日は、@s_yk の[「中途入社者向け共通オンボーディングの感想」](https://qiita.com/s_yk/items/9ccddc747473f887cdde)でした。
:::

# はじめに

LITALICO プロダクトエンジニアリング（PE）部の片桐英人（かたぎり えいと）です。LITALICO EngineersAdvent Calendar 2023 に参加するのは2回目です。前回は、[Rails アプリケーションでパスキー認証を実装してみる](https://qiita.com/eito_katagiri-litalico/items/ce4be95cb27bc37044d3) という記事を書きました。

テストを書いていて悩むことがあります。例えば、「文字列を入力として受け取り、その文字列が4文字以上8文字以下で、全ての文字が半角の英数字で、大文字、子文字、そして、数字をそれぞれ1文字以上含む場合に `true` を返す。それ以外は、`false` を返す。」というメソッドのテストを書く場合にどのようなテストケースを準備すれば良いのかということについてです。

`0aAB` とか `0000aaAA` とかの正常なケースと、空文字とか、`000`、`0000aaAA1`、そして、`0000` などの異常なケースを境界値付近を意識して準備するのは、すぐに思いつきます。ただ、例えば、正常なケースで `aaaaAA00` とか `AAAA00aa`、そして、異常なケースで、`aaa`、`0aA`、もしくは、`aaaaaaaaa` などは、必要ないのかと考えてしまいます。現状は、すぐに思いつくケースでしかテストを書いていません。

何か良い方法はないかなと調査していたら、「Property-Based Testing（PBT）」という言葉に遭遇しました。何となく良さそうだったので、調査して、Ruby（RSpec）で、PBT を試すことにしました。

# Property-Based Tesing とは？

Property-Based Testing は、プロパティと呼ばれる事前条件（入力）と期待される特性（出力）の全ての組み合わせから、事前条件をランダムに、かつ、大量に生成して、出力を確認することでテストする手法です。[^1] [^2] [^3]

[^1]: https://en.wikipedia.org/wiki/Software_testing#Property_testing
[^2]: https://www.infoq.com/jp/news/2023/08/property-based-testing/
[^3]: https://blogs.oracle.com/otnjp/post/know-for-sure-with-property-based-testing-ja

`expect(fn('sample')).to be_truthy` のように具体的な入力から、出力が期待通りかを確認する Example-Based Testing と比較して、少ない行数で色々なケースを確認することができるとこが利点です。

[Project FIFO](https://docs.project-fifo.net/docs) では、460行ほどの QuickCheck でのテストで、6万行ほどのコードを網羅して、タイミングや競合状態によるエラーなど25の重要な不具合を発見したそうです。[^4]

[^4]: https://www.erlang-factory.com/static/upload/media/1461230674757746pbterlangfactorypptx.pdf

大量のケースを生成してテストするために時間がかかってしまうことがあることが欠点です。

# rantly

今回は、[rantly](https://github.com/rantly-rb/rantly) を使用してみることにしました。[RubyGems.org](https://rubygems.org/) で、"quickcheck" や "property based testing" で検索するとダウンロード数が多いので、この gem を使うことにしました。

# rantly を使ってみる

## テスト対象のコード

今回は、前記した以下のような仕様のメソッドを対象とします。

> 文字列を入力として受け取り、その文字列が4文字以上8文字以下で、全ての文字が半角の英数字で、大文字、子文字、そして、数字をそれぞれ1文字以上含む場合に `true` を返す。それ以外は、`false` を返す。

ruby で 実装すると以下のようになると思います。（`Sample#check` メソッド）

```ruby
class Sample
  def check(str)
    /\A(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{4,8}\z/.match?(str)
  end
end
```

## Example-Based Tesing の場合

RSpec で、Example-Base Testing と以下のようになると思います。今回は、正常系と異常系を1つの example で記述しています。

```ruby
RSpec.describe Sample do
  let(:sut) { described_class.new }

  describe '#check' do
    it 'checks' do
      expect(sut.check('0aAB')).to be_truthy
      expect(sut.check('0000aaAA')).to be_truthy

      expect(sut.check('')).to be_falsy
      expect(sut.check('000')).to be_falsy
      expect(sut.check('0000aaAA1')).to be_falsy
    end
  end
end
```

## Property-Based Testing の場合

### 正常系

正常系を　Property-Based Testing で書いてみます。以下が、その結果です。

```ruby
RSpec.describe Sample do
  describe '#check' do
    let(:sut) { Sample.new }

    it '正常系' do
      property_of {
        [
          sized(1) { string(:digit) },
          sized(1) { string(:lower) },
          sized(1) { string(:upper) },
          sized(range(1, 5)) { string(:alnum) }
        ].shuffle.join
      }.check { |str|
        expect(sut.check(str)).to be_truthy
      }
    end
  end
end

```

`property_of { ... }.check { ... }` は、rantly による拡張（`rantly/rspec_extensions`）です。`property_of` のブロック内では、事前条件を生成します。`sized(1) { string(...) }` では、長さが 1 の文字列を生成します。`:digit`、`:lower`、`:upper`　で生成する文字列を指定していて、それぞれ、数字、英子文字、英大文字になります。`sized(rand(1, 5)) { string(:alnum) }` では、長さが 1 から 5 の間の数字と英子・大文字からなる文字列を生成します。これらを `shuffle` で、混ぜて、`join` で1つの文字列にしています。これにより、仕様にあう文字列を作成しています。

生成される文字列は、`krNUUDx3`、`vvMCUlT7`、`q9Gbsp`、`bEfM7`、`6LYc` のように生成されます。

`check { ... }` では、`property_of` で生成される文字列を受け取り、繰り返します（デフォルトでは、100回）。

### 異常系

異常系のケースは、「3文字以下」と「9文字以上」の2ケースを以下のように作成しました。

```ruby
...
    it '異常系 3文字以下' do
      property_of {
        sized(range(0, 3)) { string(:alnum) }
      }.check { |str|
        expect(sut.check(str)).to be_falsy
      }
    end

    it '異常系 9文字以上' do
      property_of {
        sized(range(9, 128)) { string(:alnum) }
      }.check { |str|
        expect(sut.check(str)).to be_falsy
      }
    end
...
```

### 実行例

実行すると以下のようになります。何回か繰り返し実行してみましたが、全て成功しました。今回のテスト対象のメソッドは単純なためだとは思います。今回、生成されている文字列をみると今までのように

```
Sample
  #check

..........
SUCCESS - 100 successful tests
    正常系

..........
SUCCESS - 100 successful tests
    異常系 3文字以下

..........
SUCCESS - 100 successful tests
    異常系 9文字以上

Finished in 0.00532 seconds (files took 0.05139 seconds to load)
3 examples, 0 failures
```

元の実装を以下のように変更して、小文字は、`a` もしくは `z` が1文字入っていれば良いようにしてみます。[ここ](https://qiita.com/drafts/62dffc9de8ba4ffa6881/edit#example-based-tesing-%E3%81%AE%E5%A0%B4%E5%90%88) で例示した Example-Base Testing 方式での、正常系では、英子文字に `a` しか使っていないので、失敗することはないです。

```
...
  def check(str)
    /\A(?=.*\d)(?=.*[az])(?=.*[A-Z]).{4,8}\z/.match?(str)
  end
...
```

テストを実行すると以下のように失敗します。

```
Sample
  #check

FAILURE - 0 successful tests, failed on:
"1iDAH"
    正常系 (FAILED - 1)

..........
SUCCESS - 100 successful tests
    異常系 3文字以下

..........
SUCCESS - 100 successful tests
    異常系 9文字以上

Failures:

  1) Sample#check 正常系
     Failure/Error: expect(sut.check(str)).to be_truthy

       0 successful tests, failed on:
       1iDAH

       expected: truthy value
            got: nil
     # ./spec/sample_spec.rb:18:in `block (4 levels) in <top (required)>'
     # ./spec/sample_spec.rb:17:in `block (3 levels) in <top (required)>'
     # ------------------
     # --- Caused by: ---
     #   expected: truthy value
     #        got: nil
     #   ./spec/sample_spec.rb:18:in `block (4 levels) in <top (required)>'

Finished in 0.00773 seconds (files took 0.04253 seconds to load)
3 examples, 1 failure
```

# さいごに

rantly を使って、RSpec で、Propery-Based Testing 方式のテストを書くことができることがわかりました。Example-Base Testing 方式よりは、指定した条件下で多様かつ大量の値を生成して、入力とすることで、対象の実装の不備が見つけやすくなりそうな点は良さそうだと思いました。ただ、正しく条件にあうように値を生成できていないと誤ってテストが失敗することがあるので、注意が必要だと思いました。

Propery-Based Testing と Example-Based Testing 方式のどちらかではなく、両方を使い分けることで、効果的なテストが作成できると思いました。

明日の15日目は、@enochan　の「コンテンツ制作にかかわる多職種でワークショップしてみた話」です。お楽しみに！