はじめに
3月頃,『【Rails】RSpecでWeb APIのテストでハマったところ①』という初心者丸出しな記事を書きました.
あれから9ヶ月ほど,お仕事としてRailsに触れたりしたため知見・スキルも向上してきたと思います.
そして今,前述の記事を見直したところ恥ずかしくて顔を覆いたくなる感じになったので改めてWeb APIのテストについて書いていきます.
APIのテスト?
そもそもWeb APIのテストはどこに書くの?ってところからですが…
Controller SpecではなくRequest Specに書いていきます.
Use RSpec Request Specs
Since we’ve established that we’ll be using Rack::Test to drive the tests, RSpec request specs make the most sense. There’s no need to get fancy and add extra weight to your testing tools for this.
Request specs provide a thin wrapper around Rails’ integration tests, and are designed to drive behavior through the full stack, including routing (provided by Rails) and without stubbing (that’s up to you).
Request Specって? だいたいこんな感じ.
request-specという、HTTPにおけるリクエストとレスポンスの組み合わせを、言わばブラックボックスとして扱うテスト形式の呼び名があります。リクエストを入力、レスポンスを出力として扱い、ある入力に対して期待される出力が返されるかどうかをテストします。
- RSpecでRequest Describer - Qiita
前準備
必要なGemとRSpecまわりの設定たち.
group :test do
gem 'rspec-rails'
gem 'factory_girl_rails'
gem 'database_rewinder'
gem 'rspec-request_describer'
gem 'autodoc'
gem 'json_spec'
end
Railsまわりの設定だからrails_helper.rb
…でいいのかな?
spec/rails_helper.rb
に Rails 特有の設定を書き、spec/spec_helper.rb
には RSpec の全体的な設定を書く、というようにお作法が変わるそうです。これによって、Railsを必要としないテストを書きやすくなるんだとか。
- RSpec 3 時代の設定ファイル rails_helper.rb について - willnet.in
RSpec.configure do |config|
config.include JsonSpec::Helpers
config.include RSpec::RequestDescriber, type: :request
config.include RequestHelpers, type: :request
config.include RequestMacros, type: :request
config.before :all do
FactoryGirl.reload
end
config.before :suite do
DatabaseRewinder.clean_all
end
config.after :each do
DatabaseRewinder.clean
end
Autodoc.configuration.toc = true
end
Request Spec三種の神器
Request SpecでWeb APIのテストをする際に,絶対に必要な超便利gemです(自分が勝手に言ってるだけですが…).
1つずつ紹介.
RequestDescriber でテストをキレイにシンプルに
@r7kamura氏が挙げるRequest Specの慣習をもとに,よりテストコードを簡略化するためのgemがRequestDescriberです(違ってたらごめんなさい).
- 1テストケースで1リクエスト
- 内部でRouter/Dispatcher、Controller、Modelがどうなっているかというのはテストしていない
- テストケースのネストの深さを均一にする ...(以下省略)
このライブラリでもっとも特徴的かつ重要な機能は以下になります.
describeメソッドを利用して記述した文章を元にHTTPリクエストを送ってくれる
- RSpecでRequest Describer - Qiita
簡単に言うとdescribe 'GET /users/:id' do ... end
としたときにsubject
内でリクエストを発行してくれる,というものになってます.
(以下のコードは元記事のshould
をexpect
に変えただけ)
# describeの内容を解析して subject { get "/users/#{id}" } がセットされる
describe 'GET /users/:id' do
let(:id) { FactoryGirl.create(:user).id }
context 'with invalid id' do
let(:id) { 'invalid_id' }
# is_expectedでsubjectを呼び出す
it { is_expected.to eq 404 }
end
context 'with valid id' do
# subjectを評価後,reponse.bodyの内容を検証
it 'returns a user' do
is_expected.to eq 200
res = response.body
expect(res).to ...
end
end
end
ただ,「これだとsubject
を呼ばないとresponse bodyが検証できない,1つのit
に1つのexpect
では?」という疑問を持つ方もいるかもしれません.実際僕はそう思ってツイートしたところ, @r7kamura氏から直接回答をいただくことが出来ました.
非常に丁寧に解説いただきありがとうございました.
@izumin5210 全てのテストケースで必ずstatusを検査するようにしようという意図があってそうしていました。subjectを評価するタイミングは少しシビアで、他にbeforeが定義されているとそちらを先に呼んでほしいため、ライブラリ側で評価することができませんでした
— コンテナ (@r7kamura) October 16, 2014
@izumin5210 確かに1つのテストケースで1つの検査をするのが理想的ですが、記述量と処理速度のコスパを考えてそこに落ち着いている感じです?
— コンテナ (@r7kamura) October 16, 2014
@izumin5210 request specが非常に重い処理であるということと、APIは正常系のレスポンスボディの形があまり変化しないこと、異常系のテストが増えがちでステータスコードが境界値となりやすいことからその辺りが落とし所になっています
— コンテナ (@r7kamura) October 16, 2014
Parameterは?
params
という変数に格納しておいたらそれを使ってくれる.
let(:params) do
{ user: { name: 'izumin5210' } }
end
FactoryGirlつかうならこんな感じ?
let(:params) do
{ user: FactoryGirl.attributes_for(:user) }
end
Headerは?
headers
に格納.
let(:headers) do
{ 'Content-Type' => 'application/json',
'Accept' => 'application/json'
}
end
以下の様なmacroを定義しておけば呼び出すだけでOK.
module RequestMacros
def self.included(base)
base.extend(ClassMethods)
end
module ClassMethods
def application_json
before do
set_content_type 'application/json'
set_accept 'application/json'
end
end
end
end
認証は?
下のようなmacroを作って呼び出したら未認証時のテストも出来る(もうちょいまともな書き方できるかも).
module RequestMacros
def self.included(base)
base.extend(ClassMethods)
end
module ClassMethods
def context_user_authenticated(*user_traits, &block)
context('with authentication') {
# 認証に関する処理をbeforeとかに突っ込んどく
}.class_eval &block
context 'without authentication' do
it { is_expected.to eq 401 }
end
end
end
end
この章のまとめ
RequestDescriberを使ったり,リクエスト時の共通処理をマクロに切り出すことで記述量を減らしてスリムで怠惰なテストを目指しましょう.
require 'rails_helper'
describe 'User resource', type: :request do
# Content-Type,Acceptの設定
application_json
describe 'GET /users' do
# 認証が必要なAPIのテスト
context_with_authenticated do
# テストケースはブロック内に書ける
it { is_expected.to eq 200 }
end
end
end
json_specでresponse検査
json_spec,@jnchitoさんおすすめgemです.
結構クセあるので最初は戸惑いましたが,今となっては手放せない感じ.
@izumin5210 ちなみに僕はjson_specっていうgemを使っています。最初は慣れるまでちょっと時間がかかったけど、コツをつかめばなかなか便利なツールです。 https://t.co/cVrDHrmhgl
— Junichi Ito (伊藤淳一) (@jnchito) March 24, 2014
describe 'GET /users/:id' do
let(:user) { FactoryGirl.create(:user) }
let(:id) { user.id }
context 'with valid id' do
it 'returns a user' do
is_expected.to eq 200
body = response.body
# {
# "user": {
# "name": "izumin5210",
# "email": "izumin5210@example.com"
# }
# }
# user というパスが存在するか?
expect(body).to have_json_path('user')
# user/id の内容は user.id と一致するか
expect(body).to be_json_eql(user.id).at_path('user/id')
# user/name の内容は "user.name" と一致するか(ダブルクオートも含め)
expect(body).to be_json_eql(%("#{user.name}")).at_path('user/name')
# ...
end
end
end
エラーの検査
エラーメッセージをrequest bodyに格納する場合,それを検査するためのマッチャ用意しとくと捗りません?
エラーメッセージを入れとくパスなんてアプリ内で統一していることが多そうなので…
ERROR_PATH = 'errors'
# エラーメッセージが存在するか( errors がbodyに含まれるか)
RSpec::Matchers.define :have_errors do |expected|
match do |actual|
expect(actual).to have_json_path(ERROR_PATH)
end
failure_message do
%(expected to have error\n) +
%(got no errors)
end
failure_message_when_negated do
%(expected to not have errors\n) +
%(got errors)
end
end
# 指定したパスにメッセージが入ってるか
RSpec::Matchers.define :have_error_messages do |expected|
match do |actual|
expect(actual).to have_errors
expect(actual).to have_json_path(@path)
messages = (expected.is_a? Array) ? expected : [expected]
messages = messages.map { |msg| %("#{msg}") }.join(',')
expect(actual).to be_json_eql(%([#{messages}])).at_path(@path)
end
failure_message do
%(expected "#{expected}"\n) +
%(got "#{JSON.parse(actual)[ERROR_PATH][MESSAGE_PATH]}")
end
failure_message_when_negated do
%(expected not "#{expected}"\n) +
%(got "#{JSON.parse(actual)[ERROR_PATH][MESSAGE_PATH]}")
end
chain :at_path do |path|
@path = "#{ERROR_PATH}/#{path}"
end
end
使い方はこんな感じ.
describe 'GET /users/:id' do
let(:id) { FactoryGirl.create(:user).id }
context 'with invalid id' do
let(:id) { 'invalid id' }
it do
is_expected.to eq 404
body = response.body
# エラーメッセージの検査
expect(body).to have_error_messages(%("そんなユーザおらへんでw")).at_path('user')
end
end
context 'with valid id' do
it 'returns a user' do
is_expected.to eq 200
res = response.body
# エラーメッセージがないことをチェック
expect(res).to_not have_errors
# expect(res).to ...
end
end
end
これが人生初マッチャでした.
この章のまとめ
@izumin5210 するべきかどうかはケースバイケースです・・・っていうと回答にならないので、yes/noで答えるならyesかな~。というのも画面に比べて、APIは問題の発生に気付きにくい&手作業+目視での確認が面倒なので、RSpecで品質を担保させた方が効率的だから。
— Junichi Ito (伊藤淳一) (@jnchito) March 24, 2014
autodoc でドキュメント自動生成
autodocはテストケースに:autodoc
ってつけるだけでテスト実行時にドキュメントを生成してくれる神gemです.
最近RSpec 3対応したようです.ありがとうございます!!!
describe 'GET /users/:id' do
let(:id) { FactoryGirl.create(:user).id }
context 'with valid id' do
it 'returns a user', :autodoc do
is_expected.to eq 200
res = response.body
expect(res).to_not have_errors
# expect(res).to ...
end
end
end
異常系のドキュメントまで生成してるとごちゃごちゃしちゃいそうなので,正常系だけでいいかな…
タグ代わりにもなりますし.
- 正常系にタグを付ける
この章のまとめ
autodocは神.
謝辞
RequestDescriber及びautodocの作者であり僕の疑問にも丁寧に答えてくださった @r7kamura さん,『Everyday Rails - RSpecによるRailsテスト入門』の訳者の1人であり超初心者だった僕にテストの基本を教えてくださった @jnchito さん,ありがとうございました.
参考文献
テスト全般
- Everyday Rails - RSpecによるRailsテスト入門(書籍)
- Rails でつくる API のテストの書き方(RSpec + FactoryGirl) - 彼女からは、おいちゃんと呼ばれています
- RSpec 3 時代の設定ファイル rails_helper.rb について - willnet.in