fv17の日記

Webエンジニアの備忘用ブログです。主にWeb界隈の技術に関して書いています。

【各章まとめ】Everyday Rails - RSpecによるRailsテスト入門 - 第8章 スペックをDRYに保つ

復習用まとめ

本章で学ぶこと

  • ワークフローをサポートモジュールに切り出す
  • テスト内でインスタンス変数を再利用するかわりにletを使う
  • shared_contextに共通のセットアップを移動する
  • カスタムマッチャの作成
  • テストを集約して、複数のスペックを一つにする

サポートモジュールに、ログインのフローを切り出す

複数のスペックでの何回も書かれている処理を共通処理として切り出す。例えば、ログイン処理など。ポイントは、メソッド名称を見ただけで何をしているのか一目でわかるように切り出すこと。

切り出し方

spec/supportディレクトリ以下に、login_support.rbを作成

module LoginSupport
  def sign_in_as(user)
    visit root_path
    click_link "Sign in"
    fill_in "Email", with: user.email
    fill_in "Password", with: user.password
    click_button "Log in"
  end
end

RSpec.configure do|config|
  config.include LoginSupport
end

呼び出し方

上記のようにmoduleに、「config.include XxxxxSupport」と記述するか、呼び出すスペック内にてinclude

RSpec.feature "Projects" ,type::feature do
  include LoginSupport  #  ←使うスペックでincludeする

  scenario "user creates a new project" do
    # ...
  end
end

Device gemを使っている場合のログインフローの切り出し方

特に上記のように画面操作をシュミレートしなくても、フィーチャーテストにおいてDeviceのヘルパーをつかうことで、ログイン状態にすることができる。

まずは、rails_helper.rbに次の一行を追加

RSpec.configure do |config|
  # ...
  config.include Devise::Test::IntegrationHelpers, type: :feature
end
RSpec.feature "Projects" ,type::feature do 
  scenario "user creates a new project" do
    user = FactoryBot.create(:user)
    sign_in user  # セッションを作成する
    visit root_path  # セッション作成後に遷移させることを忘れずに
    # ...
  end
end

letで遅延読み込み

なぜletを使うか?

beforeブロックでテストデータを用意する場合、下記のデメリットがある。

  • テスト実行のたびに毎回実行され、不要データが作成されテスト速度が遅くなる可能性がある。
  • 要件が増えるにつれ可読性が悪くなる。

letの使い方

RSpec.describe Note, type: :model do
  let(:user) { FactoryBot.create(:user) }
  let(:project) { FactoryBot.create(:project, owner: user) }

  # このテストではletが呼び出される
  it "is valid with a user, project, and message" do
    note = Note.new(
      message: "This is a sample note.",
      user: user,
      project: project,
    )
    expect(note).to be_valid
  end

  # このテストではletが呼び出されず、無駄なデータが作成されない
  it "is invalid without a message" do
    note = Note.new(message: nil)
    note.valid?
    expect(note.errors[:message]).to include("can't be blank")
  end
end

let!を使う必要がある場合

テスト内で明示的にletの値が呼び出されない場合、例えば検索のテスト等でテスト実行前にデータを用意しておく必要がある場合など、let!を使うことでテスト前にlet内部のコードが呼び出される仕組みとなっている。

  # これより上のコードは略

  describe "search message for a term" do
    let!(:note1) do  FactoryBot.create(
      :note,
      project: project,
      user: user,
      message: "This is the first note")
    end

    let!(:note2) do  FactoryBot.create(
        :note,
        project: project,
        user: user,
        message: "This is the second note")
    end

    let!(:note3) do  FactoryBot.create(
        :note,
        project: project,
        user: user,
        message: "First, preheat the oven.")
    end

    context "when a match is found" do
      it "returns notes that match the search term" do
        expect(Note.search("first")).to include(note1, note3)
      end
    end

    # letとした場合、このテストではデータが全く作成されなくなってしまう
    # let!にすることで、テスト実行前にデータが作成された状態になる 
    context "when no match is found" do
      it "returns an empty collection" do
        expect(Note.search("message")).to be_empty
        expect(Note.count).to eq 3
      end
    end
  end

shared_context

テストにおける条件(=context)、セットアップを共有する。毎回同じセットアップコードを書いているならば、shard_contextで共通化できないかを考えてみる。

通化の方法

spec/support/contexts ディレクトリ以下に、project_setup.rb などと切り出す

RSpec.shared_context "project setup" do
  let(:user) { FactoryBot.create(:user) }
  let(:project) { FactoryBot.create(:project, owner: user) }
  let(:task) { project.tasks.create!(name: "Test task") }
end

使う側の実装

RSpec.describe TasksController, type: :controller do
  include_context "project setup"  # ←この一行を加えるだけでセットアップ完了。

  describe "#show" do
    it "responds with JSON formatted output" do
      sign_in user
      get :show, format: :json,
        params: { project_id: project.id, id: task.id }
      expect(response.content_type).to eq "application/json"
    end
  end 
  .
  .
end

カスタムマッチャ

読みにくいエクスペクテーションを分かりやすいカスタムマッチャとして切り出す。処理をメソッドでまとめ、保守性の高い名前を付けることとやってることは同じ。

RSpec の重要な信条のひとつは、人間にとっての読みやすさです。

spec/support/matchers以下にファイルを作成

spec/support/matchers/content_type.rb

RSpec::Matchers.define :have_content_type do |expected|
  match do |actual|
    content_types = {
      html: "text/html",
      json: "application/json",
    }
    actual.content_type == content_types[expected.to_sym]
  end
end

...一旦ここまで。