日頃Railsアプリを書くときはRSpecによるテストコードも一緒に作成しています。テストコードがあることで安心してプロダクションコードを修正できるためです。
そんな中、テストコードの考え方の1つである、満たすべき性質に対して色々な入力を生成して確かめる「プロパティベーステスト」を知りました。
そこで、プロパティベーステストの全体像を把握したいと考え、書籍「実践プロパティベーステスト」を読みました。
実践プロパティベーステスト ― PropErとErlang/Elixirではじめよう – 技術書出版と販売のラムダノート
1章で、今までRSpecで書いてきたテスト(例示ベーステスト、Example-based Testing、EBT)と異なる考え方を知りました。また、5章、6章、10章で具体的なプロパティベーステストの書き方を目にして、具体的にどんな書き方になるのかを学びました。
読み終えてイメージはつかめたので、実際にRubyやRailsで書いてみたくなりました。
Rubyで書く方法を調べてみたところ、以下のGitHubに言語ごとのプロパティベーステストライブラリがまとまっていました。
jmid/pbt-frameworks: An overview of property-based testing functionality
これによるとRubyでは
- PropCheck
- PBT
- Ranty
がありました。
このうち、READMEに
Usage within Rails / with a database
と書かれていてRailsでも使えそうな印象を持った、PropCheck (prop_check) で試してみようと考えました。
ここで、例示ベーステストとプロパティベーステストは併用することが多い認識ですが、プロパティベーステストだけを書くという経験がありません。
そのため、慣れている例示ベーステストを元に、同じ仕様でプロパティベーステストも書いてみたことから、メモを残します。
目次
環境
今回のRailsアプリ
今回のプロパティベーステストを作る対象として、「単価と数量を受け取って合計金額を返す」という注文エンドポイントを持つRailsアプリを用意します。
コントローラでは quantity と unit_price の2つのパラメータを受け取り、フォームオブジェクトでのバリデーション結果によりレスポンスを変更します。
class OrdersController < ApplicationController def create form = ::OrdersForm.new(create_params) if form.invalid? render json: { total: nil, message: form.errors.full_messages } else render json: { total: form.total, message: form.errors.full_messages } end end private def create_params params.permit(:quantity, :unit_price) end end
フォームオブジェクトでは、バリデーションと合計金額の計算を行います。
バリデーションは、 quantity と unit_price に対して次の仕様で行います。
numericality: { greater_than: 0 }- 0より大きい整数であること
integer_inputs_must_be_integer- リクエストパラメータが数値の文字列であること
- この
validateが必要な背景は次の通りattribute :quantity, :integerのように定義しているため、データを受け取ったときに、stringからintegerへの型キャストが行われる"12.34"みたいなパラメータは12に変換される
validatesは12.34を12へ変換した後に動作することから、"12.34"というリクエストパラメータはnumericalityでエラーにならない- そのため、別途このバリデーションを追加している
参考までに、バリデーション integer_inputs_must_be_integer がない時の結果は次のようになります。 5.9 * 2.9 = 17.11 ですが、レスポンスは 10 になっています。
% curl -X POST http://localhost:3000/orders -H "Content-Type: application/json" -d '{"quantity": "5.9", "unit_price": "2.9"}'
{"total":10,"message":[]}
また、合計金額の算出は #total メソッドで実装し、quantity と unit_price の乗算結果を返します。
フォームオブジェクトの全体としてはこんな感じです。
class OrdersForm include ActiveModel::Model include ActiveModel::Attributes attribute :quantity, :integer attribute :unit_price, :integer validates :quantity, numericality: { greater_than: 0 } validates :unit_price, numericality: { greater_than: 0 } # integer型へのキャストで小数が切り捨てられるため、生入力の整数形式を検証する validate :integer_inputs_must_be_integer def total quantity * unit_price end private def integer_inputs_must_be_integer [:quantity, :unit_price].each do |field| # 変更前の値は ActiveModel::AttributeSet型の @attributes に保持される raw_value = @attributes.values_before_type_cast[field.to_s] next if raw_value.blank? next if integer_like_input?(raw_value) errors.add(field, :not_an_integer, value: raw_value) end end def integer_like_input?(value) case value when Integer true when String /\A[+-]?\d+\z/.match?(value.strip) when BigDecimal value.frac.zero? when Numeric (value % 1).zero? else false end end end
例示ベーステストの実装
まずは書き慣れている例示ベーステストにて、テストコードを書いてみます。
RSpecだけで書く場合
quantity と unit_price に対するバリデーションが正しく動作するかを検証するテストコードを書きます。
なお、フォームオブジェクトへ渡ってくる段階では、 quantity と unit_price は両方ともまだ文字列のため、テストデータにも文字列を渡します。
quantity と unit_price で行うことは重複しているため、ここでは quantity のバリデーションだけ記載します。
require 'rails_helper' RSpec.describe OrdersForm do describe 'バリデーション' do subject(:form) { described_class.new(quantity: quantity, unit_price: unit_price) } context 'いずれも正の整数化できる文字列の場合' do let(:quantity) { '2' } let(:unit_price) { '300' } it 'validになり、エラーメッセージも含まれていないこと' do expect(form).to be_valid expect(form.errors.full_messages).to be_empty end end context 'quantityがnilの場合' do let(:quantity) { nil } let(:unit_price) { '300' } it 'quantityが数値ではないというエラーになること' do expect(form).to be_invalid expect(form.errors.full_messages).to include('Quantity is not a number') end end context 'quantityが0の場合' do let(:quantity) { '0' } let(:unit_price) { '300' } it 'quantityが0より大きくなければならないというエラーになること' do expect(form).to be_invalid expect(form.errors.full_messages).to include('Quantity must be greater than 0') end end context 'quantityが負数の場合' do let(:quantity) { '-1' } let(:unit_price) { '300' } it 'quantityが0より大きくなければならないというエラーになること' do expect(form).to be_invalid expect(form.errors.full_messages).to include('Quantity must be greater than 0') end end context 'quantityが小数の文字列の場合' do let(:quantity) { '2.9' } let(:unit_price) { '300' } it 'quantityが整数ではないというエラーになること' do expect(form).to be_invalid expect(form.errors.full_messages).to include('Quantity must be an integer') end end context 'quantityが数値でない文字列の場合' do let(:quantity) { 'a' } let(:unit_price) { '300' } it 'quantityに関するエラーになること' do expect(form).to be_invalid expect(form.errors.full_messages).to include('Quantity must be greater than 0') expect(form.errors.full_messages).to include('Quantity must be an integer') end end end end
rspec-parameterized を使う場合
ここで、 quantity と unit_price に対するテストコードについて、テスト対象は同じなものの入力値と出力値の組み合わせが異なります。
このようなときに使えるのはパラメタライズドテストです。そこで、 rspec-parameterized を使って書いてみます。
https://github.com/tomykaira/rspec-parameterized
なお、ここで取り上げるのも quantity まわりのみです。
describe 'rspec-parameterizedを使ったバリデーション' do subject(:form) { described_class.new(quantity: quantity, unit_price: unit_price) } where do { 'いずれも正の整数化できる文字列の場合' => { quantity: '2', unit_price: '300', expected_valid: true, expected_messages: [] }, 'quantityがnilの場合' => { quantity: nil, unit_price: '300', expected_valid: false, expected_messages: ['Quantity is not a number'] }, 'quantityが0の場合' => { quantity: '0', unit_price: '300', expected_valid: false, expected_messages: ['Quantity must be greater than 0'] }, 'quantityが負数の場合' => { quantity: '-1', unit_price: '300', expected_valid: false, expected_messages: ['Quantity must be greater than 0'] }, 'quantityが小数の文字列の場合' => { quantity: '2.9', unit_price: '300', expected_valid: false, expected_messages: ['Quantity must be an integer'] }, 'quantityが数値でない文字列の場合' => { quantity: 'a', unit_price: '300', expected_valid: false, expected_messages: ['Quantity must be greater than 0', 'Quantity must be an integer'] } } end with_them do it '期待どおりの妥当性とエラーメッセージになること' do expect(form.valid?).to eq(expected_valid) expect(form.errors.full_messages).to contain_exactly(*expected_messages) end end end
prop_checkを使ったプロパティベーステストの実装
次に、prop_check gemを使ってプロパティベーステストを書いてみます。
prop_checkのセットアップ
Gemfileに追加します。テストだけでしか使わないため、 test groupに入れておけば良さそうです。
group :test do gem "prop_check" end
これでセットアップは完了です。
いずれも正の整数化できる文字列の場合
では、実際にテストコードを書いていきます。
まずは、次の例示ベーステストをプロパティベーステストにしてみます。
subject(:form) { described_class.new(quantity: quantity, unit_price: unit_price) } context 'いずれも正の整数化できる文字列の場合' do let(:quantity) { '2' } let(:unit_price) { '300' } it 'validになり、エラーメッセージも含まれていないこと' do expect(form).to be_valid expect(form.errors.full_messages).to be_empty end end
例示ベーステストのテストデータは
let(:quantity) { '2' } let(:unit_price) { '300' }
のように定義しました。
一方、プロパティベーステストではジェネレータを使い、テストデータを定義します。
ここでのテストデータとして適切なのは「正の整数」なことから、prop_checkのジェネレータを確認すると positive_integer がありました。
https://www.rubydoc.info/github/Qqwy/ruby-prop_check/main/PropCheck/Generators#positive_integer-class_method
そこで、 PropCheck::Generators#positive_integer を使ってテストデータを定義します。
テストデータを定義する前に、READMEに従って prop_check を使う describe の中で G という定数を定義します。
PropCheck::Generators が長いのと、 positive_integer() だけだとどこで定義されたメソッドなのか分からないため、G を使うと理解しています。
describe 'prop_checkを使ったプロパティベーステスト' do G = PropCheck::Generators # ... end
ジェネレータを使ってテストデータを定義します。今回、リクエストパラメータは文字列なので、ジェネレータで生成した値を to_s で文字列化します。
https://github.com/Qqwy/ruby-prop_check?tab=readme-ov-file#generatormap
let(:positive_integer_string) { G.positive_integer.map(&:to_s) }
データの準備ができたので、プロパティベーステストで検証するコードを書きます。
テストでは forall ブロックに2つの引数 quantity と unit_price を渡す必要があります。それらの引数は上記で定義したジェネレータ positive_integer_string の値になるため、こんな感じで書きます。
PropCheck.forall(quantity: positive_integer_string, unit_price: positive_integer_string)
次に、 forall ブロックの中でRSpecの expect をするため、ブロック引数を用意します。
READMEによると、ブロック引数は
It takes any number of generators as arguments (or keyword arguments), as well as a block to run.
として定義できそうなので、 quantity と unit_price はキーワード引数にします。
PropCheck.forall(quantity: positive_integer_string, unit_price: positive_integer_string) do |quantity:, unit_price:| # ... end
あとはいつも通りRSpecの expect を書けば完成です。
なお、 described_class.new では、Ruby3.1から導入されたキーワード引数の値の省略記法 (quantity:) を使っています。
Ruby 3.1.0 リリース | Ruby
context 'いずれも正の整数化できる文字列の場合' do it 'validになり、エラーメッセージも含まれていないこと' do PropCheck.forall(quantity: positive_integer_string, unit_price: positive_integer_string) do |quantity:, unit_price:| form = described_class.new(quantity:, unit_price:) expect(form).to be_valid expect(form.errors.full_messages).to be_empty end end end
ちなみに prop_check gemのデフォルトでは、100回の試行と10,000回の収縮(shrink)が自動で行われるようです。
https://www.rubydoc.info/github/Qqwy/ruby-prop_check/main/PropCheck/Property/Configuration
テストを実行すると、いつもと同じようなテスト結果が表示されます。正常終了した場合、試行回数や収縮回数はログに残らないようです。
1 example, 0 failures, 1 passed Finished in 0.021842333 seconds
quantityがnilや0の場合
この場合は、 quantityに対しては prop_check のジェネレータを使わず、 nil や 0 で固定します。
例えば nil の場合のテストコードは次の通りです。
context 'quantityがnilの場合' do it 'quantityが数値ではないというエラーになること' do PropCheck.forall(unit_price: positive_integer_string) do |unit_price:| form = described_class.new(quantity: nil, unit_price:) expect(form).to be_invalid expect(form.errors.full_messages).to include('Quantity is not a number') end end end
quantityが負数の場合
prop_check のジェネレータには負の整数もあるため、それを利用します。
https://www.rubydoc.info/github/Qqwy/ruby-prop_check/main/PropCheck/Generators#negative_integer-class_method
let(:negative_integer_string) { G.negative_integer.map(&:to_s) }
letで定義した negative_integer_string を forall のキーワード引数 quantity へと設定します。
context 'quantityが負数の場合' do it 'quantityが0より大きくなければならないというエラーになること' do PropCheck.forall(quantity: negative_integer_string, unit_price: positive_integer_string) do |quantity:, unit_price:| form = described_class.new(quantity:, unit_price:) expect(form).to be_invalid expect(form.errors.full_messages).to include('Quantity must be greater than 0') end end end
quantityが小数の文字列の場合
ジェネレータには float 系も用意されていますが、今回は今までの positive_integer ジェネレータと、新しく使う tuple ジェネレータを組み合わせて作成します。
https://www.rubydoc.info/github/Qqwy/ruby-prop_check/main/PropCheck/Generators#tuple-class_method
let(:positive_decimal_string) { G.tuple(G.positive_integer, G.positive_integer).map { |i, f| "#{i}.#{f}" } }
使い方は今までと同じで、 forall のキーワード引数 quantity へ設定します。
context 'quantityが小数の文字列の場合' do it 'quantityが整数ではないというエラーになること' do PropCheck.forall(quantity: positive_decimal_string, unit_price: positive_integer_string) do |quantity:, unit_price:| form = described_class.new(quantity:, unit_price:) expect(form).to be_invalid expect(form.errors.full_messages).to include('Quantity must be an integer') end end end
quantityが数値でない文字列の場合
例示ベーステストのテストケースに考慮漏れがあったため、そのまま移植しようとして失敗しました。
ここでは失敗例と、失敗への対応例を見ていきます。
失敗するテストコード
prop_checkのジェネレータを調べたところ、 printable_string で文字列を生成し、 where で数値を除外すれば良さそうでした。
printable_string- Generates a printable string both ASCII characters and Unicode.
where- Creates a new Generator that only produces a value when the block condition returns a truthy value.
letの定義はこんな感じです。
let(:non_numeric_string) { G.printable_string.where { |s| s !~ /\A[+-]?\d+(\.\d+)?\z/ } }
non_numeric_string の使い方は今までと同じで、 quantity に設定します。
context 'quantityが数値でない文字列の場合' do it 'quantityに関するエラーになること' do PropCheck.forall(quantity: non_numeric_string, unit_price: positive_integer_string) do |quantity:, unit_price:| form = described_class.new(quantity:, unit_price:) expect(form).to be_invalid expect(form.errors.full_messages).to include('Quantity must be greater than 0') expect(form.errors.full_messages).to include('Quantity must be an integer') end end end
テストを実行すると、テストが失敗しました。 form.errors.full_messages の結果が ["Quantity is not a number"] だけになっているようです。
0) OrdersForm prop_checkを使ったプロパティベーステスト バリデーション 例示ベースと1:1で対応する書き方 quantityが数値でない文字列の場合 quantityに関するエラーになること
Failure/Error: expect(form.errors.full_messages).to include('Quantity must be greater than 0')
(after 43 successful property test runs)
Failed on:
`{quantity: "", unit_price: "7"}
`
Exception message:
---
expected ["Quantity is not a number"] to include "Quantity must be greater than 0"
---
Shrunken input (after 11 shrink steps):
`{quantity: "", unit_price: "1"}
`
Shrunken exception:
---
expected ["Quantity is not a number"] to include "Quantity must be greater than 0"
---
# ./spec/forms/orders_form_spec.rb:285:in 'block (7 levels) in <top (required)>'
# ./spec/forms/orders_form_spec.rb:281:in 'block (6 levels) in <top (required)>'
どのようなテストデータのときに発生したのかについては、RSpecの実行ログに
(after 43 successful property test runs)
Failed on:
`{quantity: "", unit_price: "7"}
`
と
Shrunken input (after 11 shrink steps):
`{quantity: "", unit_price: "1"}
`
Shrunken exception:
が残されていました。
空文字列 ("") のときに条件を満たさなかったようです。
ここで、 OrdersForm の実装を見返してみます。
class OrdersForm include ActiveModel::Model include ActiveModel::Attributes attribute :quantity, :integer # ... end
ここで、 attribute :quantity, :integer のように、typeを :integer とすると、空文字が来た場合は nil へと変換されます。
https://api.rubyonrails.org/classes/ActiveModel/Type/Integer.html
そのため、テストが落ちてしまったと考えられました。
例示ベーステストでは「空文字の時」というテストコードがなかったので気づけませんでした。しかし、プロパティベーステストでは自動的に「空文字の時」という条件が追加されたため気づけました。
プロダクションコードではなくテストコードの不備ではありますが、以降でテストコードを追加していきます。
quantityが空文字の場合
quantityが nil と同じパターンになります。
context 'quantityが空文字の場合' do it 'quantityが空文字からnilへ変換され、エラーとなること' do PropCheck.forall(unit_price: positive_integer_string) do |unit_price:| form = described_class.new(quantity: '', unit_price:) expect(form).to be_invalid expect(form.errors.full_messages).to include('Quantity is not a number') end end end
quantityが数値や空文字以外の場合
今までの
let(:non_numeric_string) { G.printable_string.where { |s| s !~ /\A[+-]?\d+(\.\d+)?\z/ } }
で生成される値には空文字も含まれていたため、これを修正する必要があります。
where メソッドのブロックへ条件を追加することもできますが、今回は printable_string ジェネレータに空文字以外を生成するようなオプションを渡すことで解決できそうです。
オプションとして何を渡せるかについて見たところ、 printable_string のドキュメントでは
Accepts the same options as
array
と書かれていました。
また、実装を見ると
# https://github.com/Qqwy/ruby-prop_check/blob/155a3278cfae619edba244eaf0986e0771cb64f2/lib/prop_check/generators.rb#L613-L615 def printable_string(**kwargs) array(printable_char, **kwargs).map(&:join) end
と、 array でラップしていました。
この array ですが、RubyのArrayではなく、 prop_check の array ジェネレータのことです。
https://www.rubydoc.info/github/Qqwy/ruby-prop_check/main/PropCheck/Generators#array-class_method
これより、 array ジェネレータを見ると、 min か empty あたりを使うことで空文字の生成を抑えられそうでした。
ドキュメントには
empty:When false, behaves the same as ‘min: 1
とあったため、今回は empty オプションを使うことにしました。
let(:non_numeric_string_without_empty_string) { G.printable_string(empty: false).where { |s| s !~ /\A[+-]?\d+(\.\d+)?\z/ } }
あとは non_numeric_string_without_empty_string を quantity へ渡すだけです。
context 'quantityが数値・空文字以外の文字列の場合' do it 'quantityに関するエラーになること' do PropCheck.forall(quantity: non_numeric_string_without_empty_string, unit_price: positive_integer_string) do |quantity:, unit_price:| form = described_class.new(quantity:, unit_price:) expect(form).to be_invalid expect(form.errors.full_messages).to include('Quantity must be greater than 0') expect(form.errors.full_messages).to include('Quantity must be an integer') end end end
この後、テストを再実行すると、すべてのテストがパスしました。
参考資料
- 実践プロパティベーステスト ― PropErとErlang/Elixirではじめよう – 技術書出版と販売のラムダノート
- Railsでプロパティベーステスト入門:PropCheckとpbtで堅牢なテストを書く
- プロパティベーステスト (Property Based Testing) を Ruby で書き雰囲気を味わう - DIGGLE開発者ブログ
ソースコード
GitHubに上げました。
https://github.com/thinkAmi-sandbox/rails_pbt_with_prop_check-example
prop_check gemを使ってテストを書いたプルリクはこちら。
https://github.com/thinkAmi-sandbox/rails_pbt_with_prop_check-example/pull/1