【技術書まとめ】『Everyday Rails - RSpecによるRailsテスト入門』まとめ


『Everyday Rails - RSpecによるRailsテスト入門』を読んだので簡単にまとめてみました。

テストとは

そもそもテストとは何なのか。

つまり、テストはあなたに開発者としての自信を付けさせるものであるべきなのです。

テストはどうあるべきかということについては、\

  • テストは信頼できるものであること
  • テストは簡単に書けること
  • テストは簡単に理解できること(今日も将来も)

と著者は言っています。今日も将来も簡単に理解できるというのは重要ですね。本文では非エンジニアでも読んでわかるという記載があります。モデルやそのメソッドやスキーマを見ずとも、テストを見ればその全容がパッとわかるととてもうれしいですね。じゃあ原理主義的に「簡単に理解できるもの」を書かなければいけないのか、簡単とは何か頭をひねって考えなければならないのかと言うと、

とはいえ結局、一番大事なことはテストが存在することです。

なるほど。

さあ、テストをつくってみよう

ジェネレータを使ってモデルのテストを作成する

$ bin/rails g rspec:model user

ジェネレータを使わないでじかに書いたらダメなの? と言う問いには、

必ずしもスペックファイルを作成するためにジェネレータを利用する必要はありません。しかし、ジェネレータの使用はタイプミスによるつまらないエラーを防止するための良い方法です。

まずはモデルのバリデーションテストをつくる

例えば、

it "is invalid without a first name" do
  user = User.new(first_name: nil)
  user.valid?
  expect(user.errors[:first_name]).to include("can't be blank")
end

バリデーションテストを一つずつ作る意味はあるのか? という疑問に著者が答えている部分がありました。

  • バリデーションは書き忘れやすい
  • テストを書いているときに追加しなければいけないバリデーションを思い出せる

「こんなテストは役に立たない。モデルに含まれるすべてのバリデーションを確認しようとしたらどれ くらい大変になるのかわかっているのか?」そんなふうに思っている人もいるかもしれません。ですが、 実際はあなたが考えている以上にバリデーションは書き忘れやすいものです。しかし、それよりもっと 大事なことは、テストを書いている 最中に モデルが持つべきバリデーションについて考えれば、バリデ
ーションの追加を忘れにくくなるということです。(このプロセスはテスト駆動開発でコードを書くのが 理想的ですし、最後は実際そうします。)

確かにそうですね。

バリデーションを書き換えたり、コメントアウトしてみたりする

一時的にバリデーションをコメントアウトしたり、テストを書き換えたりして、結果が変わることを確認してください。

nilを入れたり数字しか入れられないところに文字列を入れたりしてみます。

次はメソッドをテストする

it "returns a user's full name as a string." do
    user = User.new(
      first_name: "John",
      last_name:  "Doe",
      email:      "[email protected]",
    )

    expect(user.name).to eq "John Doe"
  end

rspecでは==eqを使います。

正常系だけでなく異常系もテストします。

マッチャの色々

  • be_valid
  • eq
  • include
  • be_empty

他にも色々あります。
https://github.com/rspec/rspec-expectations

DRY にする

describeでメソッドごとにまとめます。

RSpec.describe Note, type: :model do
  describe "search message for a term" do
    ...
  end
end

contextで場合分けをします。値が返ってくる場合と返ってこない場合などです。

RSpec.describe Note, type: :model do
  describe "search message for a term" do
    context "when a match is found" do
      ...
    end

    context "when no match is found" do
      ...
    end
  end
end

beforeで重複データをまとめてセットアップします。インスタンス変数にアサインするので@をつけるのを忘れないようにしましょう。

  before do
    @user = User.create(
      first_name: "Joe",
      last_name:  "Tester",
      email:      "[email protected]",
      password:   "password",
    )

    @project = user.projects.create(
      name: "Test Project",
    )
  end

じゃあどのくらいまでDRYにすればいいのか?

こうなった場合は重複を検討してください。

  • 大きなスペックファイルを頻繁にスクロールしている
  • 外部のサポートファイルを大量に読み込んでいる

もし自分がテストしている内容を確認するために、大きなスペックファイルを頻繁にスクロールしているようなら(もしくはあとで説明する外部のサポートファイルを大量に読 み込んでいるようなら)、テストデータのセットアップを小さな describe ブロックの中で重複させること を検討してください。

また、note1note_with_numbers_onlyとするように、変数名によってわかりやすさを向上させる方法も良いです。

テストの場合は DRY であることよりも読みやすいことの方が重要です。

