一から勉強させてください( ̄ω ̄;)

最下級エンジニアが日々の学びをアウトプットしていくだけのブログです。

Grape + Rails4.2のAPI開発でエンドポイントとエンティティのテストについて考える

最近、Grape + Rails (v4.2.6)でRESTなAPIを開発する機会があったのですが、今回はその開発の中で悩んだ、「Grapeのエンドポイントとエンティティのテスト」について書きたいと思います。

前提としてGrapeののエンティティは grape-entity、テストフレームワークrspecを採用しているものとします。またAPIのformatはJSONとします。

APIの実装

例として、ユーザーがひたすら無意味に投稿しまくるだけのシンプルなアプリケーションを考えてみる。

モデル

# User
class User < ActiveRecord::Base
  has_many :posts, inverse_of: :user
  
  validates :name, presence: true
end

# Post
class Post < ActiveRecord::Base
  belongs_to :user, inverse_of: :posts
  
  validates :user, presence: true
  validates :text, presence: true
  
  scope :latest, -> { order created_at: :desc }
end

エンティティ

# User
module API
  module V1
    module Entities
      class User < Grape::Entity
        expose :id
        expose :name
      end
    end
  end
end

# Post
module API
  module V1
    module Entities
      class Post < Grape::Entity
        expose :id
        expose :text
        expose :user, using: Entities::User
        expose :created_at
        expose :updated_at
      end
    end
  end
end

エンドポイント

# /api/v1/postsでPostの一覧が返ることを想定,
module API
  module V1
    class Posts < Grape::API
      resources :posts do
        desc 'Return all posts' do
          success API::V1::Entities::Post
        end
        get do
          posts = Post.includes(:user).latest
          present(posts, with: API::V1::Entities::Post)
        end
      end
    end
  end
end
# /api/v1/postsのレスポンスのサンプル
[
  {
    "id": 2,
    "text": "ひゃっはーー",
    "user": {
      "id": 1,
      "name": "Tom"
    },
    "created_at": "2016-08-09T07:22:35.000Z",
    "updated_at": "2016-08-09T07:22:35.000Z"
  },
  {
    "id": 1,
    "text": "うぇーーい",
    "user": {
      "id": 2,
      "name": "Bob"
    },
    "created_at": "2016-08-08T07:22:35.000Z",
    "updated_at": "2016-08-08T07:22:35.000Z"
  }
]

APIのテスト

方針

  • Grapeではモデルとエンティティを指定してpresentメソッドを呼ぶといい感じにbody(今回はJSON)を返してくれる
  • このpresentメソッドをテスト環境で使えれば、各エンティティ、エンドポイントで返却されるであろうJSONを組み立ててテストできそう
  • JSONのテストはjson_spec使えばよさそう
  • Rspectype: :apiを定義して、API専用ヘルパーメソッドを読み込んでやればよさそう
  • このあたりを解読すればいけそう

API用ヘルパー

# rails_helperで読み込んでおく
module API
  module TestHelpers
    include Rack::Test::Methods
    include Grape::DSL::InsideRoute
    
    private

    def app
      Rails.application
    end

    def request
      last_request
    end

    def response
      last_response
    end

    def render_api_response *args
      # Grape::DSL::InsideRouteモジュールをincludeしているのでpresentメソッドをそのまま使える
      present(*args).to_json.tap do
        # presentメソッドを呼ぶとbodyがインスタンス変数に入るようになっているが、テストでは返却されるJSONがわかればよいだけなので@bodyは常にnilにしておく
        @body = nil
      end
    end

    # Override
    def entity_representation_for entity_class, object, options
      # presentメソッドの中で呼ばれるメソッド
      # 実際はここでenvをセットするような処理が入っているが、Rack::Testのenvメソッドと名前があたってArgumentErrorが発生するので無視する
      entity_class.represent object, options
    end
  end
end

RSpec.configure do |config|
  config.include API::TestHelpers, type: :api
end

エンティティのテスト

require 'rails_helper'

RSpec.describe API::V1::Entities::Post, type: :api do
  describe 'fields' do
    subject { render_api_response(post, with: described_class) }

    let(:user) { create(:user) } # FactoryGirlが入っている体でおねがいします
    let(:post) { create(:post, user: user) }
    let(:expected_user) { render_api_response(user, with: API::V1::Entities::User) }

    it do
      is_expected.to be_json_eql(post.id).at_path 'id'
      is_expected.to be_json_eql(post.text.to_json).at_path 'text'
      is_expected.to be_json_eql(post.created_at.to_json).at_path 'created_at'
      is_expected.to be_json_eql(post.updated_at.to_json).at_path 'updated_at'
      is_expected.to be_json_eql(expected_user).at_path 'user'
    end
  end
end

エンドポイントのテスト

require 'rails_helper'

RSpec.describe API::V1::Posts, type: :api do
  describe 'index' do
    before { send_request }
    
    let(:send_request) { get "/api/v1/posts", params.merge(format: :json) }
    let(:params) { {} }
    let(:user) { create(:user) } # FactoryGirlが入っている体でおねがいします
    let!(:posts) { create_list :post, rand(2..4), user: user }
    let(:search_result) { Post.latest }
    let(:expected) { render_api_response(search_result, with: API::V1::Entities::Post) }

    it do
      expect(response).to be_successful
      expect(response.body).to be_json_eql expected
    end
  end
