前回まではspecの書き方の話でした。今回はデータを作る側です。
テストデータの扱い方は、実行速度に直結します。そしてlet_it_beのような仕組みを使うと、テスト間でデータが汚染される問題も出てきます。

create / build / build_stubbed

FactoryBotには3つの生成方法があります。

  • create — 保存する。idが振られる
  • build — 保存しない。idはnil
  • build_stubbed — 保存しない。ダミーのidが振られる

保存が必要ならcreate、保存されている必要がないならbuild、ダミーのidが必要ならbuild_stubbedを使います。 保存しない方が速いので、必要がなければbuildです。

buildbuild_stubbedはどちらも保存しないので、この2つに速度の差はほとんどありません。使い分けているのは、build_stubbedダミーのidを振るからです。関連を辿る実装があると、存在しないレコードを参照してしまう。idがnilなら、そこで素直に落ちるか、そもそも辿りません。

build_stubbedが要るのは、存在しないidが欲しい時や、request specでvalidなパラメータを組み立てたい時です。404の検証でDBにないidを渡す、保存せずに属性一式を作ってリクエストに載せる、といった場面になります。

保存が必要になるのは、

  • validates_uniqueness_ofのようにDBを見るバリデーション
  • 関連先の集計
  • 永続化後の挙動(コールバック、再取得)

たとえば重複のケースはcreateが要ります。

context '重複' do
  let(:code) { valid_code }
  before { create(:invitation, code:) }

  it_behaves_like 'Invalid'
end

DBにレコードがないと一意性のチェックが働きません。

一方、文字数や形式のバリデーションならDBは不要なのでbuildで足ります。最初からcreateにすると、その分だけ遅くなります。

なおbuildが活きるのは、model specやhelper specのようにDBを介さない検証が中心の場合です。request specは大半がDB前提なので、let_it_becreateすることになります。

コールバックは意識が要る

after_createのようなコールバックは、buildでは走りません。コールバックの結果を検証したいならcreateが要ります。

逆にcreateすると、意図しない副作用まで起きます。関連レコードが作られる、ジョブが積まれる。factoryで1件作っただけで何かが動いていても、気づきにくい。

そもそもコールバックは動きが読めなくなるので、あまり使いたくないと思っています。specの側でも、何が走るかを意識することになります。

関連先も呼び出し側の戦略に従う

factoryでassociationを使っている場合、関連先の作られ方は呼び出し側に引きずられます

create(:space)         # created_userもcreate
build(:space)          # created_userもbuild。INSERTなし
build_stubbed(:space)  # created_userもbuild_stubbed

FactoryBot 5からのデフォルトの挙動です。use_parent_strategyをfalseにすると昔の挙動(常にcreate)に戻せますが、buildでも関連がINSERTされるようになるので、推奨しません。

factoryを見ても「どう作られるか」は書かれていないので、この挙動は知っておく必要があります。

関連先の値が必要なら、そこはcreate

buildでも、関連先の値を参照するだけなら問題ありません。

ただし関連先のidが必要な場合は、そこだけcreateします。

let_it_be(:created_user) { create(:user) }
let(:space) { build(:space, created_user:) }

has_manyを辿って集計する場合は、space自体がDBにないと関連を引けないので、そちらもcreateが必要になります。

let / let! / let_it_be

最初はlet!を多用していました。前提が確実に用意される安心感があったからです。

遅延評価を理解してから、参照しないなら作られないことに気づきました。検証で参照するなら、その時点で作られる。参照しないなら、そもそも要らなかった。

そこからlet!が減って、残ったものの多くは「変更しない前提データ」だったので、let_it_beに置き換わりました。速度も上がります。

なおlet_it_bebefore_allはRSpec標準ではなく、test-profというgemの機能です。

今使い分けはこうなっています。

  • let_it_be — 変わらない前提データ。ブロック全体で1回だけ作られる
  • let — 値として使うもの。参照された時に評価される
  • let! — 評価のタイミングそのものが検証内容になるもの
  • before — 実行するだけで、戻り値を使わないもの。itごとに必要なsign_inやmockの設定
  • before_all — 同じく戻り値を使わないが、1回でいいもの。前提レコードの作成

buildbuild_stubbedは値なのでletで足ります。createは前提データを用意するものなので、subjectより前に作られている必要があります。戻り値を使うならlet!let_it_be、使わないならbeforebefore_allに置きます。

letにはもう1つ用途があります。subjectの後に評価したいものです。

let(:current_space) { Space.find(space.id) }

4回目の記事に出てきた、更新後のレコードを取るものです。これをlet!にするとsubjectの前に評価されて、更新前を掴んでしまいます。

let_it_beはcreateだけのものではない

