7
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

RSpec の便利な機能を使って、わかりやすく簡潔な API のテストを書こう!

Last updated at Posted at 2019-08-31

はじめに

自分がこれまで API 向けの RSpec を書いてきて、これは良いな!と思った記述の仕方をご紹介します。
うまく活用することで、わかりやすく簡潔な記述ができるかと思います。

RSpec と Ruby のバージョン

  • RSpec 3.8.0
  • Ruby 2.4.0

subject を活用する

条件が異なるテストがあり、同じ API 呼び出し (before { get '/users' }) が
複数箇所に現れてしまったそんなとき!

beforeをDRYにしたくなる...
  describe '#index' do
    context 'with 1 user' do
      let!(:user) { create(:user) }
      before { get '/users' }
      it { expect(response.status).to eq 200 }
    end

    context 'with 2 users' do
      let!(:users) { create_list(:user, 2) }
      before { get '/users' }
      it { expect(response.status).to eq 200 }
    end
  end

下記のようにした場合、API 呼び出しが条件設定前(ユーザー作成前)に実行されるので、
意図したテストになりません :disappointed:

beforeを括りだしたが意図とは異なるテストになってしまった…
  describe '#index' do
    before { get '/users' } # ユーザー作成前に実行されてしまう

    context 'with 1 user' do
      let!(:user) { create(:user) }
      it { expect(response.status).to eq 200 }
    end

    context 'with 2 users' do
      let!(:users) { create_list(:user, 2) }
      it { expect(response.status).to eq 200 }
    end
  end

subject & is_expected を活用すると、DRY にできます!
また、どの API 呼び出しに対するテストなのかも分かりやすくなると個人的に感じています :thumbsup:

subjectを活用した書き方
  describe '#index' do
    subject { get '/users' }

    context 'with 1 user' do
      let!(:user) { create(:user) } 
      it { is_expected.to eq 200 } # ここで subject が評価される
    end

    context 'with 2 users' do
      let!(:users) { create_list(:user, 2) }
      it { is_expected.to eq 200 } # ここで subject が評価される
    end
  end

注意点

subject が評価されるのは is_expected が呼ばれたときなので、
response を確認する場合には順序にご注意を。

    context 'with 2 users' do
      let!(:users) { create_list(:user, 2) }
      it do
        is_expected.to eq 200
        response_json = JSON.parse(response.body) # subject が評価されている必要あり
        expect(response_json.length).to eq 2
      end
    end

change マッチャの活用

「API 呼び出しによって、何かの変化を確認したい!」場合には、change マッチャ!
例として、ユーザー数の変化を確認するテストを作成してみます。

愚直に書くと、下記のように、API 呼び出し前後での User.count の値を確認することに… :sweat:

愚直にUserの増減を確認するテスト
  describe 'POST /users' do
    context 'with correct parameters' do
      let(:params) { { name: 'name', email: 'hoge@hoge' } }

      it 'should create 1 user' do
        expect(User.count).to eq 0
        post '/users', params: params
        expect(User.count).to eq 1
      end
    end

    context 'with empty parameters' do
      let(:params) { {} }

      it 'should not create any user' do
        expect(User.count).to eq 0
        post '/users', params: params
        expect(User.count).to eq 0
      end
    end
  end

change マッチャを活用すると下記のように記述できます。
何によって、何の値が、どのように変化することを期待しているのか、が分かりやすいのではないでしょうか :smiley:
(ただし、change マッチャは expect が長くなりやすいので、諸刃の剣感があります)

changeマッチャを活用した場合
  describe 'POST /users' do
    context 'with correct parameters' do
      let(:params) { { name: 'name', email: 'hoge@hoge' } }

      it 'should create 1 user' do
        expect { post '/users', params: params }.to change(User, :count).from(0).to(1)
      end
    end

    context 'with empty parameters' do
      let(:params) { {} }

      it 'should not create any user' do
        expect { post '/users', params: params }.not_to change(User, :count).from(0)
      end
    end
  end

subject と組み合わせる場合には、subject をブロックにする必要があります。
スッキリしますね :smiley:

