おいちゃんと呼ばれています

ウェブ技術や日々考えたことなどを綴っていきます

Rails でつくる API のテストの書き方(RSpec + FactoryGirl)

最近 RailsAPI をつくりはじめました。1か月テスト書きまくって、だいたい書き方が落ち着いてきたので共有します。

もっとこうした方が良くない?というのをコメントまたははてブコメントしてくださったら泣いて喜びます!

環境は下記のとおりです。

* rails 4.0.0
* rspec-rails 2.14.0
* factory_girl_rails 4.2.0

もくじ

Rails でつくる API のテストの書き方(RSpec + FactoryGirl)

1. ベストプラクティス
2. RSpec の Request spec で書く
3. Capybara は使わない

4. GET のテスト例 1(データを 1件取得)
  (1) ステータスコードの明示
  (2) モデルの個別の内容を返すときは、属性を列挙して確認

5. GET のテスト例 2(データを一覧取得)
  (1) 属性を列挙して確認しなくてよい
  (2) FactoryGirl の定義内容を逐一確認しなくて済むようにする

6. POST のテスト例 1(正常系)
  (1) FactoryGirl.attributes_for
  (2) post を before ブロックに含めない
  (3) ステータスコードと success/failed を response.body に埋める

7. POST のテスト例 2(異常系)
  (1) エラーメッセージの中身を確認する
  (2) context や it を日本語で書く

8. PUT のテスト例
9. API ドキュメントを自動生成する autodoc
10. 返ってくる json のチェックをサポートする json_expressions

1. ベストプラクティス

ひととおり Google 先生にたずねてみて、これがたぶんベストプラクティスだよね、と思ったのが下記の記事。タイトルもそのままなのですが。

今回はこの記事の中からいくつかポイントをピックアップして、あと、少し tips を加えたかたちにしたいと思います。

2. RSpec の Request spec で書く

まず基本的に、特別な理由がなければ RSpec の Request spec で書くのが良いと思います。日本語の記事で Controller spec を使っているのが散見されたのですが、それだと対応できない場合があります。

例えば、scaffold で生成されるような、典型的な create メソッド。@video.save に成功したら render action: 'show' するようにしていますが、これを Controller spec で response.body を確認すると空文字列になっています。したがって、正しく @video の内容が返ってくることをテストできません。

# app/controllers/api/videos_controller.rb

# POST /videos.json
def create
  @video = Video.new(video_params)

  respond_to do |format|
    if @video.save
      format.json { render action: 'show', status: :created, location: @video }
    else
      format.json { render json: @video.errors, status: :unprocessable_entity }
    end
  end
end 

このようなケースに対応するためにも、Request specs を使うのが良いと思います。

補足すると、Controller spec は単体テストで Request spec は結合テストで、そもそも役割が違うのですが、ふたつともに書くと、だいたい冗長になるので、省エネのためにも、僕は Request spec しか使っていないです。

3. Capybara は使わない

Capybara の作者 Jonas Nicklas さんが下記のように書いています。

Do not test APIs with Capybara. It wasn’t designed for it.

代わりに Rack::Test を使え、と。

Capybara は、バージョン 2.0.0 から spec/requests を見なくなったので(代わりに spec/features を見る)、下記のようにして、ウェブページのテストと棲み分けしています。

  • spec/spec_helper.rbinclude Capybara::DSL と書かない
  • API のテストは spec/requests に置く
  • ウェブページのテストは spec/features に置く = Capybara 使う

4. GET のテスト例 1(データを 1件取得)

先に例を示します。

describe 'GET /api/videos/:id.json' do
  before do
    @video = FactoryGirl.create(:video)
    get "/api/videos/#{@video.id}.json"
  end

  it '200 OK が返ってくる' do
    expect(response).to be_success
    expect(response.status).to eq(200)
  end

  it '動画情報を取得できる' do
    json = JSON.parse(response.body)
    expect(json['title']).to eq(@video.title)
    expect(json['status']).to eq(@video.status)
    # expect(json['time']).to ...
    # expect(json['tags']).to ...
  end
end

典型的なケースですね。少し解説を加えます。

(1) ステータスコードの明示

まず、expect(response).to be_success だけではなく、expect(response.status).to eq(200)ステータスコードを明示しているのなぜか?

それは、json を受け取る側で、ステータスコードを参照して処理を分岐させているためです。

つまり 200 でも 201 でも be_success になるんだけど、このケースでは 200 を返すよということを json を受け取る側をつくる開発者が、テストコードを見れば分かるようにしています(実際はあんまり見てなくて、他の方法でその開発者に伝えているのですが。後述します)

(2) モデルの個別の内容を返すときは、属性を列挙して確認

(一覧ではなくて)個別の情報を返すときは、面倒だけれども、属性を列挙して確認するようにしています。

たいていの場合、モデルの属性を全部返すわけではないでしょうから、返す属性と返さない属性がきちんと仕様どおりになっているか確認したり、返す属性の中身が正しいか確認したりします。

