Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: ActiveModelSerializersでAPIを作る--Part 2 RSpec編(翻訳)

part1は以下をご覧ください。

Rails: ActiveModelSerializersでAPIを作る–Part 1(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

※元記事はRails 5.0.1以降を使っています。

Rails: ActiveModelSerializersでAPIを作る--Part 2 RSpec編(翻訳)

本シリーズの前回の記事では、Rails 5でソリッドなAPIを構築する手順について解説しました。今回は、構築したAPIのテストと、RSpecで読みやすいクリーンなテストを書く方法について解説いたします。

テストはアプリにおけるきわめて重要な位置を占めています。テストがなければ、アプリのどの部分が正常に動作しているかを調べるだけでも大変になってしまい、コードのリファクタリングはもちろん、既存の機能を損なわずに新機能を追加することすら難しくなってしまいます。
十分に書かれたSpecは、システムのドキュメントにも似ています。そうしたSpecには、アプリの各部分やメソッドがどのように振る舞うかが記述されているものです。

RSpecのセットアップ

テストフレームワークには、MiniTestではなくRSpecを使います。以下のように、RSpec、FactoryGirl、ShouldaMatchersを追加してください。

source 'https://rubygems.org'

git_source(:github) do |repo_name|
  repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?("/")
  "https://github.com/#{repo_name}.git"
end

gem 'rails', '~> 5.0.1'
gem 'pg', '~> 0.18'
gem 'puma', '~> 3.0'
gem 'active_model_serializers', '~> 0.10.0'
gem 'rack-cors'
gem 'rack-attack'
gem 'will_paginate'
gem 'pundit'

group :development, :test do
  gem 'pry-rails'
  gem 'faker'
  gem 'rspec-rails', '~> 3.5'
end

group :test do
  gem 'factory_girl_rails', '~> 4.0'
  gem 'shoulda-matchers', '~> 3.1'
  gem 'simplecov', require: false
end

group :development do
  gem 'bullet'
  gem 'listen', '~> 3.0.5'
  gem 'spring'
  gem 'spring-watcher-listen', '~> 2.0.0'
end
$ bundle install
  • RSpec: RubyやRailsアプリ向けのテスティングフレームワークです。RSpecを使うと、Specをとても簡単に書けるようになります。また、アプリのフロントエンド部分をテストしたい場合は、Capybara gemを追加することもできます。CapybaraはRSpecとシームレスに統合されています(訳注: CapybaraはRails 5.1に統合されましたが、本記事のGemfileには含まれていません)。

  • FactoryGirl: テストで使うファクトリー(テスト用データ)を作成します。ここでは基本的にレコードを作成します。

  • ShouldMatchers: 関連付けやバリデーション、一部のコントローラメソッドのテストを支援します。

システムの各部分について理解できたら、RSpecをインストールして初期化しましょう。

$ rails generate rspec:install

RSpecが正常に動作することを確認します。

$ rspec

Gemsのセットアップ

今回の場合、Gemfileにgemを追加するほかに、アプリに若干の設定を追加する必要もあります。
spec/rails_helper.rbに以下を追記して、FactoryGirlを有効にします。

...

RSpec.configure do |config|
  ...

  config.include FactoryGirl::Syntax::Methods

  ...
end

以下のShouldaMatchers用の設定もspec/rails_helper.rbの末尾に追加します。

Shoulda::Matchers.configure do |config|
  config.integrate do |with|
    with.test_framework :rspec
    with.library :active_record
    with.library :active_model
  end
end

Punditのテストメソッドの追加も必要です。spec/rails_helper.rbの最上部に以下を追加します。

require 'pundit/rspec'

ファクトリーを追加する

それでは必要なファクトリーを準備しましょう。admin、user、author、book、book_copyについてそれぞれファクトリーが必要です。spec/フォルダの下にfactories/というフォルダを作成し、最初のファクトリーとしてadmin_factory.rbを追加します。

訳注: RSpecやFactoryGirlなどを事前にインストールしてからgenerateすると、必要なspecファイルやfactoryファイルも同時に作成されます。

# spec/factories/admin_factory.rb
FactoryGirl.define do
  factory :admin, class: 'User' do
    admin true
    first_name 'Piotr'
    last_name 'Jaworski'
    sequence(:email) { |i| "my-email-#{i}@mail.com" }
  end
end

見てのとおり、ファクトリーのコードはきわめてシンプルです。必要な属性をすべて列挙し、値を指定するだけで定義できます。メールの定義にはsequenceを使っていますが、これは何だかおわかりでしょうか?ファクトリーが作成されるたびに、各レコードにシーケンス値が保存されます。メールアドレスが重複すると困るので、sequenceを使ってメールアドレスが一意になるようにしています。

必要な他のファクトリーも追加してください。

# spec/factories/user_factory.rb
FactoryGirl.define do
  factory :user do
    first_name 'Dummy'
    last_name 'User'
    sequence(:email) { |i| "dummy.user-#{i}@gmail.com" }
  end
end
# spec/factories/author_factory.rb
FactoryGirl.define do
  factory :author do
    first_name 'Dummy'
    sequence(:last_name) { |i| "Author #{i}" }
  end
end
# spec/factories/book_copy.rb
FactoryGirl.define do
  factory :book_copy do
    sequence(:isbn) { |i| "0000#{i}" }
    format 'hardback'
    published Date.today - 5.years
    association(:book)
  end
end
# spec/factories/book_factory.rb
FactoryGirl.define do
  factory :book do
    association(:author)
    sequence(:title) { |i| "Book #{i}" }
  end
end

モデルSpec

これで最初のSpecを書く準備ができました。早速やってみましょう。Authorクラス内のすべての関連付け、バリデーション、メソッドをテストする必要があります。specs/modelsディレクトリを作成し、以下の内容でauthor_spec.rbファイルを作成します。

# spec/models/author_spec.rb
require 'rails_helper'

describe Author do
  subject { create(:author) }

  describe 'associations' do
    it { should have_many(:books) }
  end

  describe 'validations' do
    it { should validate_presence_of(:first_name) }
    it { should validate_presence_of(:last_name) }
  end
end

何がテストされているかおわかりでしょうか?ここでは、バリデーションと関連付けがすべて揃っているかどうかがテストされています。試しにモデルでバリデーションか関連付けをひとつコメントアウトしてみると、以下のようにテストが失敗することがわかります。

1) Author associations should have many books
Failure/Error: it { should have_many(:books) }
Expected Author to have a has_many association called books (no association called books)
# ./spec/models/author_spec.rb:7:in `block (3 levels) in <top (required)>'