気をつけること

  • 期待する結果は能動形で明示的に記述すること
  • 起きてほしいことと、起きてほしくないことをテストすること
  • 境界値テストすること
  • 可読性を上げるためにスペックを整理すること

FactoryBotとは

FactoryBot という gem はテストをきれいで読みやすくリアルにしてくれます。でも多用しすぎると遅くなるので気をつけましょう。

ジェネレータで Factory を追加する

bin/rails g factory_bot:model user

Factory を追加する

FactoryBot.define do
  factory :user do
    first_name { "Aaron" }
    last_name  { "Sumner" }
    email { "[email protected]" }
    password { "dottle-nouveau-pavilion-tights-furze" }
  end
end

セットアップがちゃんと完了しているか確認するテストを書きましょう。

it "has a valid factory" do
  expect(FactoryBot.build(:user)).to be_valid
end

それからuserをセットアップしていた部分を Factory に変えます。デフォルト部分を置き換えるなら、

user = FactoryBot.build(:user, first_name: nil)

便利な機能を色々使ってみる

  • ユーザーが二つ必要な場合などは連番をつけるシーケンスを使いましょう。
  • 関連を扱うこともできます。
  • 継承traitでDRYにもできます。
  • コールバックによってcreate後のアクションも指定できます。
    • after(:create) { |project| create_list(:note, 5, project: project) }

コントローラーのテストは?

コントローラーのテストには不十分な点があります。例えば destroy アクションのテストを作ったものの、結局UIが用意されていなかったなどです。使うとしたら場面を限定するようにしましょう。読めるようにはしておいた方が良いです。

フィーチャースペックを使ってみる

フィーチャースペックとは

フィーチャースペックはモデルとコントローラーが他のモデルやコントローラーとうまく一緒に動作することを確認するものです。またこれは受入テスト統合テストとも呼ばれます。

コントローラースペックと何が違うのか

Capybaraの機能であるvisitclick_linkclick_buttonなどによってユーザーの実際のUI操作を再現できます。expect部分ではhave_contentを使うことで実際に画面に表示されているコンテンツを確認します。これによって複数のモデルやコントローラーの動作をテストできます。

CapybaraDSL機能を使えばファイルのアップロードやCSSもテストすることができます。
https://github.com/teamcapybara/capybara#the-dsl

どこでテストが失敗したかわからないときは

CapybaraはUIを持たないブラウザで画面を操作します。なので処理を一つ一つたどることができません。そういったときはsave_and_open_pageを挟み込んでみましょう。binding_pryのような感じですね。

scenario "guest adds a project" do
  visit projects_path
  save_and_open_page
  click_link "New Project"
end

JavaScriptを使ったテスト

js: trueでJSを使うテストが簡単に作れます。実際にテストするとブラウザが立ち上がって自動入力してくれます。手動よりは早いですがやはり通常のテストよりかなり遅くなるのでJSのテストは必要な時にだけ有効にするのが良いでしょう。

また継続的インテグレーション(CI)環境では:selenium_chrome_headlessをドライバーに設定してウィンドウを開かないでテストすることができます。

シナリオ内にusing_wait_time(15)を入れることで、JSの読み込みの待ち時間を調整することもできます。

リクエストスペックを使ってAPIをテストしてみる

リクエストスペックはCapybaraを使いません。プログラム同士の対話には必要ないからです。コントローラースペックと似ていますが、ログインを入れたり直接パスを使えたりといったより高いレベルのテストができます。

スペックをDRYに保つには

サポートモジュールに切り出す

ログイン方法や文言が変わった場合に全ての部分を変更するのは大変です。例えばログイン作業をヘルパーメソッドに切り出すことができます。フィーチャースペックでよく使います。

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

モジュール内のメソッド名は、コードを読んだときに目的がぱっとわかるような名前にしてください。もしメソッドの処理を理解するために、いちいちファイルを切り替える必要があるのなら、それはかえってテストを不便にしてしまっています。

名前はパッとわかるようにしましょう。

letを使う

beforeを使ってログイン処理などを行なうのも悪くありません。ですがこれは毎回実行されてしまいます。予期せぬ影響があったりテストが遅くなったりします。

letは呼ばれた時に初めてデータを読み込むという遅延読み込みをしてくれます。使う時点で作ってくれるのでbeforeの時のようにインスタンス変数に入れる必要もありません。

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

  it "is valid with a project and name" do
    task = Task.new(
      project: project,
      name: "Test task"
    )
    expect(task).to be_valid
  end
end

