辛くならないテストを書こう
「テストは辛いよ」
正確には肥大化したテストはテストは書くのも読むのも辛いものである。
RSpecはデファクトとして知られているのでこれまでいろんなRSpecの記述を経験してきたけれども、人によってはRSpecの表現力を十分に扱いきれていないと感じるときがある。
Rubyはオブジェクト指向言語なので、それぞれのクラスに明確な役割をもたせることができる。
テストが辛くなる根本的な原因はいわゆる関心の分離がうまくできておらず、複数の観点でテストが記述されているからだ。
例えばモデルのテストはリクエストで担保されているので書く必要がないということを耳にする。
RubyやRailsで定義するクラスについては熱心に語られることがあるのに、RSpecはあまり語られていない。
RSpecもRubyとRails同様に構造化しておくべきだと思う。
今回は私の今取り組んでいるプロジェクトから抜粋したものを使用する:
<!-- index.html.erb -->
<% content_for(:title, t(".title")) %>
<div>
<h2><%= t(".title") %></h2>
</div>
<% if @pagy.pages > 1 %>
<%== pagy_bootstrap_nav(@pagy) %>
<% end %>
<div class="row">
<% @automate_tasks.each do |automate_task| %>
<div class="col">
<%= render_automate_task(automate_task) %>
<%= render_perform_task_modal(automate_task) %>
</div>
<% end %>
</div>
テスト以前にまずViewのファイルはこのくらい必要最低限を心がけることが必要である。
テストが例えどれだけ構造化されていたとしても、絶対量が少ないほうが開発はしやすい。
ここからわかることはAutomateTask
(自動化処理)の一覧を表示するページだ。
したがってこのページの観点は以下のテストが必要だと思う:
AutomateTask
を2件渡したとき.col
が2回表示されること- 意図した
title
が表示されること - ページネーションが機能しているか
- 2ページ以上にまたがるときは表示されること
- 1ページのときは表示されないこと
「ちょっと待った。このテストで肝心なViewのテストが抜けているではないか」
彼の言葉を代弁するとCSSのセレクタや、ページ上の必要な表示が抜けているではないかといった指摘である。
Viewのテストが敬遠されやすい理由は以下のものである:
- MVCのうち、とくにViewの観点では細かい仕様変更が多く、修正量が膨大になりがち
- JavaScriptを使えないのでページの動的なテストを追うことができない
辛くなりやすいテストは大きな森の中で一本の木を調べるようなものである。
ここで先程のコードを注意深く見直してほしい。
@automate_tasks
の反復にはrender_automate_task
とrender_perform_task_modal
というメソッドが使われている。
このページのテスト観点は複数のモデルを渡せるかどうかである。
複数のモデルに対してそれぞれ細かい表示をテストするのは現実的ではない。
もしViewの細かいテストをしたいと思えばそのメソッドたちに責任を委譲してやればよい。
これがRSpecにおける正しい関心の分離であると信じている。
それではこのrender_automate_task
の中身を見てみよう:
module AutomateTasksHelper
def render_automate_task(automate_task)
render AutomateTaskComponent.new(automate_task: automate_task)
end
end
全く拍子抜けかもしれないが、ここではただ単純に特定のViewComponentを呼び出しているというメソッドにすぎない。
<div class="card automate-task-card">
<div class="card-body">
<h5 class="card-title"><%= automate_task.type %></h5>
<p class="card-text">
<strong>Schedule:</strong> <code><%= schedule %></code><br>
<strong>Next at:</strong> <%= next_at %><br>
<strong>Enabled:</strong> <%= enabled %><br>
</p>
<%= toggle_bs_modal_button "Perform Task", perform_task_modal_id %>
</div>
</div>
次にこれがAutomateTaskComponent
のERBファイルである。
実に簡素である。
これはほぼ手直しをしていないコードでありつつ、このようにブログにも掲載できるし誰でもそのコードの役割が理解できる。
ViewComponentについての詳細はここでは省くが、これで先程の例えである木そのもののテストが書きやすい。
require "rails_helper"
RSpec.describe AutomateTasksHelper, type: :helper do
describe "#render_automate_task" do
subject { helper.render_automate_task(automate_task) }
let(:automate_task) { create(:automate_task) }
it { is_expected.to have_css("div.automate-task-card") }
it { is_expected.to include(automate_task.type) }
end
describe "#render_perform_task_modal" do
# ...
end
end
若干違和感があるかもしれないが、普段Railsを書いている人もヘルパーというディレクトリを積極的に書いている人はそこまで多くないのではないだろうか。
実はヘルパーでテストを書くときはgemさえあれば特に設定しなくてもCapybaraのマッチャをすぐに利用できるのでhave_css
みたいなテストが書きやすい。
このテストの関心は主にautomate_task.type
が呼ばれることと意図したクラスが含まれているかだけに限定できる。
それ以外のメソッドはさらにAutomateTaskComponent
へ委譲することができる。
例えばこのautomate_task
にはダイアログ(Modal)が開く設定(render_perform_task_modal
)もあるのだが、それは別の観点(describe
)に移して書く。
それではAutomateTaskComponent
のテストも見てみよう。
class AutomateTaskComponent < ViewComponent::Base
include TwbsHelper
attr_reader :automate_task
delegate :schedule, to: :automate_task
def initialize(automate_task:)
@automate_task = automate_task
end
def next_at
automate_task.next_at.to_fs(:short)
end
def enabled
automate_task.enabled? ? "Yes" : "No"
end
def perform_task_modal_id
"perform-task-#{automate_task.id}-modal"
end
end
このコンポーネントは主に.card-text
の中の表示と、ダイアログのIDの生成が関心である。
toggle_bs_modal_button
に関してはTwbwHelper
に定義しているのでこのテストは行う必要はなく、このコンポーネントの関心事はこれらのメソッドをそれぞれ定義していくだけである。
察しがよければこのクラスはいわゆるView Model
あるいはPresenter
パターンに近いように感じるだろう。
現実問題としてRailsはMVCのみで表現するには足りなすぎるのだが、適切なパターンとクラスを当てはめていけばテストを書くこと自体はさして苦しくならない。
require "rails_helper"
RSpec.describe AutomateTaskComponent, type: :component do
let(:component) { described_class.new(automate_task: automate_task) }
let(:automate_task) { create(:automate_task) }
describe "#enabled" do
subject { component.enabled }
context "when the task is enabled" do
before { allow(automate_task).to receive(:enabled?).and_return(true) }
it { is_expected.to eq("Yes") }
end
context "when the task is disabled" do
before { allow(automate_task).to receive(:enabled?).and_return(false) }
it { is_expected.to eq("No") }
end
end
end
enabled
メソッドはこのようにテストすればよい。
ひょっとしたらもっとモデル側で特殊な計算をしてからYes
/No
の表示を返すのかもしれないが、あくまでこのテストの関心事はenabled?
が返す値に応じて表示が変わるということだけである。
そういった処理はモデル側で行うべきである。
最近RSpecを書くときのベストプラクティスはコードとテストはそれぞれ一対一であると感じている。
辛くないテストを書くには洞察力とRSpecに対する知識は必要であるが、それさえ手に入れてしまうとコーディングはかつての辛さから開放されてとても楽しいものになる。
さあ、辛くならないテストを書こう。