この記事は、LITALICO Engineers Advent Calender 2023 シリーズ2の14日目の記事です。前日は、@s_yk の「中途入社者向け共通オンボーディングの感想」でした。
はじめに
LITALICO プロダクトエンジニアリング(PE)部の片桐英人(かたぎり えいと)です。LITALICO EngineersAdvent Calendar 2023 に参加するのは2回目です。前回は、Rails アプリケーションでパスキー認証を実装してみる という記事を書きました。
テストを書いていて悩むことがあります。例えば、「文字列を入力として受け取り、その文字列が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
expect(fn('sample')).to be_truthy
のように具体的な入力から、出力が期待通りかを確認する Example-Based Testing と比較して、少ない行数で色々なケースを確認することができるとこが利点です。
Project FIFO では、460行ほどの QuickCheck でのテストで、6万行ほどのコードを網羅して、タイミングや競合状態によるエラーなど25の重要な不具合を発見したそうです。4
大量のケースを生成してテストするために時間がかかってしまうことがあることが欠点です。
rantly
今回は、rantly を使用してみることにしました。RubyGems.org で、"quickcheck" や "property based testing" で検索するとダウンロード数が多いので、この gem を使うことにしました。
rantly を使ってみる
テスト対象のコード
今回は、前記した以下のような仕様のメソッドを対象とします。
文字列を入力として受け取り、その文字列が4文字以上8文字以下で、全ての文字が半角の英数字で、大文字、子文字、そして、数字をそれぞれ1文字以上含む場合に
true
を返す。それ以外は、false
を返す。
ruby で 実装すると以下のようになると思います。(Sample#check
メソッド)
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 で記述しています。
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 で書いてみます。以下が、その結果です。
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ケースを以下のように作成しました。
...
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文字入っていれば良いようにしてみます。ここ で例示した 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 の「コンテンツ制作にかかわる多職種でワークショップしてみた話」です。お楽しみに!
-
https://en.wikipedia.org/wiki/Software_testing#Property_testing ↩
-
https://www.infoq.com/jp/news/2023/08/property-based-testing/ ↩
-
https://blogs.oracle.com/otnjp/post/know-for-sure-with-property-based-testing-ja ↩
-
https://www.erlang-factory.com/static/upload/media/1461230674757746pbterlangfactorypptx.pdf ↩