シナプス技術者ブログ

シナプスの技術者公式ブログ。インターネットで、鹿児島の毎日を笑顔にします。

外部APIを叩くRubyのコードのテストに「VCR」を使ってみました

技術部の中野です。

Rubyで外部APIを叩くコードを書いていまして、その外部APIを叩く部分を含むコードのテストについて、いくつかの方法を試し最終的に「VCR」を使ってみたところ、とても便利だったので記事にしてみました。

外部のAPIを叩くRubyコード

シナプスはISP(インターネット・サービス・プロバイダー)であるためASの運用を行っていますが、その運用する中で他のASの情報を確認する事が多々あります。

他のASの情報を調べるには様々な方法がありますが、その1つに「PeeringDB」というサービスがあり、世界中のAS運用者によって活用されています。

現在、このPeeringDBのAPIを利用し、ASの情報を検索するプログラムを以下のようにRubyで作っています。

コード

# frozen_string_literal: true

require 'httpclient'
require 'json'

module Peeringdb
  class Net
    URL = 'https://www.peeringdb.com/api/net?asn__in='

    attr_reader :id, :org_id, :name, :asn, :info_type,
                :info_prefixes4, :info_prefixes6, :ix_count,
                :fac_count

    private_class_method :new

    def initialize(data = {})
      @id = data[:id]
      @org_id = data[:org_id]
      @name = data[:name]
      @asn = data[:asn]
      @info_type = data[:info_type]
      @info_prefixes4 = data[:info_prefixes4]
      @info_prefixes6 = data[:info_prefixes6]
      @ix_count = data[:ix_count]
      @fac_count = data[:fac_count]
    end

    def self.search(asn = 0)
      clinet = HTTPClient.new
      response = clinet.get(URL + asn.to_s)
      json = JSON.parse(response.body, symbolize_names: true)
      new(json[:data][0])
    end
  end
end

利用イメージ

使い方は、このような感じです。

# PeeringDBで、AS7511の情報を検索
as = Peeringdb::Net.search(7511)

# 登録されている名前
as.name   
=> "SYNAPSE"

# 登録されている種別
as.info_type
=> "Cable/DSL/ISP"

# IPv4プレフィックス数
as.info_prefixes4
=> 20

何も考えずに書いたテストコード

上のコードについて、何も考えずテストを書くと以下のようになります。

# frozen_string_literal: true

RSpec.describe Peeringdb::Net do
  describe 'search' do
    it 'return name was valid' do
      # AS7511の情報を取得
      as = Peeringdb::Net.search(7511)

      # 戻ってきたデータがPeeringdb::Netのインスタンスであることを確認
      expect(as).to be_an_instance_of(Peeringdb::Net)

      # as.nameが"SYNAPSE"であることを確認
      expect(as.name).to eq('SYNAPSE')
    end
  end
end

外部APIを叩くテストの課題

このテストコードでテストは正常に行えるのですが、外部のAPIを叩くテストには、以下のような課題があります。

  • APIにアクセスできない環境では、テストが失敗する
  • APIの応答に時間がかかる場合、テストに時間がかかる
  • レート制御やスロットリングがかかるAPIの場合、タイミング次第でテストが失敗する
  • APIが、呼び出し回数に応じた従量課金などの場合、テストの度にお金がかかる
  • 認証を必要とするAPIの場合、トークンなどの情報の管理に手間がかかる

外部APIを叩くテストの課題の解決方法「モック/スタブ」

外部APIを叩くテストの課題について、いくつか解決方法がありますが、その1つに「モック/スタブ」を使う方法があります。

モックやスタブは、テスト対象と依存関係のあるライブラリ/モジュールなどの送信メッセージや受信メッセージを代替するもので、外部APIを呼ぶ部分を代替しテスト内で予め設定したデータを返すような形で利用できます。

今回は、以下の2つの仕組みを利用してみました。

RSpec Mocksによるテストコード

RSpec Mocksにて、モック/スタブを利用したコードはこちらです。

# frozen_string_literal: true

RSpec.describe Peeringdb::Net do
  describe 'search' do
    it 'return name was valid' do
      json = '{"data": [{"id": 3976, "org_id": 4035, "name": "SYNAPSE"}]}'

      # HTTPClientのインスタンスダブル
      http_client = instance_double(HTTPClient)

      # HTTP::Messageのインスタンスダブル
      message = instance_double(HTTP::Message)

      # HTTPClient.newが呼ばれたら、HTTPClientのインスタンスダブルを返す
      allow(HTTPClient).to receive(:new).and_return(http_client)

      # http_client.getが呼ばれたら、HTTP::Messageのインスタンスダブルを返す
      allow(http_client).to receive(:get).and_return(message)

      # message.bodyが呼ばれたら、jsonを返す
      allow(message).to receive(:body).and_return(json)


      # AS7511の情報を取得
      as = Peeringdb::Net.search(7511)

      # 戻ってきたデータがPeeringdb::Netのインスタンスであることを確認
      expect(as).to be_an_instance_of(Peeringdb::Net)

      # as.nameが"SYNAPSE"であることを確認
      expect(as.name).to eq('SYNAPSE')
    end
  end
end

WebMocksによるテストコード

WebMockにて、モック/スタブを利用したコードはこちらです。

# frozen_string_literal: true