次はBookCopyクラスのテストですが、関連付けやバリデーションのテストだけでは足りません。#borrowメソッドと#return_bookメソッドがあるので、これらのテストの必要です。メソッドの場合、「成功」と「失敗」の2つのシナリオをテストする必要があります。book_copy_spec.rbを書いてみましょう。

# spec/models/book_copy_spec.rb
require 'rails_helper'

describe BookCopy do
  let(:user) { create(:user) }
  let(:book_copy) { create(:book_copy) }

  describe 'associations' do
    subject { book_copy }

    it { should belong_to(:book) }
    it { should belong_to(:user) }
  end

  describe 'validations' do
    subject { book_copy }

    it { should validate_presence_of(:isbn) }
    it { should validate_presence_of(:published) }
    it { should validate_presence_of(:format) }
    it { should validate_presence_of(:book) }
  end

  describe '#borrow' do
    context 'book is not borrowed' do
      subject { book_copy.borrow(user) }

      it { is_expected.to be_truthy }
    end

    context 'book is borrowed' do
      before { book_copy.update_column(:user_id, user.id)  }

      subject { book_copy.borrow(user) }

      it { is_expected.to be_falsy }
    end
  end

  describe '#return_book' do
    context 'book is borrowed' do
      before { book_copy.update_column(:user_id, user.id)  }

      subject { book_copy.return_book(user) }

      it { is_expected.to be_truthy }
    end

    context 'book is not borrowed' do
      subject { book_copy.return_book(user) }

      it { is_expected.to be_falsy }
    end
  end
end

