前回は、長いspecが辛いのは追加とレビューの時だという話と、ファイル・describe・contextの分け方を書きました。
今回はその中身です。1つのdescribeの中を、どういう順序で書いて、どこまで共通化するか。
目的は前回と同じで、読む量と読む場所を減らすことです。
- 並び順にはある程度の制約がある
- 並び順
- 共通化1:letでデフォルトを定義する
- 共通化2:shared_examplesとshared_context
- どこに置くか
- specは冗長でもいい、の範囲
- まとめ
並び順にはある程度の制約がある
先に制約の話をします。
shared_examplesやshared_contextは、使う前に定義されている必要があります。it_behaves_likeより下に書くとNameErrorになる。
letやsubjectはメソッド定義なので順序の制約はありませんが、内側のcontextで上書きする形になるので、外側にデフォルトを置くのが自然です。
describe 'GET #index' do
subject { get spaces_path(format: subject_format), params: }
let(:subject_format) { nil }
let(:params) { {} }
describe '検索' do
let(:subject_format) { :json }
context '部分一致' do
let(:params) { { text: 'aaa' } } # 上書きする
end
end
end
つまり並び順にはある程度の必然性があります。その上で、読みやすさの観点を足していきます。
並び順
describe '対象' do
subject { ... } # 検証対象
let(:...) { ... } # パラメータのデフォルト値
describe '観点' do
include_context '...' # 前提条件参照(support)
shared_context '...' do # 前提条件
...
end
let_it_be(:...) { ... } # 共通のテストデータ
before_all { ... } # 関連データの作成など
let(:...) { ... } # 検証で使うデータ(共通)
shared_examples '...' do # 検証内容
let(:...) { ... } # 検証で使うデータ
it '...' do
is_expected.to eq(...)
...
end
end
context '条件' do # テストケース
let(:...) { ... } # デフォルトからの差分
include_context '...' # 前提条件参照
it_behaves_like '...' # 検証内容参照
end
end
end
検証対象 → 前提の準備 → 検証内容 → テストケースの順です。上から読めば理解が積み上がる形になります。
なお、subjectから「検証で使うデータ」までは、どの順で書いても動作は変わりません。RuboCopのデフォルトルールではletをまとめるよう指摘されるので、RSpec/ScatteredLetやRSpec/LetBeforeExamplesのようなcopは無効にする必要があります。それでも役割ごとに分けて並べた方が、何が前提で何が検証用のデータかが読めます。
itやit_behaves_likeの上には空行を入れています。実行の前に一呼吸置く感じで、どこまでが準備でどこからが検証かが読めます。
コメントと実装は隣接させる
前回書いたテストパターンのコメントは、それを実現するコードのすぐ上に置きます。
describe 'GET #index' do
subject { get spaces_path(format: subject_format), params:, headers: accept_headers }
# 前提条件
# URLの拡張子が.json, AcceptヘッダにJSONが含まれる, ログイン中
# テストパターン
# タイトル: 部分一致(大文字・小文字を区別しない), 不一致
describe '検索' do
let(:subject_format) { :json }
let(:accept_headers) { ACCEPT_INC_JSON }
include_context 'ログイン処理'
「前提条件」に書いたものが、直下のletとinclude_contextで実装されています。
離れていると、どちらかを直した時に片方が取り残されます。隣接していれば同時に目に入る。コメントは実行されないので落ちませんが、距離を近づけることで乖離しにくくなります。
contextの中も同じです。コメントで宣言した条件を、context名で受けて、直下のletで実装します。describeとsubjectも同様です。
context '部分一致' do
let(:params) { { text: 'aaa' } }
コメントで宣言した部分一致がcontext名になり、その実装が直下のletに来ます。宣言と実装がズレていれば、その場で気づけます。
共通化1:letでデフォルトを定義する
同じ値を各contextで書くと、何が違うのかが読み取れなくなります。
上位でデフォルトを定義して、contextでは変えるものだけを上書きする。
subject { get spaces_path(page: subject_page, format: subject_format), params:, headers: ... }
let(:subject_page) { 1 }
let(:params) { {} }
context 'ページが2' do
let(:subject_page) { 2 } # これだけが違う
end
contextを読めば、そのケースが何を変えているかが1行で分かります。
1回しか使わなくてもletに出す
itの中で代入しているコードを見かけますが、letに出した方がいいと考えています。
# 避けたい
it '更新される' do
expect(Space.find(space.id)).to have_attributes(...)
end
# これも避けたい
it '更新される' do
current_space = Space.find(space.id)
expect(current_space).to have_attributes(...)
end
# こう書く
let(:current_space) { Space.find(space.id) }
it '更新される' do
expect(current_space).to have_attributes(...)
end
理由は2つあります。
名前が意図を説明する。current_spaceという名前があれば「更新後のスペース」だと読めます。Space.find(space.id)だけだと、なぜ再取得しているのか読み手が推測することになります。
itが検証だけになる。準備と検証が混ざらない。
RSpecは内部DSL(ホスト言語の構文のまま、英語の文のように読ませる記法)として設計されています。expect(...)も文として読めた方がよくて、主語の位置に式が入ると崩れます。expect(current_space)なら名詞が入るので読めます。
なお、これは共通化とは目的が違います。shared_examplesは重複を減らすために切り出しますが、letはitの責務を絞るために出します。1回しか使わなくても出す理由はそこです。
itの直後はis_expectedかsubject
判断の目安として使っています。
itの直後に別の処理があるなら、
- 前提の準備なら
beforeかletに出す - そうでないなら、不要な検証ではないか
たとえばcreateしたデータが期待通りかをsubjectの前に確認しているコードがあったとします。
let(:space) { create(:space, :private) }
it '...' do
expect(space.private).to be(true) # これは要らない
is_expected.to eq(200)
end
:privateのtraitで作ったなら、そうなっているはずです。疑うべきはfactoryの方で、そちらを直さないといけません。実装している最中に確認するのは自由ですが、コミットする必要はないと考えています。
共通化2:shared_examplesとshared_context
使い分け
- shared_context — 前提を持ち込む(
letやbefore) - shared_examples — 検証を持ち込む(
it)
include_context 'ログイン処理' # 前提
it_behaves_like '正常なレスポンス' # 検証
呼び出し行を見れば、どちらの性質かが分かります。
切り出す基準
2つ以上出てきたら切り出す、が基本です。1回しか使わないものを切り出すと、読む場所が増えるだけになります。
ただし数だけでは決まりません。呼び出し側から中身を見に行かずに済むかを見ています。
it_behaves_like 'Invalid'
これは「invalidであること」と「エラーメッセージが一致すること」以外に解釈の余地がないので、中を見る必要がありません。
逆に、名前と引数で挙動が確定しないものは、中を見に行くことになります。共通化した結果、読む場所が増えている状態です。そうなるなら、切り出さないか、名前を変える方がいいと思います。
もう1つ、並びが揃うなら1回でも切り出すことがあります。同じ階層にValidとInvalidが並んでいるのに、片方だけitを直書きしていると構造が崩れて読みにくくなります。
命名
Valid / Invalidのように、短くて対になる名前を使っています。
context '正常値' do
let(:code) { valid_code }
it_behaves_like 'Valid'
end
context '重複' do
let(:code) { valid_code }
let(:messages) { { code: [...] } }
before { create(:invitation, code:) }
it_behaves_like 'Invalid'
end
RSpecの慣習だとit_behaves_like 'a validated model'のような主語述語の形にすることもありますが、日本語で「保存できず、エラーメッセージが一致する」のように書くと呼び出し行が長くなります。
呼び出し行が短い方が、contextの構造が読めます。条件はcontext名に書かれているので、検証内容は短い名前で足ります。
日本語で書くと表記揺れ(「保存できない」「保存されない」「無効」)も起きます。Valid / Invalidの2択なら揺れようがありません。
どこに置くか
切り出す場所によって、読みに行くコストが変わります。
- 同じファイル内 → 上にスクロールすれば読める
spec/support/配下 → 別ファイル。grepして開く
同じ共通化でも距離が違うので、supportに置いていいのは中を見る必要が本当にないものに限られます。
ログイン系はこれを満たします。include_context 'ログイン処理'は名前で挙動が確定するし、中を見に行く動機がありません。Valid / Invalidも、全model specで使う汎用的なものなのでsupportに置いています。
ドメイン固有のものをsupportに置くかは意見が割れるところです。名前だけでは前提が確定しないので、「これは何をセットアップしているのか」を確認したくなる瞬間が来ます。その時に距離が効いてきます。
賛成派と反対派で議論が噛み合わないことがありますが、支持しているのは場所ではなく、置くものの性質だと思っています。「supportは追いづらい」という指摘は、追う必要があるものを置いた場合に正しい。
specは冗長でもいい、の範囲
「テストコードは多少冗長でもいい」と言われます。DRYにしすぎると読めなくなる、という主張です。
同意しますが、これは主にファイルをまたぐ共通化の話だと思っています。supportに切り出したり、他のspecとshared_examplesを共有したりすると、確かに追いづらくなります。
同じファイル内なら数行〜数十行上にあるので、読みに行くコストは小さい。同じdescribeの中なら文脈も共有しています。ここで冗長を許しても、読む量が増えるだけです。
前回書いた通り、行数そのものは指標になりません。共通化で行数は減りますが、読む場所が遠くなれば読む量は増えます。判断するのは読む量の方です。
まとめ
- 並び順は「検証対象 → 前提の準備 → 検証内容 → テストケース」。コメントとそれを実現するコードは隣接させ、
itの上には空行を入れる - この並びはRuboCopのデフォルトルールと一致しないが、役割ごとに分けた方が読める
letで上位にデフォルトを定義し、contextでは変えるものだけを上書きするletは1回しか使わなくても出す。itを検証だけにするためitの直後はis_expectedかsubject。前に何かあるならbeforeに出すか、不要な検証を疑う- shared_contextは前提、shared_examplesは検証
- 切り出す基準は2つ以上使うか。加えて、呼び出し側から中身を見に行かずに済むか
- supportに置いていいのは、中を見る必要がないものだけ
- 「specは冗長でもいい」は、ファイルをまたぐ共通化の話。同じファイル内なら冗長にする理由は薄い
次回は、itをまとめる話を書きます。「1 expectation per it」への反論と、aggregate_failuresの使い所です。