5. GET のテスト例 2(データを一覧取得)

次は一覧取得の API のテスト例です。

describe 'GET /api/videos.json' do
  before do
    @video1 = FactoryGirl.create(:video, title: 'すぐにまたがる家庭教師')
    @video2 = FactoryGirl.create(:video, title: '夫のために身体を差し出した人妻')
    get '/api/videos.json'
  end

  it '200 OK が返ってくる' do
    expect(response).to be_success
    expect(response.status).to eq(200)
  end

  it '動画一覧情報を取得できる' do
    json = JSON.parse(response.body)
    expect(json).to match(/すぐにまたがる家庭教師/)
    expect(json).to match(/夫のために身体を差し出した人妻/)
  end
end

(1) 属性を列挙して確認しなくてよい

Rails 4 標準で使われている jbuilder を例にすると、たいてい、下記のように、個別情報を partial して一覧で返すようにしていると思います。

json.partial! 'video', collection: @videos, as: :video

なので、モデルが複数個返ってきているかは確認する必要がありますが、個別の中身については、前述のテスト例 1 のケースで済ませているので、不要かと考えています。

(2) FactoryGirl の定義内容を逐一確認しなくて済むようにする

もしかしたら、下記のようにすると、同じタイトルの動画が 2つできてしまうかもしれません。

@video1 = FactoryGirl.create(:video)
@video2 = FactoryGirl.create(:video)

その場合に下記のように書いても、動画が複数個返ってきたかは確認できません。