changeマッチャ+subjectを使う場合
  describe 'POST /users' do
    subject { -> { post '/users', params: params } }

    context 'with correct parameters' do
      let(:params) { { name: 'name', email: 'hoge@hoge' } }
      it { is_expected.to change(User, :count).from(0).to(1) }
    end

    context 'with empty parameters' do
      let(:params) { {} }
      it { is_expected.not_to change(User, :count).from(0) }
    end
  end

shared_context + include_context

複雑な前提条件やよく使う前提条件を shared_context として定義しておくことで、
include_context で読み込むことができます。

例えば、以下のように複数のレコードが絡むような前提条件があると、見通しが少し悪くなってしまいます。

同じ前提条件が出てくるテスト達
    context 'test case A' do
      let(:user) { create(:user) }
      let(:another_user) { create(:user) }
      let!(:another_user_follow) { create(:relationship, follower_id: another_user.id, followed_id: user.id) }

      it { is_expected.to eq 200 }
    end

    context 'test case B' do
      let(:user) { create(:user) }
      let(:another_user) { create(:user) }
      let!(:another_user_follow) { create(:relationship, follower_id: another_user.id, followed_id: user.id) }

      # (他の条件)

      it { is_expected.to eq 200 }
    end

前提条件を share_context で定義しておき、必要なテストで include_context を使い読み込むことで、
実際のテストの見通しが良くなります :thumbsup:

share_context+include_contextを使用した例
    shared_context 'followed_user' do
      let(:user) { create(:user) }
      let(:another_user) { create(:user) }
      let!(:user_follow) { create(:relationship, follower_id: another_user.id, followed_id: user.id) }
    end

    context 'test case A' do
      include_context 'followed_user'
      it { is_expected.to eq 200 }
    end

    context 'test case B' do
      include_context 'followed_user'

      # (他の条件)

      it { is_expected.to eq 200 }
    end

よく使う前提条件は、spec/support/shared_contexts/ 以下に定義しておくと、
他のテストで利用できるので便利です。

spec/support 以下に置いて読み込む場合には、rails_helper.rb に下記のような記述が必要です。

Dir[Rails.root.join("spec", "support", "**", "*.rb")].each { |f| require f }

include を活用する

request spec でレスポンス中のパラメーターの値まで確認することは、場合によってはやりすぎです :sweat:
ただし、API として最低限、レスポンスのキーが存在するかは確認しておきましょう。

includeを活用した書き方
  describe '#index' do
    subject { get "/users/#{user.id}" }

    context 'with 1 user' do
      let(:user) { create(:user) }

      it do
        is_expected.to eq 200
        response_json = JSON.parse(response.body)
        expect(response_json).to include('id', 'name')
      end
    end
  end

レスポンスが配列の場合には、all を使うことで、include を各要素に適用できます :thumbsup:

配列の要素にincludeを適用する書き方
  describe '#index' do
    subject { get '/users' }

    context 'with 2 users' do
      let!(:users) { create_list(:user, 2) }

      it do
        is_expected.to eq 200
        response_json = JSON.parse(response.body)
        expect(response_json).to all(include('id', 'name'))
      end
    end
  end

shared_examples/shared_examples_for

条件は異なるけれども、同じ結果を期待する場合には、shared_examples を使うことで DRY にできます :smiley:
(shared_examples_for とも書くことができます)

同じ期待値のテスト達
  describe '#index' do
    subject { get '/users' }

    context 'with 2 users' do
      let!(:users) { create_list(:user, 2) }

      it do
        is_expected.to eq 200
        response_json = JSON.parse(response.body)
        expect(response_json).to all(include('id', 'name'))
      end
    end

    context 'with 3 users' do
      let!(:users) { create_list(:user, 3) }

      it do
        is_expected.to eq 200
        response_json = JSON.parse(response.body)
        expect(response_json).to all(include('id', 'name'))
      end
    end
  end

shared_examples の内容を it_behaves_like で使用することができます。

