Rails/RSpecでspecをどう書くか、何回かに分けて整理します。
1回目は構造の話です。長いspecの何が辛いのか、そしてファイル・describe・contextをどう分けるか。
- テストは何のために書くのか
- 長いspecの何が辛いのか
- 対処1:action毎にファイルを分ける
- 対処2:describeでテスト観点を分ける
- 対処3:contextをフラットに書かない
- 対処4:軸を分けてケース爆発を防ぐ
- 対処5:テストパターンをコメントで宣言する(おすすめ)
- ネストの深さについて
- まとめ
テストは何のために書くのか
まず前提を揃えておきます。
テストを書く目的は、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ケースの中で、検証すべき項目に漏れがないか
- その期待値は、仕様として正しいか
このうち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つあります。「このファイルは何を守っているか」が目次として読めます。
describeとcontextの使い分けもここで決まります。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が独立性を保証する
- テストパターンを先にコメントで宣言する
次回は、ファイル内の並び順と共通化(let、shared_examples、shared_context)について書きます。
