2019-08-29

現場で使えるRSpecパターン集 for Rails App

はじめに

RSpecは慣れるととても手に馴染むテスティングツールだが、割と癖があってRSpecでテストを書くのに苦労している人も多いのではないだろうか。

自分はまさにそうで、書きたいテストは決まっていてもそれをどう書けばよいか、というところで当初は時間がかかっていたように思う。

実際に実務で何年かRSpecを使ってきて、よく使うパターン(型)のようなものができてきたので、それらをここにまとめてみようと思う。同じように「どう書けばいいか」で躓いている人や書き方をド忘れしてしまった人の助けになれば幸いである。

前提として、Railsアプリケーションを想定した内容になっている。

検証環境、ライブラリ

  • macOS Mojave
  • rspec : 3.8.2
  • ruby : 2.6.3
  • rails : 5.2.3

基本方針

便宜上、以下の方針でパターンを書く。

  • subject でテストの主体を明示する
  • it の引数は省略する
  • FactoryBotwebmock といったライブラリは使わない

Request spec

ステータスコードが xxx であること

# ステータスコード200を期待する場合
subject { get xxx_path }

it do
  is_expected.to eq 200
end

または

subject { get xxx_path }

it do
  expect(response).to have_http_status(200)
end

レスポンスボディに xxx が含まれていること

subject { get xxx_path }

it do
  subject
  expect(response.body).to include('xxx')
end

データが新規作成されていること

Fooモデルの件数が1増えていることを確認

subject { post xxx_path }

it do
  expect { subject }.to change(Foo, :count).by(1)
end

データが更新されていること

fooのvalue属性が "from" から "to" になることを確認

# 実際にはパラメータを渡して、fooが更新対象となるようにするだろう
subject { put xxx_path } 

let(:foo) { Foo.find(1) } # 更新対象のインスタンス

it do
  expect { subject }.to change(foo, :value).from('from').to('to')
end

データが削除されていること

Fooモデルの件数が1減っていることを確認

subject { delete xxx_path }

it do 
  expect { subject }.to change(Foo, :count).by(-1)
end

リダイレクトされること

subject { get xxx_path }

# yyy_pathにリダイレクトされることをテスト
it do
  expect { subject }.to redirect_to(yyy_path)
end

ファイルのダウンロードができること

レスポンスヘッダの Content-Disposition から確認する

subject { get xxx_path }

# 通常、ファイルダウンロード時はContent-Dispositionヘッダが次のような形式になることから
# Content-Disposition: attachment; filename="YOUR_FILENAME.pdf"
it do
  subject
  expect(response.headers['Content-Disposition']).to include('attachment')
  expect(response.headers['Content-Disposition']).to include('YOUR_FILENAME.pdf')
end

Model spec

次のようなFooモデルクラスがあるとする。

class Foo < ApplicationRecord
  validates :value, presence: true
end

バリデーションにパスすること

subject { foo.valid? }

let(:foo) { Foo.new(value: 'abc') }

it do
  is_expected.to be true
end

バリデーションエラーとなること

subject { foo.valid? }

let(:foo) { Foo.new(value: nil) }

it do
  is_expected.to be false
end

バリデーションエラーとなり、期待するエラーが発生していること

subject { foo.valid? }

let(:foo) { Foo.new(value: nil) }

# 指定した属性でエラーが起きていることをテスト
it do
  subject
  expect(foo.errros).to include(:value)
end

# 指定した属性でエラーが起きており、かつメッセージも期待通りであることをテスト
it do
  subject
  expect(foo.errros.full_messages_for(:value)).to include("can't be blank")
end

Job spec

次のようなSampleJobクラスがあり、sample_job という名称でキューが登録されるものとする。

# app/jobs/sample_job.rb
class SampleJob < Application
  queues_as :sample_job

  def perform(name)
    puts "Hello, #{name}!"
  end
end

ジョブがエンキューされること

subject { SampleJob.perform_later('Bob') }

# subjectを実行することでエンキューされることをテスト
it do
  expect { subject }.to have_enqueued_job(SampleJob).with('Bob').on_queue('sample_job')
end

ジョブが実行され、期待する動作をすること

#perform_enqueued_job によりジョブが同期的に実行されるため、その後に期待する状態をテストすればOK。
※ 参考 : https://api.rubyonrails.org/v5.2.3/classes/ActiveJob/TestHelper.html#method-i-perform_enqueued_jobs

subject { SampleJob.perform_later('Bob') }

