技術部の中野です。
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」を利用することで解決できることがあります。
VCRは以下の仕組みから成り立っています。
- APIの呼び出しをフックする
- 初回実行時のアクセスは、リクエスト/レスポンスをカセットと呼ばれるYAML形式で保存
- 2回目以降の実行時のアクセスは、保存していたYAMLファイルを読み込んで利用する(APIにはアクセスしない)
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
まとめ
- 外部APIを利用するプログラムのテスト方法は、「モック/スタブ」や「Symmetric API Testing」などがある
- Rubyの場合「VCR」を使うと便利です
- 他の言語でも、同様の実装があるようです