遅延読み込みでなく通常通りに先に読み込む場合はlet!を使います。ですが!は読み落としやすいです。なので使う場合はbeforeにしたほうがわかりやすいかどうかを再検討してみてください。

shared_contextに切り出す

セットアップ作業などを切り出すこともできます。

spec/support/context/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 の重要な信条のひとつは、人間にとっての読み やすさです。ですので、カスタムマッチャを使って読みやすさを改善してみましょう。

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

expect(response.content_type).to eq "application/json"expect(response).to have_content_type :jsonになりました。

エラーメッセージやエイリアスも作ることができます。

カスタムマッチャ作りにハマっていく前に、shoulda-matchers gem も一度見ておいてください。 この gem はテストをきれいにする便利なマッチャをたくさん提供してくれます。

二つのテストを一つに集約する

aggregate_failuresを使って集約することができます。

it "responds successfully" do
  sign_in @user
  get :index
  aggregate_failures do
    expect(response).to be_success
    expect(response).to have_http_status "200"
  end
end

メソッドに切り出して読みやすくする

これは「シングルレベルの抽象化を施したテスト(testing at a single level of abstraction)」として知られるテクニックです。

scenario "user toggles a task", js: true do
  go_to_project "RSpec tutorial"
end

def go_to_project(name)
  visit root_path
  click_link name
end

もしかするとプログラミングを知らない人がこのテストを読んでも、何をやっているのかある程度理解できるかもしれません。

速いテストをより素早く書くには

実行速度の速いスペックをより素早く書くにはどうすればいいでしょうか。

  • 構文を簡単かつきれいにしてスペックをより短くする
    • Shoulda Matchersを使えば1行に短縮できる
    • テストファーストで書く時に簡単
it { is_expected.to validate_presence_of :first_name }
  • お気に入りエディタを活用してキー入力を減らす
  • モックとスタブでボトルネックを切り離す
    • モックはデータベースにアクセスしない代役
    • スタブは呼び出されるとテスト用に本物の結果を返すダミーメソッド
  • 遅いスペックを除外するタグを使う
    • focus: trueを使って実行するスペックを限定する
    • 実行時に~をつければタグ以外の全テストを実行できる
    • タグはコミット前に忘れず削除する
    • 日常的に必要ない場合はskipを使う
  • テスト全体をスピードアップさせるテクニック
    • テストを並列に実行する

その他のテスト

アップロード機能のテスト

attach_fileメソッドを使います。アップロードするファイルは忘れずにバージョン管理システムにコミットしましょう。

バックグラウンドワーカーのテスト

queue_adapterをセットします。

before do
  ActiveJob::Base.queue_adapter = :test
end

ブロックスタイルのexpectと組み合わせてテストします。

expect {
  GeocodeUserJob.perform_later(user)
}.to have_enqueued_job.with(user)

メール送信のテスト

二つのレベルでテストします。書き方は他のテストとそんなに変わりません。

  • メールが正しく作られているか
  • 正しい宛先に送られているか

送信されたmailを取得してフィーチャースペックでテストすることもできます。

mail = ActionMailer::Base.deliveries.last

メーラースペック、フィーチャースペック、モデルスペックのどこでどれだけテストをするかは開発者次第です。選択肢がいろいろあります。

外部のAPIを叩くテスト

外部のAPIを叩く場合気をつける点があります。

  • そのテストだけ遅くなる
  • レートリミットを超えたとたんエラーが発生する

VCRgemWebMockを使うことで回避できます。WebMockはHTTPをスタブ化してくれるライブラリです。

gem 'vcr'
gem 'webmock'

設定ファイルで設定してから、テストにvcr: trueにすることで有効化できます。レスポンスの内容はspec/cassettesに保存されます。

私は VCR が大好きですが、VCR には短所もあります。特に、カセットが古びてしまう問題には注意が必要です。……もしテストに使っている外部 API の仕様が変わってしまっても、あなたはカセットが古くなっていることを知る術がない、ということです。

これについての対処法としては、

  • バージョン管理にカセットファイルを含めない
  • 一定頻度でカセットを再記録する

二つ目の注意点として、API のシークレットトークンやユーザーの個人情報といった機密情報 をカセットに含めないようにしてください。

読後のまとめ

RSpecによるテストの方法が網羅的かつ実戦形式で記載されていたので理解しやすかったです。頭から最後まで写経しながら通読することでRSpecで行なうテストの全体像がしっかり頭に入るようになっています。とても親しみやすい文体で書かれていて、途中で頭をひねようなこともありません。いい本でした。