現場で使えるRSpecパターン集 for Rails App
はじめに
RSpecは慣れるととても手に馴染むテスティングツールだが、割と癖があってRSpecでテストを書くのに苦労している人も多いのではないだろうか。
自分はまさにそうで、書きたいテストは決まっていてもそれをどう書けばよいか、というところで当初は時間がかかっていたように思う。
実際に実務で何年かRSpecを使ってきて、よく使うパターン(型)のようなものができてきたので、それらをここにまとめてみようと思う。同じように「どう書けばいいか」で躓いている人や書き方をド忘れしてしまった人の助けになれば幸いである。
前提として、Railsアプリケーションを想定した内容になっている。
検証環境、ライブラリ
- macOS Mojave
- rspec : 3.8.2
- ruby : 2.6.3
- rails : 5.2.3
基本方針
便宜上、以下の方針でパターンを書く。
subject
でテストの主体を明示するit
の引数は省略するFactoryBot
やwebmock
といったライブラリは使わない
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