it do
  perform_enqueued_jobs { subject }

  # ジョブ実行後に期待する振る舞いを以下に書く
end

Mailer spec

次のようなSampleMailerクラスがあり、#send_mail でメールが送信されるものとする。

# app/mailers/sample_mailer.rb
class SampleMailer < ApplicationMailer
  default from: 'from@example.com'

  def send_mail
    mail(to: 'to@example.com', subject: 'title', body: 'body')
  end
end

メール送信処理が実行すること

通常、テスト環境では実際にメールを送信せず、送信されたはずのメールは ActionMailer::Base.deliveries から参照できる。
※ 参考 : https://railsguides.jp/testing.html#メイラーをテストする

subject { SampleMailer.send_mail }

it do 
  expect { subject }.to change(ActionMailer::Base.deliveries, :count).by(1)
end

送り主(from)が期待通りであること

subject { SampleMailer.send_mail }

it do
  mail = subject
  expect(mail.from).to eq 'from@example.com'
end

宛先(to)が期待通りであること

subject { SampleMailer.send_mail }

it do
  mail = subject
  expect(mail.to).to eq 'to@example.com'
end

タイトルが期待通りであること

subject { SampleMailer.send_mail }

it do
  mail = subject
  expect(mail.subject).to eq 'title'
end

本文が期待通りであること

subject { SampleMailer.send_mail }

it do
  mail = subject
  expect(mail.body).to eq 'body'
end

モック

外部APIを呼び出すオブジェクトを使っている場合などに使う。

オブジェクトのメソッド呼び出しをモックする

let(:my_obj) { instance_double('MyObj') }
 
before do 
  allow(my_obj).to receive(:my_method).and_return('Hello, world!')
  allow(MyObj).to receive(:new).and_return(my_obj)
end

it do
  my_obj = MyObj.new
  expect(my_obj.my_method).to eq 'Hello, world!'
end

期待するメソッドが呼び出されていること

subject { ... }

let(:my_obj) { instance_double('MyObj') }
 
before do 
  allow(my_obj).to receive(:my_method).and_return('Hello, world!')
  allow(MyObj).to receive(:new).and_return(my_obj)
end

it do
  subject
  expect(my_obj).to have_received(:my_method).once
end

共通

その他、Specの種類によらず共通でよく使うパターンを少しだけ。

例外を投げること

subject { 1 / 0 }

it do
  expect { subject }.to raise_error(ZeroDivisionError)
end

期待するメッセージとともに例外を投げること

subject { 1 / 0 }

it do
  expect { subject }.to raise_error(ZeroDivisionError).with('divided by 0')
end

オブジェクトが期待する属性を持っていること

subject { OpenStruct.new(name: 'Bob') }

it do
  expect(subject).to have_attributes(name: 'Bob')
end

配列が期待通りの構造であること

subject { [1, 10, 'Hello, world!'] }

it do
   expect(subject).to match [
     1, 10, 'Hello, world!'
   ]
end

Composing Mathcer を使えばより柔軟なテストができる。

subject { [1, 10, 'Hello, world!'] }

it do
   expect(subject).to match [
     eq(1),
     a_kind_of(Integer),
     match(/^Hello/),
   ]
end

Hashが期待するキーを持つこと

subject { { key: 'value' } }

it do
  expect(subject).to include(:key)
end

Hashが期待するキーバリューを持つこと

subject { { key: 'value' } }

it do
  expect(subject).to include(key: 'value')
end

Hashの配列が全て期待するキーを持つこと

subject do
  [
    { key: 'value1' },
    { key: 'value2' },
    { key: 'value3' },
  ]
end

it do
  expect(subject).to all(include(:key))
end

正規表現にマッチすること

subject { '123' }

it do
  expect(subject).to match(/^[0-9]+$/)
end

nilであること

subject { nil }

it do
  expect(subject).to be_nil
end

be_nil に限らず、nil? のような ? で終わるメソッドは be_xxx として使うことができる。
※ 参考 : https://relishapp.com/rspec/rspec-expectations/v/3-8/docs/built-in-matchers/predicate-matchers

あるクラスのサブクラスのインスタンスであること

subject { 'Hello, world!' }

it do
  expect(subject).to be_a(String) # => success
  expect(subject).to be_a(Object) # => success
end

あるクラスの直接のインスタンスであること

subject { 'Hello, world!' }

it do
  expect(subject).to be_an_instance_of(String)  # => success
  expect(subject).to be_an_instance_of(Object) # => fail
end