ランダムに選出したデータの主キー重複で落ちるSPECを落ちなくする
ランダムに選出したデータの主キー重複で落ちるSPECを落ちなくする
タイトルだけだとナンノコッチャと思われるでしょう。
今回考えるケースRuby on Railsで構築したアプリのこんな例です。
このアプリには、都道府県のデータを格納しているテーブルが有り、
code: bash
% rails g model Prefecture name:string
のように作られています。
このモデルで表現される47都道府県には、それぞれを識別する都道府県IDが割り振られていて、IDがprefecturesテーブルの主キーになっている訳です。
このテーブルは展開してしまえば47個のデータになり、都道府県の統廃合が行われない限りデータに変化もないので、場合によってはテーブルに持たせるまでも無くあまり良いデザインとは言えませんが、既に動いているアプリなので仕方のないものと考えましょう。
さて、ここであるデータモデルが、この都道府県データに紐付いているとします。
例えば、各都道府県にある城のデータモデルが、どの都道府県に存在しているかを表すカラムを持っているようなイメージです。
具体的にモデルを作ってみると、次のような格好になります。
code: bash
% rails g model Castle prefecture_id:integer name:string
こうして作ったCastleモデルは、指定したPrefectureで絞り込むことのできるscopeを持っているとします。
code: ruby
class Castle < ApplicationRecord
belongs_to :prefecture
scope :by_prefecture, ->(prefectures: prefectures) {
where(prefecture_id: prefectures.map(&:id))
}
end
ここまでで、下準備が整いました。
SPECを書く
さて、今回のテーマはこのCastleクラスに対するspecです。
あまり良い例ではありませんが、用意しているスコープが意図している通り動いていることを確認するために「適当なprefecture_idに紐づく城情報が取得できること」というテストを考えてみます。
PrefectureのFactoryは、生成するたびに47都道府県から適当なデータを返してほしいので、次のように組みました。
code: ruby
FactoryBot.define do
factory :prefecture do
sequence(:id) { (1..47).to_a.sample }
name { Faker::Name.name }
end
end
最初にこのコードを組んだプログラマは、適当なデータを抽出するという点に重きを置いていたあまり、都道府県のデータが重複するケースが有ることに気づかなかったようです。
例えば、次のようなCastleのspecを書いてしまうと、一定の確率で主キーが重複するため、ランダムにspecがコケてしまいます。
code: ruby
require 'rails_helper'
RSpec.describe Castle, type: :model do
context '城と県情報のつながりを確認するテスト' do
it '選択した県で絞り込むことができていること' do
prefectures = create_list(:prefecture, 7) # 7都道府県ぐらい、適当に県の情報を作る
castles = Castle.by_prefecture(prefectures: prefectures)
castles.each do |c|
expect(prefectures.include?(c.prefecture_id)).be true
end
end
end
end
この手のランダムに落ちるテストは非常に厄介で、CI等を使っていてコケたときに、ひとたび割れ窓として認識されたとしても、
後から誰かのコミットで落ちないテストに化けてしまう事で、誰も原因を追求せず「流し直せば通る可能性が高い」という悪習が解決策として定着する可能性があります。
これを直すためにはどうすればいいでしょうか?
答えは簡単で、Factoryの側を「既にDBにある場合はDBからデータを引いてきて、DBにない場合だけデータを作るように」してやれば良いです。
これが、今回のタイトルで伝えたかった「やりたいこと」となります。
注意しなければいけない点として、Factory側で戻り値のIDの重複を許容する実装になりますから、このFactoryを使っている側がFactoryから重複した都道府県IDが返ってくることを見越してテストを組まなければいけないという点です。
(「create_listして得られたID列がすべてバラバラの中身である」という仮定のもとテストが書かれていたら、この修正を加えることによってテストが落ちることになるでしょう)
5〜7県程度のデータをランダムに取得するようなケースであれば、重複しないようにテストコードを書くより、重複したらもう一度Prefectureデータを生成し直すようなコードにしたほうが結果としてシンプルになることもありますので、覚えておくと良いでしょう。
(47都道府県から5〜7件程度を抽出するのであれば、何回か引き直せば全部異なるデータが得られる可能性が高いです)
具体的にどう直すか
具体的には、Factoryをこのように修正します。
code: ruby
FactoryBot.define do
factory :prefecture do
sequence(:id) { (1..47).to_a.sample }
name { Faker::Name.name }
# initialize_withを足す
initialize_with do
Prefecture.find_or_initialize_by(id: id)
end
end
end
通常、initialize_withは決まったデータを取得するために使われますが、ここにランダムに選出されたIDを渡してやることで既にそのIDがDBに存在していればDBの値を取得して返し、存在していなければDBに値を登録してから返すようになります。
これで、先の主キー重複で落ちるテストの問題を回避することができるようになりました。
めでたしめでたし。