shared_examplesを活用した例
  describe '#index' do
    subject { get '/users' }

    shared_examples 'return proper response keys' do
      it do
        is_expected.to eq 200
        response_json = JSON.parse(response.body)
        expect(response_json).to all(include('id', 'name'))
      end
    end

    context 'with 2 users' do
      let!(:users) { create_list(:user, 2) }
      it_behaves_like 'return proper response keys'
    end

    context 'with 3 users' do
      let!(:users) { create_list(:user, 3) }
      it_behaves_like 'return proper response keys'
    end
  end

引数を使うこともできるので、下記のように似ているけれども微妙に異なるものも…

似ているけれども微妙に異なる期待値のテスト達
  describe '#index' do
    subject { get '/users' }

    context 'with 2 users' do
      let!(:users) { create_list(:user, 2) }

      it do
        is_expected.to eq 200
        response_json = JSON.parse(response.body)
        expect(response_json).to all(include('id', 'name'))
        expect(response_json.length).to eq 2
      end
    end

    context 'with 3 users' do
      let!(:users) { create_list(:user, 3) }

      it do
        is_expected.to eq 200
        response_json = JSON.parse(response.body)
        expect(response_json).to all(include('id', 'name'))
        expect(response_json.length).to eq 3
      end
    end
  end

下記のように shared_examples で書くことができます :smile:

shared_examplesと引数を活用した例
  describe '#index' do
    subject { get '/users' }

    shared_examples 'return proper response keys' do |user_count|
      it do
        is_expected.to eq 200
        response_json = JSON.parse(response.body)
        expect(response_json).to all(include('id', 'name'))
        expect(response_json.length).to eq user_count
      end
    end

    context 'with 2 users' do
      let!(:users) { create_list(:user, 2) }
      it_behaves_like 'return proper response keys', 2
    end

    context 'with 3 users' do
      let!(:users) { create_list(:user, 3) }
      it_behaves_like 'return proper response keys', 3
    end
  end

shared_examples の注意点

shared_examples は便利なのですが、使いすぎると見通しが悪くなってしまうこともあると感じています :disappointed:
うまく活用して、全体として理解しやすい記述を目指したいですね :smile:

カスタムマッチャを活用する

カスタムマッチャを活用することで、期待値判定の記述を簡潔にすることができます。
特に、独自のエラーレスポンスを定義している場合には、カスタムマッチャ導入で捗りそうです :muscle:

例えば、下記のような、エラーレスポンスを確認するテストがあるとします。

エラー時のレスポンスの確認
  describe '#index' do
    subject { get '/users/0' }

    context 'with no user' do
      it do
        is_expected.to eq 404
        response_json = JSON.parse(response.body)
        expect(response_json['error']['code']).to eq 111
        expect(response_json['error']['message']).to eq 'not found'
      end
    end
  end

エラーレスポンスを確認するカスタムマッチャを定義しておくと、下記のように書くことができます :smile:

カスタムマッチャ活用の例
  describe '#index' do
    subject { get '/users/0' }

    context 'with no user' do
      it do
        is_expected.to eq 404
        expect(response).to be_error_json(code: 111, message: 'not found')
      end
    end
  end

カスタムマッチャの定義は下記のようになっています。

カスタムマッチャの例
RSpec::Matchers.define :be_error_json do |code:, message:|
  match do |actual|
    json_response = JSON.parse(actual.body)
    expect(json_response['error']['code']).to eq code
    expect(json_response['error']['message']).to eq message
  end
end

カスタムマッチャは、spec/support/matchers.rb などに定義すると良いでしょう :thumbsup:
spec/support 以下に置いて読み込む場合には、rails_helper.rb に下記のような記述が必要です。

Dir[Rails.root.join("spec", "support", "**", "*.rb")].each { |f| require f }

おわりに

以上、APIのテストで活用したい記述方式でした!

RSpec の記述方式は、チームによって方針があったり、
良い感じに記述できた!と思ったら、後から見たら分かりづらくなっていたり…ということがあるので、
過度に DRY にせず、便利な機能も用法用量を守って適切に使うのが良いかと思います :smile:

今回ご紹介した以外にも便利な機能はたくさんあるので、ぜひ活用して、楽しいテストライフをお過ごしください〜。

7
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?