expect(json).to match(/#{@video1.title}/)
expect(json).to match(/#{@video2.title}/)

したがって、タイトルを明示して確認するようにしています。factory :video がどのように定義されているかを確認すれば済むのですが、当該テストの記述だけ見て判断できる方がより良いと思います。

なお、並び順も含めて確認したい場合は下記のような感じです。

expect(json[0]['title']).to eq('すぐにまたがる家庭教師')
expect(json[1]['title']).to eq('夫のために身体を差し出した人妻')

6. POST のテスト例 1(正常系)

POST の正常系のテストの例。パラメータを POST したら、データが 1件登録されるケースを想定しています。

#
# 3. 動画の登録
#
describe 'POST /api/videos.json' do
  let(:path) { '/api/videos.json' }

  context '3-1. パラメータが正しいとき' do
    before do
      user = FactoryGirl.create(:user)
      sign_in user

      @params = { video: FactoryGirl.attributes_for(:video) }
    end

    it '201 Created が返ってくる' do
      post path, @params
      expect(response).to be_success
      expect(response.status).to eq(201)
    end

    it 'ステータスコードと success/failed を response.body に埋める' do
      post path, @params
      json = JSON.parse(response.body)

      expect(json['status']).to eq(201)
      expect(json['result']).to eq('success')
      expect(json['error_messages']).to eq(nil)
    end

    it 'Video が 1 増える' do
      expect {
        post path, @params
      }.to change(Video, :count).by(1)
    end
  end

  context '3-2. 動画タイトルが入っていないとき' do
  # ...

(1) FactoryGirl.attributes_for

POST するときはパラメータをハッシュで指定するのですが、FactoryGirl を使っている場合は、定義している内容を下記のようにしてもってこれるので便利です。

@params = { video: FactoryGirl.attributes_for(:video) }

(2) post を before ブロックに含めない

下記のようにデータが増減するのを確認することがよくあるので、post は before ブロックに含めないようにしています。

expect {
  post path, @params
}.to change(Video, :count).by(1)

(3) ステータスコードと success/failed を response.body に埋める

当初 POST が成功したら、登録したデータを返すようにしていたのですが、iPhone アプリ側の開発者から要望がありました。

  1. header のステータスコードを見れば済むハナシなんだけど、ステータスコードを response.boy に入れてもらえると工数が減るんだけどなー
  2. 処理が成功したのか失敗したのかをステータスコードから判別するよりも、response.body で明示してもらった方が助かる

ヤリ手の彼がそういうのだからきっとそうなんだと思います。一方で API 側の工数は別にそのせいで増えたりしないので(jbuilder 便利)、そのような実装にしています。参考まで。

# app/view/api/videos/create.json.jbuilder
json.status response.status
json.result @result
json.error_messages @error_messages

7. POST のテスト例 2(異常系)

POST の異常系のテストの例。データを 1件登録するために POST したんだけど、パラメータに誤りがあるケースを想定。

#
# 3. 動画の登録
#
describe 'POST /api/videos.json' do
  let(:path) { '/api/videos.json' }

  context '3-1. パラメータが正しいとき' do
    # ...
  end

  context '3-2. 動画タイトルが入っていないとき' do
    before do
      user = FactoryGirl.create(:user)
      sign_in user

      @params = { video: FactoryGirl.attributes_for(:video, title: '') }
    end

    it '422 Unprocessable Entity が返ってくる' do
      put path, @params
      expect(response).not_to be_success
      expect(response.status).to eq(422)
    end

    it 'エラーメッセージが返ってくる' do
      put path, @params
      json = JSON.parse(response.body)

      expect(json['status']).to eq(422)
      expect(json['result']).to eq('failed')
      expect(json['error_messages']).to eq(['動画タイトルを入力してください'])
    end

    it 'Video が増減しない' do
      expect {
        put path, @params
      }.not_to change(Video, :count)
    end
  end
  
  # ...
end

(1) エラーメッセージの中身を確認する

異常系のテストは、エラーメッセージがきちんと返っているか?を必ず確認するようにしています。ここはユーザに直接見えるところなので、重点を置いている感じ。

たまに i18n の記述漏れでエラーメッセージに英語が混ざっている場合もあるので、それも含めてチェックしている感じです。

(2) context や it を日本語で書く

ここはまあ、意見の分かれるところでしょうか。特にオープンソースで書くコードか、業務で書くコードか、で。

逐一やっているわけではないですが、ディレクターに

こういうケースではこういうエラーメッセージになりますが、それでよいですか?

と確認したいことがあります(というか、確認すべきです)。そのときに、別途「エラーメッセージ一覧」とか資料をつくるのだるいので、テストコードを一緒に見ながら説明するようにしています。

  • context '3-2. 動画タイトルが入っていないとき' do
  • it 'エラーメッセージが返ってくる' do

というふうに日本語で書いていたり、項番を振っているのは、そのとき説明しやすいようにするためです。

ただ、それが主な理由ですが、関係するエンジニアがいまのところ全員日本人なので、テストコードを見て仕様を把握するスピードを上げるため、というのもあります。

8. PUT のテスト例

PUT のテスト例。もうだいたい言ったので、サンプルコードだけ示します。既に登録されているデータ 1件を更新するケース。

#
# 4. 動画の更新
#
describe 'PUT /api/videos/:id.json' do

  context '4-1. パラメータが正しいとき' do
    before do
      user = FactoryGirl.create(:user)
      sign_in user

      @video = FactoryGirl.create(:video)
      @params = { video: FactoryGirl.attributes_for(:video, title: '新しいタイトル') }
      @path = "/api/videos/#{video.id}.json"
    end

    it '200 OK が返ってくる' do
      put @path, @params
      expect(response).to be_success
      expect(response.status).to eq(200)
    end

    it 'ステータスコードと success/failed を response.body に埋める' do
      put @path, @params
      json = JSON.parse(response.body)

      expect(json['status']).to eq(200)
      expect(json['result']).to eq('success')
      expect(json['error_messages']).to eq(nil)
    end

    it 'Video が更新される' do
      put @path, @params
      expect(@video.reload.title).to eq('新しいタイトル')
    end

    it 'Video が増減しない' do
      expect {
        put @path, @params
      }.not_to change(Video, :count)
    end
  end

  context '4-2. 動画タイトルが入っていないとき' do
  # ...

9. API ドキュメントを自動生成する autodoc

便利情報です。autodoc を使うと、

$ AUTODOC=1 bundle exec rspec/spec/requests/api/videos_spec.rb

とテストを実行するだけで、下記のような API ドキュメントを自動生成してくれます。

f:id:inouetakuya:20131020130923p:plain

先日、紹介記事を書きましたので参考にしてください。

10. 返ってくる json のチェックをサポートする json_expressions

僕自身は使っていないのですが、json の中身が複雑なケースでは、json_expressions を使うと書きやすそうです。

# RSpec example
describe UsersController, "#show" do
  it "returns a user" do
    # This is what we expect the returned JSON to look like
    pattern = {
      user: {
        id:         :user_id,                    # "Capture" this value for later
        username:   'chancancode',               # Match this exact string
        full_name:  'Godfrey Chan',
        email:      'godfrey@example.com',
        type:       'Administrator',
        points:     Fixnum,                      # Any integer value
        homepage:   /\Ahttps?\:\/\/.*\z/i,       # Let's get serious
        created_at: wildcard_matcher,            # Don't care as long as it exists
        updated_at: wildcard_matcher,
        posts: [
          {
            id:      Fixnum,
            subject: 'Hello world!',
            user_id: :user_id,                   # Match against the captured value
            tags: [
              'announcement',
              'welcome',
              'introduction'
            ]                                    # Ordering of elements does not matter by default
          }.ignore_extra_keys!,                  # Skip the uninteresting stuff
          {
            id:      Fixnum,
            subject: 'An awesome blog post',
            user_id: :user_id,
            tags:    ['blog' , 'life']
          }.ignore_extra_keys!
        ].ordered!                               # Ensure the posts are in this exact order
      }
    }

    server_response = get '/users/chancancode.json'

    server_response.body.should match_json_expression(pattern)
  end
end

いま、ふわっとした仕様のもとで、コードを書き進めなたら仕様を固めていっているような感じなので、テーブル定義変更とか、URL 変更とかざらに発生しています。そんなときに

テスト書いてて良かったー

と、そのありがたみをヒシヒシと感じている今日この頃です。ではでは。

参考サイト