itごとに繰り返す必要がないものは、全部let_it_beの対象です。ブロック全体で1回しか評価されないので、itの数だけ実行される分がなくなります

let_it_be(:image_file) { File.binread(TEST_IMAGE_FILE) }
let_it_be(:expect_result) { HeavyCalculator.call(...) }

fixtureのファイル読み込みや、重い計算。レコードを作るかどうかだけではありません。

参照順の制約

let_it_beの中から参照できるのは、先に定義されたlet_it_beだけです。before_allからも同じものを参照できますが、letは参照できません。

let(:power) { :admin }
let_it_be(:member) { create(:member, power) }  # これは動かない

let_it_bebefore(:all)相当のタイミングで評価されるので、itごとに評価されるletを参照できません。

逆に、let_it_be同士なら参照できます。

let_it_be(:power) { :admin }
let_it_be(:member) { create(:member, power) }

powerは値なので本来はletで足りますが、let_it_beから参照するにはlet_it_beにする必要があります。1回しか使わないならlet!beforeで書いてもいいのですが、後で参照する可能性を考えてlet_it_beに寄せておく、という判断もあります。ここは意見が割れるところだと思います。

let!に逃げなくても書ける

参照順の制約を理由に、let!を使っているケースがあります。

let!(:member) { create(:member, power) }

context '管理者' do
  let(:power) { :admin }
  ...
end
context '閲覧者' do
  let(:power) { :reader }
  ...
end

ただ、contextごとにlet_it_beを組み立て直せば対応できます

context '管理者' do
  let_it_be(:member) { create(:member, :admin) }
  ...
end
context '閲覧者' do
  let_it_be(:member) { create(:member, :reader) }
  ...
end

パラメータをletで外から渡すのではなく、contextの中で完結させる形です。上位でデフォルトを定義して差分だけ上書きする書き方とは形が変わりますが、let_it_beが使えない場面は無いはずだと思っています。

冗長ならshared_contextにまとめる

shared_context 'メンバー作成' do |power|
  let_it_be(:member) { create(:member, power) }
end

context '管理者' do
  include_context 'メンバー作成', :admin
  ...
end
context '閲覧者' do
  include_context 'メンバー作成', :reader
  ...
end

引数で挙動が確定するので、呼び出し側から中身を見に行く必要もありません。

上書きすると両方作られる

letとの違いでもう1つ大きいのが、上書きの挙動です。

let_it_be(:space) { create(:space, :public) }

context '非公開' do
  let_it_be(:space) { create(:space, :private) }  # 2件できる
end

外側のlet_it_beは既に実行済みなので、内側で上書きしても外側の分は消えません。結果として2件作られます。

値を返すだけなら問題ありません。参照すれば内側の値が使われます。createのように副作用があるものだけ、上書きが効かない形になります。

letなら参照時に評価されるので、createでも上書きすれば内側だけが使われます。同じ感覚で書くと事故ります。

let!letで上書きしても同じです。外側のlet_it_beは既に実行されているので、2レコードできます。参照すると内側の値が返るので、もう1件は名前で取れないまま残ります。

一覧の件数を検証していると、意図しない件数になって落ちる。あるいは気づかないまま通ってしまう。

2回目の記事に書いた「上位にデフォルトを置いて、contextで差分を上書きする」書き方は、let_it_beでは使えません。contextごとに作り分けるなら、外側には置かないことになります。

let!が残るのは時点の固定だけ

let!で残っているのは、subjectの実行前に現在日時を取るケースくらいです。

let!(:start_time) { Time.current }

4回目の記事に出てきたbe_betweenの下限に使うものです。letだとsubject実行後に評価されて意味がなくなるので、ここだけはlet!が必要になります。

!が誤解を招く

Rubyだと!は「破壊的」か「例外を投げる」の印です。save!sort!find_by!。どれも通常版があって、その危険な方、という関係になっています。

let!はそのどちらでもありません。通常版との違いは評価のタイミングだけで、危険度は変わらない。

この命名のせいで、「let!の方が確実そう」と思って選んでしまうことがあると思います。savesave!の連想が働く。自分が最初に多用していたのも、たぶんこれです。

全部let!にしても動いてしまう

let!を多用しても動きます。だから困らないし、問題も見えません。

ただ、そうするとletの遅延評価が死にます。使わないケースでも作られるので、ケースが増えるほど無駄が積み上がる。「これは前提なのか値なのか」という区別もコードから消えます。

そしてレビューのたびに一瞬躊躇します。!が付いていれば何か意味があるはずだと思うのに、実際は「先に評価する」だけ。全部に付いていると、その一瞬が毎回発生します。

