【動画付き】外部APIに依存するRailsアプリケーションの設計とテストの書き方

## この記事は何？
DBにデータが保存されているのではなく、外部APIから取得したデータをあたかもDBから取得したデータのように扱うRailsアプリケーションを作る場合の、設計やテストの書き方を紹介する記事です。

詳しい内容は動画で解説しているので以下の動画をチェックしてください。

<iframe width="560" height="315" src="https://www.youtube.com/embed/S9CrW5FDheo" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

また、この記事で使ったサンプルコードはこちらにあります。

https://github.com/JunichiIto/api-agent-sandbox

この記事では上の動画の簡単な概要を記述します。

## アプリケーションの概要

今回作ったのは以下のようなデータを表示するだけのアプリケーションです。

![Screen Shot 2022-08-11 at 19.15.32 (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7465/33f6554b-2e84-b9d8-9ab6-4008884b9c38.png)

ただし、このデータはDBではなく下記URL（外部API）から取得したJSONデータを表示しています。

- https://samples.jnito.com/access-log.json

```javascript 
[
  {
    "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はいたって普通の実装です。

```app/controllers/access_logs_controller.rb
class AccessLogsController < ApplicationController
  def index
    @access_logs = AccessLog.all
  end
end
```

## View
Viewもいたって普通です。

```erb:app/views/access_logs/index.html.erb
<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`メソッド）。

```app/models/access_log.rb
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クラスに詰め込んで返却します。

```app/models/access_log_api.rb
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の返却データに依存することなく、テストで使いたいデータを自由に設定することができます。

```spec/system/access_logs_spec.rb
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も使っています。

```spec/factories/access_logs.rb
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におけるモックの使い方は下記の記事を参照してください。

https://qiita.com/jnchito/items/640f17e124ab263a54dd

## モデルスペック
モデルスペックでは属性に好きな値を設定できるので、やはり外部APIに依存することなくテストを書くことができます。

```spec/models/access_log_spec.rb
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が返すデータがモック化されるので毎回同じ値が返るようになります。

```spec/models/access_log_api_spec.rb
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版」で使用したサンプルデータです。

https://gihyo.jp/book/2021/978-4-297-12437-3

また、FactoryBotやVCRの使い方については、僕が翻訳した「Everyday Rails - RSpecによるRailsテスト入門」でも紹介しています。

https://leanpub.com/everydayrailsrspec-jp/