サムネイル画像

「テストは辛いよ」

正確には肥大化したテストはテストは書くのも読むのも辛いものである。

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_taskrender_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に対する知識は必要であるが、それさえ手に入れてしまうとコーディングはかつての辛さから開放されてとても楽しいものになる。

さあ、辛くならないテストを書こう。