この記事は何?
DBにデータが保存されているのではなく、外部APIから取得したデータをあたかもDBから取得したデータのように扱うRailsアプリケーションを作る場合の、設計やテストの書き方を紹介する記事です。
詳しい内容は動画で解説しているので以下の動画をチェックしてください。
また、この記事で使ったサンプルコードはこちらにあります。
この記事では上の動画の簡単な概要を記述します。
アプリケーションの概要
今回作ったのは以下のようなデータを表示するだけのアプリケーションです。
ただし、このデータはDBではなく下記URL(外部API)から取得したJSONデータを表示しています。
[
{
"request_id": "1",
"path": "/products/1",
"status": 200,
"duration": 651.7
},
{
"request_id": "2",
"path": "/wp-login.php",
"status": 404,
"duration": 48.1,
"error": "Not found"
},
{
"request_id": "3",
"path": "/products",
"status": 200,
"duration": 1023.8
},
{
"request_id": "4",
"path": "/dangerous",
"status": 500,
"duration": 43.6,
"error": "Internal server error"
}
]
加えて、このAPIには以下のような制約がある、と想定してください(上記URLは静的な結果を返すだけですが)。
- 外部APIのデータはアクセスするたびに変わる可能性がある
- 開発用やテスト用の外部APIもない
こういった場合はどのようにアプリケーションを設計し、どんなふうにテストコードを書けば良いでしょうか?
設計方針
今回は以下のような設計方針を採ることにします。
- APIアクセスはAccessLogApiクラスで一括管理する
- Model(AccessLogクラス)はAccessLogApi経由でデータを取得する
- ControllerやViewはAccessLogクラスを通常のモデルのように扱う
依存関係を簡単に図示すると以下のようになります。
View(ERB)
↓
Controller
↓
Model(AccessLog)
↓
AccessLogApi
↓
外部API
では、それぞれの実装コードを見ていきましょう。
Controller
Controllerはいたって普通の実装です。
class AccessLogsController < ApplicationController
def index
@access_logs = AccessLog.all
end
end
View
Viewもいたって普通です。
<table class="access-log-table table">
<thead>
<tr>
<th>request_id</th>
<th>path</th>
<th>status</th>
<th>duration</th>
<th>error</th>
</tr>
</thead>
<tbody>
<% @access_logs.each do |access_log| %>
<tr>
<td><%= access_log.request_id %></td>
<td><%= access_log.path %></td>
<td><%= access_log.status %></td>
<td><%= access_log.duration %></td>
<td><%= access_log.error %></td>
</tr>
<% end %>
</tbody>
</table>
Model(AccessLogクラス)
AccessLogクラスはApplicationRecordを継承しない、プレーンなRubyのクラスです。
ただし、ActiveModel::ModelとActiveModel::Attributesをincludeしているので、ActiveRecordっぽく振る舞います。
また、データの取得はAccessLogApiクラスに処理を委譲しています(all
メソッド)。
class AccessLog
include ActiveModel::Model
include ActiveModel::Attributes
attribute :request_id, :integer
attribute :path, :string
attribute :status, :integer
attribute :duration, :float
attribute :error, :string, default: ''
class << self
def all
AccessLogApi.search
end
end
def has_error?
status.to_s.match?(/^[45]/)
end
end
API agent(AccessLogApiクラス)
外部APIとのやりとりはAccessLogApiクラスが担当します(agentとして働くクラス)。
search
メソッドはAPIから取得したデータをAccessLogクラスに詰め込んで返却します。
class AccessLogApi
class << self
def search(params = {})
# paramsを使って柔軟に検索パラメータを追加できる想定
# (だが、下記URLは静的なレスポンスしか返さないので未実装)
uri = URI.parse('https://samples.jnito.com/access-log.json')
json = Net::HTTP.get(uri)
data_list = JSON.parse(json, symbolize_names: true)
data_list.map do |data|
AccessLog.new(data)
end
end
end
end
テストコードの方針
テストコードは以下のような方針で書くことにします。
- テスト実行時は外部APIにアクセスしない(なぜならデータが安定しないから)
- システムスペックではAccessLogApiをモックにする
- AccessLogクラスのモデルスペックでは属性を直接セットする
- AccessLogApiクラスのモデルスペックではVCRを使ってAPIアクセスをテストする
システムスペック
システムスペックではAccessLogApiをモックにすることで、実際の外部APIの返却データに依存することなく、テストで使いたいデータを自由に設定することができます。
require 'rails_helper'
RSpec.describe 'Access Logs', type: :system do
describe '#index' do
before do
access_logs = FactoryBot.build_list(:access_log, 10)
allow(AccessLogApi).to receive(:search).and_return(access_logs)
end
it 'shows all data' do
visit root_path
rows = all('.access-log-table tbody tr')
expect(rows.size).to eq 10
within rows[0] do
expect(page).to have_content '/users/1'
end
end
end
end
ちなみにテスト用データの作成にはFactoryBotも使っています。
FactoryBot.define do
factory :access_log do
sequence(:request_id) { |n| n }
sequence(:path) { |n| "/users/#{n}" }
status { 200 }
sequence(:duration) { |n| (1 + n * 0.1).ceil(1) }
error { "" }
end
end
なお、RSpecにおけるモックの使い方は下記の記事を参照してください。
モデルスペック
モデルスペックでは属性に好きな値を設定できるので、やはり外部APIに依存することなくテストを書くことができます。
require 'rails_helper'
RSpec.describe AccessLog, type: :model do
describe '#has_error?' do
it 'returns true when status is 4__ or 5__' do
access_log = AccessLog.new
access_log.status = 200
expect(access_log.has_error?).to be_falsey
access_log.status = 302
expect(access_log.has_error?).to be_falsey
access_log.status = 404
expect(access_log.has_error?).to be_truthy
access_log.status = 500
expect(access_log.has_error?).to be_truthy
end
end
end
AccessLogApiクラスのテスト
AccessLogApiクラスに対してもテストを書きましょう。
今回はVCRを使うことにしました(vcr: true
がそれ)。
これにより、最初の1回だけ外部APIにアクセスすれば、それ以降はAPIが返すデータがモック化されるので毎回同じ値が返るようになります。
require 'rails_helper'
RSpec.describe AccessLogApi, type: :model do
describe '.search' do
context 'without params' do
it 'returns all records', vcr: true do
access_logs = AccessLogApi.search
# NOTE: cassetteを再生成すると以下の結果は変わる可能性があるため、適宜修正する
expect(access_logs.size).to eq 4
expect(access_logs[0]).to have_attributes(
request_id: 1,
path: '/products/1',
status: 200,
duration: 651.7,
error: ''
)
end
end
end
end
ただし、上のコメントにもあるように、何らかの理由でcassetteを再生成しないといけなくなった場合は、テストが失敗する可能性があります。
その場合は新しいAPIのデータに合わせてテストコードを修正します。
外部APIの結果に依存するのはこのテストだけ(それもcassetteの更新が必要になったときだけ)で、他のテストコードは外部APIの結果には依存しないため、テストコードの修正は発生しない、というのが、このサンプルアプリケーションの売りの一つです。
まとめ
というわけで、この記事では外部APIに依存するRailsアプリケーションの設計とテストの書き方を紹介してみました。
Railsアプリケーションとしては結構珍しい部類に入ると思いますが、もし同じような要件に遭遇した場合は参考にしてみてください。
PR
このサンプルアプリケーションで使用した外部API(といっても静的なJSONのデータを返すだけ)は、拙著「プロを目指す人のためのRuby入門 改訂2版」で使用したサンプルデータです。
また、FactoryBotやVCRの使い方については、僕が翻訳した「Everyday Rails - RSpecによるRailsテスト入門」でも紹介しています。