end

まとめ

Grapeを使ったのは初めてだったのでベストプラクティスはわからないですが、今回のテスト方針はまあまあ機能したので個人的には悪くなかったんじゃないかなーと思っています。本当はOAuth2認可が必要なエンドポイントのテストとかもあって、そのあたりのことも書きたかったのですが、それはまた次の機会に。

RubyでネストしたHashをflatな1次元のHashに変換する

{
  :foo => "bar",
  :hello => {
    :world => "Hello World",
    :bro => "What's up dude?",
  },
  :a => {
    :b => {
      :c => "d"
    }
  }
}

みたいなネストしたHashがあったとして、これを

{
  :foo => "bar",
  :"hello.world" => "Hello World",
  :"hello.bro" => "What's up dude?",
  :"a.b.c" => "d"
}

にしたい時がきっとあると思います。

今回それを実現するためのメソッドをStack Overflowから丸パクリしたので書いたので、メモ。

こんな感じ。引数にネストしたHashを渡すと目的のHashが返ってくるはず。

def flatten_hash_from hash
  hash.each_with_object({}) do |(key, value), memo|
    next flatten_hash_from(value).each do |k, v|
      memo["#{key}.#{k}".intern] = v
    end if value.is_a? Hash
    memo[key] = value
  end
end

each_with_objectを使う。初期値{}をセットして、これに欲しい情報をどんどん突っ込んでいって目的のHashを完成させる。injectがブロックの最後に評価した値がmemoにセットされていくのに対して、each_with_objectは常に最初に渡したこのオブジェクトだけを見ていてくれるのでわかりやすい。そしてflatになるまでひたすら再帰処理をする。

実際に上述のネストしたHashに対して適用した場合を考えて、処理の流れを追ってみる。

  1. まずkey = :foo, value = "bar"。これはvalueがHashではないのでmemo{ :foo => "bar" }となって次へ。

  2. 次にkey = :hello, value = { :world => "Hello World", :bro => "What's up dude?" }。これはvalueがHashなので、このvalueに対してさらにflatten_hash_fromメソッドが呼ばれる。

  3. 再帰処理の中のmemoは新たな{}であり、1のmemoとは異なるので注意。まずkey = :world, value = "Hello World"。これはvalueがHashではないので、memo{ :world => "Hello World" }となる。key = :broについても同様なので、最終的にmemo{ :world => "Hello World", :bro => "What's up dude?" }となり、これが2のflatten_hash_from(value)に返る。

  4. 3からさらにeach処理へ。ここでkey = :helloになっているので、memo["#{key}.#{k}".intern] = vとすることで、memokeyhello.world, hello.broのようになる。最終的にこの処理の後のmemo{ :foo => "bar", :"hello.world" => "Hello World", :"hello.bro" => "What's up dude?" }になる。

  5. 次にkey = :a, value = { :b => { :c => "d" } }。これもvalueがHashなので再帰処理へ。

  6. key = :b, value = { :c => "d" }なのでさらに再帰処理へ。

  7. key = :c, value = "d"なので { :c => "d" }が6のflatten_hash_fromに返る。

  8. { :"b.c" => "d" }となり、5のflatten_hash_fromに返る。

  9. key = :aの階層に戻ってきてeachするので{:"a.b.c" => "d" }が新たにmemoに追加される。その結果、memo{ :foo => "bar", :"hello.world" => "Hello World", :"hello.bro" => "What's up dude?", :"a.b.c" => "d" }となるので終了。

まとめ

再帰処理はちゃんと処理の流れを理解して使っていきたい。

参考

シーケンシャルスキャンとインデックススキャンを意識して生きていきたい

最近RailsでツラいクエリをRDBに投げまくって失敗してしまったのでメモ。

例えばusersテーブルがあって、一部のAwesomeなユーザーだけにawesometrueで割り当てられている(基本false)とする。そしてawesomeカラムにはインデックスが貼られているとする。

そして

class User < ActiveRecord::Base
  scope :awesome, -> { where awesome: true }
  scope :normal, -> { where awesome: false }
end

とする。

ここで、ユーザーレコードが100万件あってそのうち100件だけがAwesomeだった場合を考えたい。

# これは100万件から100件を探して取ってくるのでインデックス最高ってなる
User.awesome

# これは100万件から99万9900件を探して取ってくるので地獄度が高い
User.normal 

インデックスを貼ったからと安心して何も考えずにRDBからデータを引っ張ってはいけない。これはユーザー100万件をシーケンシャルスキャンするのとなんら変わらんではないか。むしろはじめにインデックスを読んでからシーケンシャルスキャン同等の検索をしている分タチが悪い可能性もある。

こちらの記事を参考にさせてもらったけど、インデックススキャンは大量検索には向かない。シーケンシャルスキャンとの特性を比較した図も載っていてわかりやすかった。

まとめ

N+1には普段から気をつけていたけど、それ以外にも非効率なクエリをつい投げてしまっていることがあるなと実感した。Railsなど便利なフレームワークを使っていたとしても、非効率なクエリを投げてしまっていないか、ログなどを見つつ常に意識していきたい。

参考