Rails/RSpecでspecをどう書くか、何回かに分けて整理します。
1回目は構造の話です。長いspecの何が辛いのか、そしてファイル・describe・contextをどう分けるか。

テストは何のために書くのか

まず前提を揃えておきます。

テストを書く目的は、CI(継続的インテグレーション)です。
改修する時に、元の挙動が壊れていないことを機械的に担保する。それによって手動テストの手間を減らし、信頼性を維持する。開発中の動作確認にも使えますが、本体はこちらです。

この目的から考えると、「変更に強いテスト」が何なのかが決まります。

テストの価値は、書いた瞬間ではなく改修の瞬間に発生します。
だとすると、挙動を変えていないのに落ちるテストは、その瞬間に価値を出すどころかコストを発生させています。目的に対して逆向きです。

ただし、「壊れないこと」が目的ではありません。
それを徹底すると、最後は何も検証しないテストになります。

欲しいのは、

  • リファクタしただけなら落ちない
  • 仕様が変わったら落ちる

この非対称です。同じ「変更」でも、リファクタか仕様変更かで期待が逆になる。これが「変更に強い」の実体だと考えています。

長いspecの何が辛いのか

5000行を超えるspecファイルを見たことがあります。1ファイルに複数のactionが入っていて、contextはフラットに並び、shared_examplesも使わずに同じようなitが延々と書かれている状態でした。

ただ、行数そのものが問題かというとそうでもなくて、構造化されていれば2000行を超えていても普通に読めます。辛いのは長さではなく、構造がないまま長いことです。

では何が辛いのか。失敗した時に原因が追えない、という話ではありません。テストが落ちれば行番号が出るので、そのitにはすぐ辿り着けます。

辛いのは、ケースを追加する時と、レビューする時です。

追加する時、置き場所が決まらない

contextがフラットに10個並んでいると、11個目を足す時に既存9個を全部読むまで判断できません。
同じ条件が既にあるか。どこに挿すのが自然か。しかも読んでも確信が持てないので、結局似たようなものを重複して足すことになります。

網羅しているか確認できない

これが本丸です。

フラットに書くと、条件の組み合わせがcontext名の文字列に潰れます。

context 'ステータスが有効で、期限が未来の場合' do
context 'ステータスが有効で、期限が過去の場合' do
context 'ステータスが無効で、期限が過去の場合' do

読む側はcontext名の文章を読んで、条件を1つずつ分解する必要があります。そして抜けている組み合わせが見えません。「無効で期限が未来」が無いことに気づけない。

レビューが追いつかない

そして今、ケースを書くのはAIです。書くコストがほぼゼロになったので、ケースは増える方向にしか動きません。
生産量が上がるほど、レビューがボトルネックになります。

実装している時もレビューが中心になりましたし、PRのレビュー自体も増えています。追いにくいコードのストレスが、そのまま生産性に効いてきます。

specのレビューで判断しないといけないのは、だいたい4つです。

  1. そのケースは既存と重複していないか
  2. 抜けている組み合わせはないか
  3. 1ケースの中で、検証すべき項目に漏れがないか
  4. その期待値は、仕様として正しいか

このうち1と2は、構造が揃っていればdiffだけで判断できます。フラットだと既存を全部読まないと判断できません。

3はitの分け方が効いてきます。細かく分けると検証項目が別々のitに散らばるので、見比べないと何が足りないか分かりません。itの数が多いこと自体もしんどい。これは別の回に書きます。

4だけは仕様を知らないと判断できないので、人間が時間を使うべきところです。だから1〜3のコストを削って、4に集中させたい。以降の話は全部そのためのものです。

対処1:action毎にファイルを分ける

1つのspecファイルにindex/show/create/update/destroyが全部入っていると、無関係な変更でも常に全体を開くことになります。しかも先頭のletが全actionの共有前提になり、1つ触ると全部が動きます。

分ける単位がactionなのは、改修の単位がactionだからです。テストファイルの粒度は、改修の粒度に合わせる。

ケースを追加する時も、「このactionの既存ケース」だけを見れば済みます。1ファイルに全actionあると、追加のたびに無関係な7割を読み飛ばすことになります。

対処2:describeでテスト観点を分ける

ファイル内の第1階層です。

モデルのspecならvalidates :nameのように切るのが自然で、迷うところは少ないと思います。判断が要るのはrequest specの方です。