何より、本当にsubjectの前に評価しなければいけないものが分からなくなりますstart_timeのようにタイミングが検証内容そのものになっているものが、他のlet!に埋もれる。!は注意して見る印なので、意味のないものが並んでいると注意が分散します。

let_it_beの汚染

let_it_beは全てのitで同じインスタンスを共有します。DBはトランザクションで巻き戻りますが、メモリ上のオブジェクトは巻き戻りません

検証でreloadすると汚染される

更新を検証する時、reloadで最新の値を取りたくなります。

expect(space.reload).to have_attributes(...)

これはlet_it_beのインスタンスを更新後の状態に書き換えます。次のitが更新後の値を持ったまま始まります。

別インスタンスとして取り直せば汚しません。

let(:current_space) { Space.find(space.id) }

4回目の記事have_attributesのサンプルでcurrent_spaceを使っていたのは、この理由もあります。更新前のspaceを土台にして、更新後のcurrent_spaceと比較する。両方が必要なので、reloadでは成り立ちません。

原則として、let_it_beを使うならreloadは使いません。破壊的にインスタンスを書き換えるからです。

ロジック側で汚染される

もう1つ、自分でreloadしなくても汚れるケースがあります。

before { sign_in user }

コントローラ側でcurrent_userを更新すると、その変更がlet_it_beのインスタンスに乗ります。次のitが汚れた状態から始まる。

こちらはreloadで解決します。

before { sign_in user.reload }

毎回DBから読み直せば、前のitで汚れた属性が消えます。let_it_be(:user, reload: true)でも同じことができます。

汚れたものを意図的にリセットする場合が、reloadを使う唯一の例外です。

ただしreloadは呼べば必ずSELECTが走るので、更新が走らないspecには要りません。汚染が起きるところだけに付けます。

それでもlet!より速い

reloadを足してもなお、let!で毎回INSERTするよりINSERT 1回 + SELECT複数回の方が速い。SELECTはINSERTよりずっと安いので、ケースが増えるほど差が開きます。

model specでは起きにくい

model specはそもそもbuildで足りることが多いので、この問題が起きにくい。DBに入れなければ汚染もありません。

specでsave!やupdate!を使わない

前節のreloadと同じ話で、specで破壊的な操作をすると他のケースに影響が出ます。最初からその状態で作る方が安全です。

createしたレコードをcontextごとに書き換えているコードを見かけます。

let(:download) { create(:download) }
before { download.update!(status: :failure) }

これは避けたいところです。

INSERT + UPDATEで2クエリ走ります。最初からcreate(:download, :failure)にすれば1クエリで済む。ケース数だけ積み上がります。

レビューの面でも、createupdate!が離れていると、最終的な状態を掴むのに両方を追うことになります。変更を見落とせば、誤った前提で読んでしまう。1行で完結していれば、そこだけ見れば済みます。

コールバックも2回走ります。after_createafter_updateの両方。実際の挙動と違う状態になる。

そしてupdated_atが現在時刻に更新されます。次回書くタイムスタンプの仕組みと衝突して、他のレコードとの前後関係が壊れます。並び順の検証が意図しない結果になる。

例外は循環参照

相互に参照する設計になっていて、片方を作らないともう片方が作れない場合は、createしてからupdateするしかありません。会社が「代表サービス」を持ち、サービスが「所属会社」を持つような、1対1の相互参照です。どちらを先に作っても、もう片方のidが決まっていません。

これは避けようがないので例外として扱っています。逆に言うと、それ以外に例外はありません。

まとめ

  • 保存が必要ならcreate、保存されている必要がないならbuild、ダミーのidが必要ならbuild_stubbed
  • 関連先の作られ方は呼び出し側の戦略に従う
  • let_it_beは変わらない前提データ、letは値、let!は評価タイミングが検証内容になるものだけ
  • build系はletcreatelet!/before(またはlet_it_be/before_all)に置く
  • subjectの後に評価したいものもletlet!にすると実行前を掴む
  • let_it_beはcreate以外にも使える。ファイル読み込みや重い計算も対象
  • buildではafter_createが走らない。createすると意図しない副作用も起きる
  • let_it_beからletは参照できないが、contextごとに組み立てれば対応できる
  • let_it_beは上書きしても外側が消えない。デフォルトを置いて差分で上書きする書き方は使えない
  • let!を多用すると、本当にタイミングが重要なものが埋もれる
  • let_it_beを使うならreloadは使わない。取り直すなら別インスタンスにする
  • 例外はロジック側で汚れたものをリセットする場合(sign_in user.reload
  • specでsave! / update!は使わない。最初からその状態で作る

次回は、factoryの設計を書きます。何を埋めて何を埋めないか、そして揃いすぎたデータが何を見逃すかの話です。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です