【Rails】RSpecと三種の神器でらくちんWeb APIテスト

  • 342
    いいね
  • 2
    コメント
この記事は最終更新日から1年以上が経過しています。

はじめに

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).

- Rails API Testing Best Practices

Request Specって? だいたいこんな感じ.

request-specという、HTTPにおけるリクエストとレスポンスの組み合わせを、言わばブラックボックスとして扱うテスト形式の呼び名があります。リクエストを入力、レスポンスを出力として扱い、ある入力に対して期待される出力が返されるかどうかをテストします。
- RSpecでRequest Describer - Qiita

前準備

必要なGemとRSpecまわりの設定たち.

Gemfile
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

rails_helper.rb
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内でリクエストを発行してくれる,というものになってます.
(以下のコードは元記事のshouldexpectに変えただけ)

# 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氏から直接回答をいただくことが出来ました.
非常に丁寧に解説いただきありがとうございました.

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です.
結構クセあるので最初は戸惑いましたが,今となっては手放せない感じ.

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

これが人生初マッチャでした.

この章のまとめ

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 さん,ありがとうございました.

参考文献

テスト全般

request specについて

autodoc関連

gemなど

この投稿は Ruby on Rails Advent Calendar 20149日目の記事です。