スペース一覧のrequest specなら、こう切ります。

describe 'GET #index' do
  describe 'アクセス制御' do
  describe '件数' do
  describe '検索' do
  describe '絞り込み' do

1つのactionに対して、観点が4つあります。「このファイルは何を守っているか」が目次として読めます。

describecontextの使い分けもここで決まります。describeは何を検証するか(観点・対象)、contextはどういう状況か(条件・前提)。「アクセス制御」は観点なのでdescribe、「未ログイン」は条件なのでcontextです。

ここで大事なのは、describeは処理の順序ではなく観点で切ることです。

「アクセス制御」と「件数」を分けているのが分かりやすい例です。誰がアクセスできるかと、アクセスできた時に何が表示されるかは、別の観点です。混ざっていると、「このactionは誰がアクセスできるのか」を知りたいだけの時に、件数のネストを掻き分けることになります。

新しいケースを足す時も、どの観点に属するかでdescribeが決まります。属する観点が無ければdescribeごと新設する。判断が機械的になります。

なお、こう分けられるのは軸が互いに独立しているからです。その判断は対処4で書きます。

対処3:contextをフラットに書かない

最小単位です。ネストすると、軸が構造になります。

context 'ステータスが有効' do
  let(:status) { :active }

  context '期限が未来' do
    let(:expired_at) { 1.day.from_now }
    # ...
  end
  context '期限が過去' do
    let(:expired_at) { 1.day.ago }
    # ...
  end
end
context 'ステータスが無効' do
  let(:status) { :inactive }

  context '期限が未来' do
    # ...
  end
  context '期限が過去' do
    # ...
  end
end

各軸の値が縦に並ぶので、抜けが目視できます
そして次のケースを足す時、「ステータスが無効 → 期限が未来」という座標が先に決まる。軸を辿るだけで置き場所が確定します。読む量が全ケース分から、軸の深さ分に減ります。

letの書き方も効いています。上位でデフォルトを定義し、contextでは変えるものだけを上書きする。差分だけが書かれるので、そのcontextが何を変えているかが一目で分かります。

対処4:軸を分けてケース爆発を防ぐ

ネストは構造を作りますが、全部の軸をネストすると今度は階層が深くなりすぎます。

ここで効くのが、独立している軸は組み合わせないという判断です。

先ほどのスペース一覧を例にします。このactionには軸がいくつもあります。

  • URLの拡張子(なし / .json)
  • Acceptヘッダ(HTML / JSON)
  • APIのみモード(true / false)
  • ログイン状態(未ログイン / ログイン中 / 削除予約済み / APIログイン中 / APIログイン中・削除予約済み)
  • 表示件数とページ
  • 検索テキスト
  • 絞り込み(公開・非公開 / 参加・未参加 / 有効・削除予定)

これを全部ネストしたら、ケース数は掛け算で膨らみます。

そこで、対処2の4つのdescribeに軸を割り振ります。

describe 'アクセス制御' do # 拡張子・Accept・APIのみモード → 200か406か
describe '件数' do        # アクセスできる前提で、ログイン状態・件数
describe '検索' do        # 認証まわりは固定して、テキスト検索だけ
describe '絞り込み' do     # 認証まわりは固定して、絞り込み条件だけ

アクセス制御は、表示件数と無関係です。0件でも100件でも、APIのみモードなら406が返る。だから件数は代表を1つ固定すれば足ります。

検索絞り込みのdescribeでは、認証まわりを1つに固定します。

describe '検索' do
  let(:subject_format) { :json }
  let(:accept_headers) { ACCEPT_INC_JSON }
  include_context 'ログイン処理'

これができるのは、検索条件が認証結果に影響しないからです。
権限がなければ検索テキストが何であれ弾かれるし、権限があれば検索処理まで進む。独立しています。

さらに絞り込みの中では、3つの軸をdescribeで分けています。

describe '公開・非公開' do  # 1と1, 1と0, 0と1, 0と0 → 4ケース
describe '参加・未参加' do  # 1と0, 0と1, 0と0 → 3ケース
describe '有効・削除予定' do # 1と0, 0と1, 0と0 → 3ケース

組み合わせを全部作ると 4 × 3 × 3 = 36ケース
軸ごとに分けると 4 + 3 + 3 = 10ケース。掛け算が足し算になります。

実行時間もレビュー量も、同じ比率で減ります。