お気づきの方もいらっしゃるかと思いますが、私はワンライナーで書くのが好きです。私にはワンライナーの方がクリアかつ読みやすく思えます。私の場合、冒頭でletを使って変数宣言するルールにしています。その次にbeforeブロックやafterブロックを呼び出し、最後にsubjectを宣言します。このルールは私にとってメンテや編成がやりやすく、かつ読みやすいコードになります。

BookSpecはBookCopySpecとほとんど同じですが、静的なクラスメソッドのテストが必要です。

# spec/models/book_spec.rb
require 'rails_helper'

describe Book do
  let(:book) { create(:book) }

  describe 'associations' do
    subject { book }

    it { should have_many(:book_copies) }
    it { should belong_to(:author) }
  end

  describe 'validations' do
    subject { book }

    it { should validate_presence_of(:title) }
    it { should validate_presence_of(:author) }
  end

  describe '.per_page' do
    subject { described_class.per_page }

    it { is_expected.to eq(20) }
  end
end

UserSpecはこれまでと少し異なり、before_saveコールバックのテストが必要です。これを行うには、何かメソッドが呼び出されたかどうかをexpect(instance).to receive(:メソッド名)を使ってチェックします。

もうひとつ、メソッドが期待どおり動作しているかについてもテストしています。ここでは、インスタンス保存時の前と後の挙動をチェックしています。

# spec/models/user_spec.rb
require 'rails_helper'

describe User do
  let(:user) { create(:user) }

  describe 'associations' do
    subject { user }

    it { should have_many(:book_copies) }
  end

  describe 'validations' do
    subject { user }

    it { should validate_presence_of(:first_name) }
    it { should validate_presence_of(:last_name) }
    it { should validate_presence_of(:email) }
  end

  describe '#generate_api_key' do
    let(:user) { build(:user) }

    it 'is called before save' do
      expect(user).to receive(:generate_api_key)
      user.save
    end

    it 'generates random api key' do
      expect(user.api_key).to be_nil
      user.save
      expect(user.api_key).not_to be_nil
      expect(user.api_key.length).to eq(40)
    end
  end
end

コントローラSpec

もっとも重要なのはコントローラのSpecです。エンドポイント側で正常に動作することをテストしなければなりません。Specがないと、メソッドの変更やリファクタリングがとても面倒になります。

Specのもうひとつの責務は、各エンドポイントがどのように振る舞うべきかを示すことです。各エンドポイントの動作がユーザーのロールごとに異なる場合は、考えられるすべての場合をテストすべきです。ここでは、許可を得ていないユーザーが重要なデータにアクセスできないようにしたいと考えています。それではAuthorsControllerのSpecを書いてみましょう。

最初の#indexメソッドで必要なテストは、adminだけがアクセス可能になっているかどうかと、レコードが有効なJSONフォーマットで返されるかどうかです。HTTPトークンを渡すために、以下のbeforeブロックを追加します。

before { request.env['HTTP_AUTHORIZATION'] = "Token token=#{api_key}" }
# spec/controllers/authors_controller_spec.rb
require 'rails_helper'

describe V1::AuthorsController do
  let(:admin) { create(:admin) }
  let(:user) { create(:user) }
  let(:author) { create(:author) }

  before { request.env['HTTP_AUTHORIZATION'] = "Token token=#{api_key}" }

  describe '#index' do
    subject { get :index }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      before { author }

      it { is_expected.to be_successful }
      it 'returns valid JSON' do
        body = JSON.parse(subject.body)
        expect(body['authors'].length).to eq(1)
        expect(body['meta']['pagination']).to be_present
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end
end

次は#showメソッドのテストです。これも一般ユーザーからアクセスできない(adminのみアクセス可能)ようになっている必要があります。シリアライザで指定された属性がJSONで返される必要もあります。

