前回は、長いspecが辛いのは追加とレビューの時だという話と、ファイル・describe・contextの分け方を書きました。
今回はその中身です。1つのdescribeの中を、どういう順序で書いて、どこまで共通化するか。
目的は前回と同じで、読む量と読む場所を減らすことです。

並び順にはある程度の制約がある

先に制約の話をします。

shared_examplesshared_contextは、使う前に定義されている必要がありますit_behaves_likeより下に書くとNameErrorになる。

letsubjectはメソッド定義なので順序の制約はありませんが、内側の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/ScatteredLetRSpec/LetBeforeExamplesのようなcopは無効にする必要があります。それでも役割ごとに分けて並べた方が、何が前提で何が検証用のデータかが読めます。

itit_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 'ログイン処理'

「前提条件」に書いたものが、直下のletinclude_contextで実装されています。

離れていると、どちらかを直した時に片方が取り残されます。隣接していれば同時に目に入る。コメントは実行されないので落ちませんが、距離を近づけることで乖離しにくくなります。

contextの中も同じです。コメントで宣言した条件を、context名で受けて、直下のletで実装します。describesubjectも同様です。

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は重複を減らすために切り出しますが、letitの責務を絞るために出します。1回しか使わなくても出す理由はそこです。

itの直後はis_expectedかsubject

判断の目安として使っています。

itの直後に別の処理があるなら、

  • 前提の準備ならbeforeletに出す
  • そうでないなら、不要な検証ではないか

たとえば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 — 前提を持ち込む(letbefore
  • shared_examples — 検証を持ち込む(it
include_context 'ログイン処理'  # 前提
it_behaves_like '正常なレスポンス'  # 検証

呼び出し行を見れば、どちらの性質かが分かります。

切り出す基準

2つ以上出てきたら切り出す、が基本です。1回しか使わないものを切り出すと、読む場所が増えるだけになります。

ただし数だけでは決まりません。呼び出し側から中身を見に行かずに済むかを見ています。

it_behaves_like 'Invalid'

これは「invalidであること」と「エラーメッセージが一致すること」以外に解釈の余地がないので、中を見る必要がありません。

逆に、名前と引数で挙動が確定しないものは、中を見に行くことになります。共通化した結果、読む場所が増えている状態です。そうなるなら、切り出さないか、名前を変える方がいいと思います。

もう1つ、並びが揃うなら1回でも切り出すことがあります。同じ階層にValidInvalidが並んでいるのに、片方だけ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_expectedsubject。前に何かあるならbeforeに出すか、不要な検証を疑う
  • shared_contextは前提、shared_examplesは検証
  • 切り出す基準は2つ以上使うか。加えて、呼び出し側から中身を見に行かずに済むか
  • supportに置いていいのは、中を見る必要がないものだけ
  • 「specは冗長でもいい」は、ファイルをまたぐ共通化の話。同じファイル内なら冗長にする理由は薄い

次回は、itをまとめる話を書きます。「1 expectation per it」への反論と、aggregate_failuresの使い所です。

コメントを残す

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