なお、絞り込みのロジックがscopeにあるなら、その検証をmodel specに移す方法もあります。HTTPを通らない分さらに速くなりますが、「パラメータがscopeに渡っているか」はrequest spec側で見る必要があります。責務の分け方の話になるので、別の回に書きます。

独立性はコードが保証する

「独立しているはず」という推測ではなく、実装を見て確認します。

return head :forbidden unless authorized?
# 以下、正常系

早期returnで止まるなら、その先の処理は一切走りません。だからパラメータが何であれ結果は同じです。実装を読めばそれが確定します。

逆に、途中にreturnがなく、条件が後ろの処理に影響するなら、組み合わせが必要です。その場合は軸を分けず、1つのdescribeの中で扱うことになります。

つまり実装の書き方がテストケース数を決めています
ネストしたifを早期returnに直すのは可読性の話だと思われがちですが、ケース数にも効きます。

対処5:テストパターンをコメントで宣言する(おすすめ)

ケースを書く前に、網羅すべきパターンを洗い出してコメントに書きます。

# 前提条件
#   検索条件なし
# テストパターン
#   URLの拡張子: ない, .json
#   Acceptヘッダ: HTMLが含まれる, JSONが含まれる
#   APIのみモード: true, false
#   未ログイン, ログイン中, ログイン中(削除予約済み), APIログイン中, APIログイン中(削除予約済み)
#   スペース: ない, 最大表示数と同じ, 最大表示数より多い(ページ: 1, 2, 3)
describe '件数' do

これは元々、自分が網羅を確認するために始めた書き方です。
改修やレビューの時にcontextをスクロールして追うのが辛いので、先頭にまとめて書いておく。

効果が3つあります。

横に並べると抜けが見える。「1, 2, 3」と並べた時に、0のケースが無いことに気づけます。

前提条件を明示できる。何を固定して何を動かしているかが書かれるので、対処4の軸分割が読み手に伝わります。「なぜここで認証を固定しているのか」が説明不要になります。

レビューがコメントと構造の突き合わせで済む。宣言と実体が一致しているかを見ればいいので、全itを読まなくて済みます。

AIに書かせる時も、「まずテストパターンを宣言してから書く」という規約にしておくと、レビュー可能な形で出力されます。人間が読む順序を、生成物の側に埋め込んでいる形です。

弱点もあります。コメントなので、実体とズレても落ちません。ここは仕組みで検知できないので、レビューで見るしかない部分です。

ネストの深さについて

2〜3段までが理想ですが、規則にはできません。守れない場面で規約が形骸化します。

現実的には、

  • 深くなったら、まず独立した軸が混ざっていないか見る
  • 混ざっていれば、対処4のようにdescribeで分ける
  • 絡んでいる軸だけが残ったなら、その深さは必要なもの

分けられる軸が残っていないかが判断で、深さそのものは結果です。

招待の期限を検証しているmodel specは3段になっていますが、既存の終了日時と、入力された日付が絡んでいるので、その組み合わせ自体がこのdescribeの検証対象です。分けたら表現できなくなります。

一方、先ほどの認証と絞り込みは絡んでいないので分けられました。

深いspecを見た時に、「まだ分けられる軸はないか」を問えるようにしておく。それがレビューの観点になります。

なお、絡んでいる軸が深くなった場合でも、shared_examplesを使えば見た目のネストは抑えられます。深くなった軸を切り出して、上位のcontextからit_behaves_likeで呼ぶ形です。ネストが横に展開されるので、階層が浅く見えます。

「specは多少冗長でもいい」と言われますが、それは主にファイルをまたぐ共通化の話だと思っています。同じファイル内なら数行上を見れば済むので、冗長にしておく理由は薄い。この共通化の話は次回にします。

まとめ

  • テストの目的はCIでの担保。そこから「リファクタでは落ちず、仕様変更では落ちる」という非対称が導かれる
  • 長いspecが辛いのは、失敗時ではなく追加時とレビュー時
  • action毎にファイルを分ける。改修の単位に合わせる
  • describeは処理の順序ではなく観点で切る
  • contextはネストして軸を構造にする。抜けが見えるようになる
  • 独立している軸は組み合わせない。掛け算が足し算になる。早期returnが独立性を保証する
  • テストパターンを先にコメントで宣言する

次回は、ファイル内の並び順と共通化(letshared_examplesshared_context)について書きます。

コメントを残す

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