# spec/controllers/authors_controller_spec.rb
...

  describe '#show' do
    subject { get :show, params: { id: author.id } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it { is_expected.to be_successful }

      it 'returns valid JSON' do
        subject
        expect(response.body).to eq({ author: AuthorSerializer.new(author).attributes }.to_json)
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

...

#createメソッドも同様に、adminだけがアクセス可能です。バリデーションが正常に動作することを確認するために、パラメータが有効な場合と無効な場合の両方のシナリオをテストする必要もあります。

# spec/controllers/authors_controller_spec.rb
...

  describe '#create' do
    let(:author_params) { { first_name: 'First name' } }

    subject { post :create, params: { author: author_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:author_params) { { first_name: 'First name', last_name: 'Last name' } }

        it { is_expected.to be_created }

        it 'creates an author' do
          expect { subject }.to change(Author, :count).by(1)
        end
      end

      context 'with invalid params' do
        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

...

#updateメソッドのフローは、#createメソッドとほとんど同じです。

# spec/controllers/authors_controller_spec.rb
...

  describe '#update' do
    let(:author_params) { {} }

    subject { put :update, params: { id: author.id, author: author_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:author_params) { { first_name: 'Foo' } }

        it 'updates requested record' do
          subject
          expect(author.reload.first_name).to eq(author_params[:first_name])
          expect(response.body).to eq({ author: AuthorSerializer.new(author.reload).attributes }.to_json)
        end

        it { is_expected.to be_successful }
      end

      context 'with invalid params' do
        let(:author_params) { { first_name: nil } }

        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

...

#destroyメソッドの場合、ユーザーがadminの場合のみレコードを削除できます。

繰り返しになりますが、私はワンライナーで各エンドポイントのHTTPステータスをテストしています。たとえば次のような感じです。

it { is_expected.to be_no_content }
it { is_expected.to be_unauthorized }

しかし、メソッドのHTTPステータスによっては(HTTP 422など)、テストをこの方法では書けないこともあります。もし次のように書ける方法があれば私が知りたいぐらいですが、まだ見たことがありません。

it { is_expected.to be_unprocessable_entity }

このような場合は、次のようにHTTPステータス名を直接渡す必要があります。

it { is_expected.to have_http_status(:unprocessable_entity) }

AuthorControllerのSpec全体は次のようになります。チュートリアルの途中でわからなくなったら、こちらをどうぞ。

# spec/controllers/authors_controller_spec.rb
require 'rails_helper'

describe V1::AuthorsController do
  let(:admin) { create(:admin) }
  let(:user) { create(:user) }
  let(:author) { create(:author) }

  before { request.env['HTTP_AUTHORIZATION'] = "Token token=#{api_key}" }

  describe '#index' do
    subject { get :index }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      before { author }

      it { is_expected.to be_successful }
      it 'returns valid JSON' do
        body = JSON.parse(subject.body)
        expect(body['authors'].length).to eq(1)
        expect(body['meta']['pagination']).to be_present
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#show' do
    subject { get :show, params: { id: author.id } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it { is_expected.to be_successful }

      it 'returns valid JSON' do
        subject
        expect(response.body).to eq({ author: AuthorSerializer.new(author).attributes }.to_json)
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#create' do
    let(:author_params) { { first_name: 'First name' } }

    subject { post :create, params: { author: author_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:author_params) { { first_name: 'First name', last_name: 'Last name' } }

        it { is_expected.to be_created }

        it 'creates an author' do
          expect { subject }.to change(Author, :count).by(1)
        end
      end

      context 'with invalid params' do
        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#update' do
    let(:author_params) { {} }

    subject { put :update, params: { id: author.id, author: author_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:author_params) { { first_name: 'Foo' } }

        it 'updates requested record' do
          subject
          expect(author.reload.first_name).to eq(author_params[:first_name])
          expect(response.body).to eq({ author: AuthorSerializer.new(author.reload).attributes }.to_json)
        end

        it { is_expected.to be_successful }
      end

      context 'with invalid params' do
        let(:author_params) { { first_name: nil } }

        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#destroy' do
    subject { delete :destroy, params: { id: author.id } }

    before { author }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it 'removes requested record' do
        expect { subject }.to change(Author, :count).by(-1)
      end

      it { is_expected.to be_no_content }
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end
end

BookCopiesController、BooksController、UsersControllerはAuthorsControllerとほとんど同じです。チュートリアルでの説明は省略いたしますが、それぞれのファイルは次のとおりです。

  • BookCopiesControllerSpec:
# spec/controllers/book_copies_controller_spec.rb
require 'rails_helper'

describe V1::BookCopiesController do
  let(:admin) { create(:admin) }
  let(:user) { create(:user) }
  let(:book_copy) { create(:book_copy) }
  let(:book) { create(:book) }

  before { request.env['HTTP_AUTHORIZATION'] = "Token token=#{api_key}" }

  describe '#index' do
    subject { get :index }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      before { book_copy }

      it { is_expected.to be_successful }
      it 'returns valid JSON' do
        body = JSON.parse(subject.body)
        expect(body['book_copies'].length).to eq(1)
        expect(body['meta']['pagination']).to be_present
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#show' do
    subject { get :show, params: { id: book_copy.id } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it { is_expected.to be_successful }

      it 'returns valid JSON' do
        subject
        expect(response.body).to eq({ book_copy: BookCopySerializer.new(book_copy).attributes }.to_json)
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#create' do
    let(:book_copy_params) { { isbn: '00001' } }

    subject { post :create, params: { book_copy: book_copy_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:book_copy_params) { { isbn: '00001', published: Date.today, book_id: book.id, format: 'hardback' } }

        it { is_expected.to be_created }

        it 'creates an book_copy' do
          expect { subject }.to change(BookCopy, :count).by(1)
        end
      end

      context 'with invalid params' do
        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#update' do
    let(:book_copy_params) { {} }

    subject { put :update, params: { id: book_copy.id, book_copy: book_copy_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:book_copy_params) { { isbn: '0000033' } }

        it 'updates requested record' do
          subject
          expect(book_copy.reload.isbn).to eq(book_copy_params[:isbn])
          expect(response.body).to eq({ book_copy: BookCopySerializer.new(book_copy.reload).attributes }.to_json)
        end

        it { is_expected.to be_successful }
      end

      context 'with invalid params' do
        let(:book_copy_params) { { isbn: nil } }

        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#destroy' do
    subject { delete :destroy, params: { id: book_copy.id } }

    before { book_copy }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it 'removes requested record' do
        expect { subject }.to change(BookCopy, :count).by(-1)
      end

      it { is_expected.to be_no_content }
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end
end
  • BooksControllerSpec:
# spec/controllers/books_controller_spec.rb
require 'rails_helper'

describe V1::BooksController do
  let(:admin) { create(:admin) }
  let(:user) { create(:user) }
  let(:book) { create(:book) }
  let(:author) { create(:author) }

  before { request.env['HTTP_AUTHORIZATION'] = "Token token=#{api_key}" }

  describe '#index' do
    subject { get :index }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      before { book }

      it { is_expected.to be_successful }
      it 'returns valid JSON' do
        body = JSON.parse(subject.body)
        expect(body['books'].length).to eq(1)
        expect(body['meta']['pagination']).to be_present
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#show' do
    subject { get :show, params: { id: book.id } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it { is_expected.to be_successful }

      it 'returns valid JSON' do
        subject
        expect(response.body).to eq({ book: BookSerializer.new(book).attributes }.to_json)
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#create' do
    let(:book_params) { { title: nil } }

    subject { post :create, params: { book: book_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:book_params) { { title: 'Title', author_id: author.id } }

        it { is_expected.to be_created }

        it 'creates a book' do
          expect { subject }.to change(Book, :count).by(1)
        end
      end

      context 'with invalid params' do
        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#update' do
    let(:book_params) { {} }

    subject { put :update, params: { id: book.id, book: book_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:book_params) { { title: 'Title' } }

        it 'updates requested record' do
          subject
          expect(book.reload.title).to eq(book_params[:title])
          expect(response.body).to eq({ book: BookSerializer.new(book.reload).attributes }.to_json)
        end

        it { is_expected.to be_successful }
      end

      context 'with invalid params' do
        let(:book_params) { { title: nil } }

        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#destroy' do
    subject { delete :destroy, params: { id: book.id } }

    before { book }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it 'removes requested record' do
        expect { subject }.to change(Book, :count).by(-1)
      end

      it { is_expected.to be_no_content }
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end
end
  • UsersControllerSpec:
# spec/controllers/users_controller_spec.rb
require 'rails_helper'

describe V1::UsersController do
  let(:admin) { create(:admin) }
  let(:user) { create(:user) }

  before { request.env['HTTP_AUTHORIZATION'] = "Token token=#{api_key}" }

  describe '#index' do
    subject { get :index }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      before { user }

      it { is_expected.to be_successful }
      it 'returns valid JSON' do
        body = JSON.parse(subject.body)
        expect(body['users'].length).to eq(2)
        expect(body['meta']['pagination']).to be_present
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#show' do
    subject { get :show, params: { id: user.id } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it { is_expected.to be_successful }

      it 'returns valid JSON' do
        subject
        expect(response.body).to eq({ user: UserSerializer.new(user).attributes }.to_json)
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#create' do
    let(:user_params) { { first_name: nil } }

    subject { post :create, params: { user: user_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:user_params) { { first_name: 'Name', last_name: 'Last', email: 'foo@bar.com' } }

        it { is_expected.to be_created }

        it 'creates a user' do
          expect { subject }.to change(User, :count).by(1)
        end
      end

      context 'with invalid params' do
        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#update' do
    let(:user_params) { {} }

    subject { put :update, params: { id: user.id, user: user_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:user_params) { { last_name: 'Last' } }

        it 'updates requested record' do
          subject
          expect(user.reload.last_name).to eq(user_params[:last_name])
          expect(response.body).to eq({ user: UserSerializer.new(user.reload).attributes }.to_json)
        end

        it { is_expected.to be_successful }
      end

      context 'with invalid params' do
        let(:user_params) { { first_name: nil } }

        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#destroy' do
    subject { delete :destroy, params: { id: user.id } }

    before { user }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it 'removes requested record' do
        expect { subject }.to change(User, :count).by(-1)
      end

      it { is_expected.to be_no_content }
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end
end

続いて、ユーザーとadminが両方ともアクセス可能なメソッドに取りかかりたいと思います。BookCopiesControllerの#borrow#return_bookメソッドです。

ここではテストケースがたくさんあります。最初に#borrowメソッドの動作を確認しましょう。adminは、user_idパラメータを渡すことで本を借りられます。このパラメータがない場合、adminは本を借りられません。本が貸出中の場合、book_copyは借りられません。一般ユーザーの場合、本が貸出中でなければ本を借りられます。いたってシンプルです。

次は#return_bookメソッドです。adminは、user_idパラメータがある場合のみ本を返却できます。adminの特権として、adminのuser_idは、book_copyのuser_idと一致する必要はありません。貸出中でない本はもちろん返却できません。

一般ユーザーは、自分で借りた本だけを返却できます。自分が借りていない本は返却できません。

# spec/controllers/book_copies_controller_spec.rb
require 'rails_helper'

describe V1::BookCopiesController do        
  ...

  describe '#borrow' do
    subject { put :borrow, params: book_copy_params }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'without user_id param' do
        let(:book_copy_params) { { id: book_copy.id } }

        it { is_expected.to have_http_status(:unprocessable_entity) }
      end

      context 'with user_id param' do
        let(:book_copy_params) { { id: book_copy.id, user_id: user.id } }

        context 'book is not borrowed' do
          it { is_expected.to be_successful }
        end

        context 'book is borrowed' do
          before { book_copy.update_column(:user_id, user.id) }

          it { is_expected.to have_http_status(:unprocessable_entity) }
        end
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }
      let(:book_copy_params) { { id: book_copy.id } }

      context 'book is not borrowed' do
        it { is_expected.to be_successful }
      end

      context 'book is borrowed' do
        before { book_copy.update_column(:user_id, admin.id) }

        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end
  end

  describe '#return_book' do
    subject { put :return_book, params: book_copy_params }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'without user_id param' do
        let(:book_copy_params) { { id: book_copy.id } }

        it { is_expected.to have_http_status(:unprocessable_entity) }
      end

      context 'with user_id param' do
        let(:book_copy_params) { { id: book_copy.id, user_id: user.id } }

        context 'book is not borrowed' do
          it { is_expected.to have_http_status(:unprocessable_entity) }
        end

        context 'book is borrowed' do
          context 'user_id matches to a book_copy user_id' do
            before { book_copy.update_column(:user_id, user.id) }

            it { is_expected.to be_successful }
          end

          context 'user_id does not match to a book_copy user_id' do
            let(:another_user) { create(:user) }

            before { book_copy.update_column(:user_id, another_user.id) }

            it { is_expected.to be_successful }
          end
        end
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }
      let(:book_copy_params) { { id: book_copy.id } }

      context 'book is borrowed' do
        context 'current user is a user who borrowed a book' do
          before { book_copy.update_column(:user_id, user.id) }

          it { is_expected.to be_successful }
        end

        context 'current user is not a user who borrowed a book' do
          let(:another_user) { create(:user) }

          before { book_copy.update_column(:user_id, another_user.id) }

          it { is_expected.to be_forbidden }
        end
      end

      context 'book is not borrowed' do
        it { is_expected.to be_forbidden }
      end
    end
  end

PolicySpec

ポリシーはアプリの重要な部分なので、ポリシーについてもテストが必要です。なお、このアプリにはポリシーはひとつしかありません。Specを書いてみましょう。specs/policiesフォルダを作成し、book_copy_policy_spec.rbに以下の内容を追加します。

# spec/policies/book_copy_policy_spec.rb
require 'rails_helper'

describe BookCopyPolicy do
  let(:user) { create(:user) }

  subject { described_class }

  permissions :return_book? do
    context 'as admin' do
      it 'grants access if user is an admin' do
        expect(subject).to permit(Contexts::UserContext.new(nil, User.new(admin: true)), BookCopy.new)
      end
    end

    context 'as user' do
      it 'denies access if book_copy is not borrowed' do
        expect(subject).not_to permit(Contexts::UserContext.new(User.new, nil), BookCopy.new)
      end

      it 'grants access if book_copy is borrowed by a user' do
        expect(subject).to permit(Contexts::UserContext.new(user, nil), BookCopy.new(user: user))
      end
    end
  end
end

上のコードでは、adminと一般ユーザーの両方について#return_book?をテストしていますが、どちらのテストも必要です。Punditには、こうしたテストをRSpecでシンプルに書くのに便利なメソッドが用意されています。

テストのカバレッジ

テストでは、カバレッジも重要です。カバレッジはテストされたコードの行数を表示したり、テストでのカバーが必要な最小限の行数を表示したりします。SimpleCov gemはこうしたレポートを詳細に作成するのに便利で、カバレッジ率も出力してくれます。

Gemファイルのtestグループに以下を追加してSimpleCovをインストールします。

gem 'simplecov', require: false
$ bundle install

SimpleCovを使うために、rails_helper.rbの最上部に以下を追記します。

require 'simplecov'
SimpleCov.start

これで、テストを実行するとSimpleCovからレポートが出力されます。

$ rspec

テストのカバレッジ率は96.5%になりました。素晴らしい結果です。coverage/index.htmlファイルをブラウザで開けば詳細なレポートを見ることができます。Specでカバーされている各ファイルのカバレッジが表示されます。

最後に

第2回では、API向けにクリアで読みやすいテストの書き方を解説しました。アプリではテストが重要であることをぜひ肝に銘じてください。よいアプリには、アプリの各部分の挙動を説明するSpecが必ずあるものです。

本チュートリアルを皆さまが気に入ってくれれば幸いです。ソースコードはこちらで参照できます。

私たちのブログがお気に召しましたら、ぜひニュースレターを購読して今後の記事の更新情報をお受け取りください。質問がありましたら、元記事にいつでもご自由にコメントいただけます。

関連記事

Rails: ActiveModelSerializersでAPIを作る–Part 1(翻訳)

RSpecで役に立ちそうないくつかのヒント(翻訳)

Ruby: テストを不安定にする5つの残念な書き方(翻訳)

[Rails 5] rails newで常に使いたい厳選・定番gemリスト(2017-2018年版)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。