RSpec.describe Peeringdb::Net do
  describe 'search' do
    it 'return name was valid' do
      # https://www.peeringdb.com/api〜に対して、GETリクエストを送ったら、
      # bodyに指定した文字列を返す
      WebMock.stub_request(:get, 'https://www.peeringdb.com/api/net?asn__in=32934').to_return(
        status: 200,
        body: '{"data": [{"id": 3976, "org_id": 4035, "name": "SYNAPSE"}]}',
        headers: { 'Content-Type' => 'application/json' }
      )

      # AS7511の情報を取得
      as = Peeringdb::Net.search(7511)

      # 戻ってきたデータがPeeringdb::Netのインスタンスであることを確認
      expect(as).to be_an_instance_of(Peeringdb::Net)

      # as.nameが"SYNAPSE"であることを確認
      expect(as.name).to eq('SYNAPSE')
    end
  end
end

モック/スタブの課題

モック/スタブを使うことで、実際にAPIを叩くことなくコードのテストができるようになりますが、以下の様な別な課題が生まれます。

  • テストコードの記述が多くなる
  • APIのレスポンスが変わると、テストコードも変更する必要がある

VCR

外部APIのテストにおけるモック/スタブの課題を解決する1つの方法として「Symmetric API Testing」と呼ばれるものがあり、Rubyであれば「VCR」を利用することで解決できることがあります。

github.com

VCRは以下の仕組みから成り立っています。

  • APIの呼び出しをフックする
  • 初回実行時のアクセスは、リクエスト/レスポンスをカセットと呼ばれるYAML形式で保存
  • 2回目以降の実行時のアクセスは、保存していたYAMLファイルを読み込んで利用する(APIにはアクセスしない)

f:id:ryonkn:20211025150951p:plain

VCRを使うための設定

RSpecの場合、VCRのgemをインストールし「spec_helper.rb」に数行追記することで利用できます。

以下は、spec_helper.rbの内容です。

# frozen_string_literal: true

require 'peeringdb'
require 'vcr'
require 'webmock'

# VCRの設定
VCR.configure do |config|
  # カセットの保存ディレクトリを指定
  config.cassette_library_dir = 'spec/vcr'

  # 利用するHTTPライブラリに合わせて設定
  config.hook_into :webmock

  # RSpecの場合は以下の設定をすることで、カセット名を自動的に設定する事が可能  
  config.configure_rspec_metadata!
end

RSpec.configure do |config|
  # Enable flags like --only-failures and --next-failure
  config.example_status_persistence_file_path = ".rspec_status"

  # Disable RSpec exposing methods globally on `Module` and `main`
  config.disable_monkey_patching!

  config.expect_with :rspec do |c|
    c.syntax = :expect
  end
end

VCRによるテストコード

VCRを利用したテストコードはこのような感じです。

一番最初のテストコードに「:vcr」を追記しただけで、VCRの利用が可能になります。

# frozen_string_literal: true

RSpec.describe Peeringdb::Net do
  describe 'search' do
                               # ↓でVCRの使用を設定
    it 'return name was valid', :vcr do
      # AS7511の情報を取得
      as = Peeringdb::Net.search(7511)

      # 戻ってきたデータがPeeringdb::Netのインスタンスであることを確認
      expect(as).to be_an_instance_of(Peeringdb::Net)

      # as.nameが"SYNAPSE"であることを確認
      expect(as.name).to eq('SYNAPSE')
    end
  end
end

上のテストコードでは、保存されるテストデータのカセットのファイル名は自動的に命名されますが、個別に指定することも可能です。

VCRで保存されたカセット

VCRにて保存されたカセットは、以下のように、APIへのアクセス時のリクエスト/レスポンスがそのままYAML形式にて記録されます。

---
http_interactions:
- request:
    method: get
    uri: https://www.peeringdb.com/api/net?asn__in=7511
    body:
      encoding: UTF-8
      string: ''
    headers:
      User-Agent:
      - HTTPClient/1.0 (2.8.3, ruby 3.0.2 (2021-07-07))
      Accept:
      - "*/*"
      Date:
      - Fri, 08 Oct 2021 02:09:51 GMT
  response:
    status:
      code: 200
      message: OK
    headers:
      Date:
      - Fri, 08 Oct 2021 02:09:52 GMT
      Content-Type:
      - application/json; charset=utf-8
      Content-Length:
      - '938'
      Connection:
      - keep-alive
      Server:
      - nginx
      Allow:
      - GET, POST, HEAD, OPTIONS
      Vary:
      - Authorization, Accept-Language, Cookie, Origin
      X-Frame-Options:
      - DENY
      Content-Language:
      - en
    body:
      encoding: UTF-8
      string: '{"data": [{"id": 3976, "org_id": 4035, "name": "SYNAPSE", "aka": "SYNAPSE",
        "name_long": "", "website": "https://www.synapse.jp", "asn": 7511, "looking_glass":
        "", "route_server": "", "irr_as_set": "AS7511:AS-SYNAPSE AS7511:AS-SYNAPSE-IPV6",
        "info_type": "Cable/DSL/ISP", "info_prefixes4": 20, "info_prefixes6": 1, "info_traffic":
        "10-20Gbps", "info_ratio": "Mostly Inbound", "info_scope": "Asia Pacific",
        "info_unicast": true, "info_multicast": false, "info_ipv6": true, "info_never_via_route_servers":
        false, "ix_count": 4, "fac_count": 2, "notes": "", "netixlan_updated": "2019-12-14T22:55:53Z",
        "netfac_updated": "2020-09-30T11:35:39Z", "poc_updated": "2020-01-22T04:24:14Z",
        "policy_url": "", "policy_general": "Open", "policy_locations": "Preferred",
        "policy_ratio": false, "policy_contracts": "Required", "allow_ixp_update":
        false, "created": "2011-05-02T21:27:42Z", "updated": "2020-07-13T02:21:20Z",
        "status": "ok"}], "meta": {}}'
  recorded_at: Fri, 08 Oct 2021 02:09:52 GMT
recorded_with: VCR 6.0.0

まとめ