こんにちは、会員事業部の小室 (id:hogelog) です。気づけば弊社に入社してから2年と2ヶ月が経っていました。
今回はその2年2ヶ月で初めて会社プロダクトを rails new
したRailsアプリケーションと、そのアプリケーションで利用したRack::VCR
(https://github.com/miyagawa/rack-vcr) について簡単に解説します。
新規アプリケーションの構成
今回私が新規に作成したRailsアプリケーションは仮にここではomoikane(仮)と呼ぶことにします。omoikaneはリクエストがあると社内の汎用APIサーバにアクセスし、APIサーバから取得した情報を元にレスポンスを返すアプリケーションです。omoikaneの実装・構成そのものはさほど難しくなかったのですが、一つ問題点がありました。
このアプリケーションはAPIのレスポンスの仕様が破壊的に変更された場合に正しく動作しなくなってしまいます。そのような変更を入れてしまわないためにはいくつかの選択肢が考えられます。
- 気をつける
- 関係するアプリのコードをよく調べて問題ないことを確認する
- 関係各位*1に確認する
- テストコードを書いてCIでAPIの破壊が無いかチェックし続ける
気をつけるのは大変なので、もちろんテストコードでチェックしておきたいものです。しかしそれには
- omoikane側ではAPIのレスポンスをモックし、APIモックレスポンスを元にしたレスポンスが正しいかテストし
- API側ではomoikane側のリクエストを模したリクエストに対して、omoikane側が必要とするレスポンスとなっているかテストする
必要があります。
汎用APIとomoikaneが別のRailsアプリではなく、モノリシックRailsアプリの別エンドポイントなどであったならもっと簡単なテストで済んだでしょう。マイクロサービスのためにはしょうがない、がんばって書こうと言われたら書けるかもしれませんが、がんばるのは疲れます。疲れたくありません。
そこで現れるのがちょうど社内で @KazuCocoa @adorechic @miyagawa の議論から生まれたRack::VCRです。
Rack::VCR
Rack::VCRとはRailsやSinatraなどのRackアプリケーションに導入することで、アプリケーション への リクエストとそのレスポンスをVCRカセット形式で出力・またはVCRカセットのデータを元にモックサーバとして動作させることができるRackミドルウェアです。
あるAPIへのリクエストとレスポンスを一度実行して記録することでテストデータを作成するのが通常のVCRであるのに対し、APIを提供する側があらかじめテストデータを生成するという考え方です。
以下に今回のコード例とともにその役割を解説します。ここで例示するコードは https://github.com/hogelog/rack-vcr-sample にまとめてありますので、詳しく知りたい場合はそちらをご確認ください。
リクエストの記録
Rack::VCRの基本機能はリクエストのVCRカセットへの記録です。
例では api/ というAPIアプリのspecでRack::VCRを利用してVCRカセットを記録します。
if Rails.env.test? Rails.configuration.middleware.insert(0, Rack::VCR) end
api/config/initializers/rack_vcr.rb
テスト実行時のみRackミドルウェアの先頭にRack::VCRを入れておきます。
RSpec.configure do |config| config.around(:each, type: :request) do |example| host! "api.example.com" vcr_cassette = example.metadata[:vcr] if vcr_cassette VCR.use_cassette(vcr_cassette, record: :all) do example.run end else example.run end end ... end
vcr: "cassette_name"
のようなメタデータがついたspecでのみVCRカセットを記録するように設定しておくと、以下のように自然な形でVCRカセットを生成するspecを書くことができます。
RSpec.describe "Books", type: :request do ... describe "books#index", vcr: "books_index" do it "returns books" do get "/books" expect(response).to have_http_status(200) data = JSON.parse(response.body) expect(data.size).to eq(2) expect(data.map{|book| book["title"] }).to eq(%w(K&R Camel)) end end ...
api/spec/requests/books_spec.rb
https://github.com/hogelog/rack-vcr-sample は例示のために api/spec/fixtures/cassettes 以下のyamlファイル(VCRカセット)をリポジトリに追加していますが、実際に運用する場合はspec実行のたびに変更が発生してしまうので .gitignore に入れるなどリポジトリに入れない運用が適切です。
リクエストのモック
これはRack::VCRの機能ではなくVCRの機能なのですが、Rack::VCRで記録したVCRカセットはテストに利用することができます。
例ではrails-app/というapiを利用するRailsアプリのテストで上述のRack::VCRで生成したVCRカセットを利用しています。
ここは特にRack::VCR特有の処理はないですが、こちらもvcr: "cassete_name"
のような指定があるspecでのみVCRカセットを利用するように設定します。
(また、この例だと活用してませんがmatch_requests_on
に渡す値を調整することで意図的に一部のクエリやパスを無視することでid
の値が不定になるようなテストも記述することが出きます)
require "vcr" VCR.configure do |config| config.cassette_library_dir = "spec/fixtures/cassettes" config.hook_into :webmock end RSpec.configure do |config| config.around(:each) do |example| vcr_cassette = example.metadata[:vcr] if vcr_cassette match = example.metadata[:match] ? example.metadata[:match] : %i(host path query) VCR.use_cassette(vcr_cassette, record: :none, match_requests_on: match) do example.run end else example.run end end ... end
RSpec.describe BooksController, type: :controller do describe "#index", vcr: "books_index" do it "show books" do get :index expect(response).to have_http_status(200) expect(assigns(:books).map{|book| book["title"] }).to eq(%w(K&R Camel)) end end ... end
rails-app/spec/controllers/books_controller_spec.rb
リクエストの再生
Rack::VCRは以下のように簡単なコードでVCRカセットを利用してレスポンスするモックサーバとして動作させることができます。このようなモックサーバーを使うことで、VCRカセットを直接扱えないRubyアプリケーション以外のアプリケーションでもVCRカセットを利用できます。
require "rack" require "rack/vcr" VCR.configure do |config| config.cassette_library_dir = File.join(File.dirname(__FILE__), "cassettes") end class MockApp def self.call(env) [501, {}, ["Not Implemented"]] end end app = Rack::Builder.new do use Rack::VCR, replay: true, cassette: "test" run MockApp end run app
ただしこれではどのリクエストでも一つのカセットのレスポンスのみ返すためあまり柔軟な利用ができません。
よって以下のように"HTTP_X_VCR_CASSETTE"
ヘッダが付与されたリクエストのみヘッダで与えられたカセットを使うようにします。
class CassetteLocator def initialize(app) @app = app end def call(env) cassette = env["HTTP_X_VCR_CASSETTE"] match = (env["HTTP_X_VCR_MATCH"] || "path query").split.map(&:to_sym) if cassette VCR.use_cassette(cassette, record: :none, match_requests_on: match) do @app.call(env) end else @app.call end end end ... app = Rack::Builder.new do use CassetteLocator use Rack::VCR, replay: true run MockApp end run app
これを通常のRackアプリのように起動するだけで"HTTP_X_VCR_CASSETTE"
ヘッダが付与されたリクエストのみヘッダで与えられたカセットを利用したモックレスポンスを返すようになります。
$ bundle exec rackup [2015-10-09 02:07:08] INFO WEBrick 1.3.1 [2015-10-09 02:07:08] INFO ruby 2.2.0 (2014-12-25) [x86_64-darwin14] [2015-10-09 02:07:08] INFO WEBrick::HTTPServer#start: pid=76284 port=9292
$ curl -H 'X_VCR_CASSETTE: books_index' 'http://localhost:9292/books' | jq . % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 214 100 214 0 0 19113 0 --:--:-- --:--:-- --:--:-- 19454 [ { "id": 1, "title": "K&R", "created_at": "2015-10-08T11:45:00.000Z", "updated_at": "2015-10-08T11:45:00.000Z" }, { "id": 2, "title": "Camel", "created_at": "2015-10-08T11:45:00.000Z", "updated_at": "2015-10-08T11:45:00.000Z" } ]
おまけ: Androidアプリのテスト
上述のモックサーバを利用するとAndroidアプリなどVCRカセットを直接読めないアプリケーションのテストも可能になります。
モックサーバを利用して以下のようなインターフェースのAPIクライアントクラスのテストします。
public class ApiClient { public ApiClient(String url); public String getUrl(); public Observable<List<Book>> getBooks(); protected Request createRequest(String path); }
テスト時にはHTTP_X_VCR_CASSETTE
ヘッダを追加で付与するためモック用APIクライントを利用することにします。
public class MockApiClient extends ApiClient { private final String cassette; public MockApiClient(String url, String cassette) { super(url); this.cassette = cassette; } @Override protected Request createRequest(String path) { return new Request.Builder() .url(getUrl() + path) .get() .addHeader("X_VCR_CASSETTE", cassette) .build(); } }
android-app/app/src/test/java/org/hogel/androidapp/MockApiClient.java
細かいことは https://github.com/hogelog/rack-vcr-sample/blob/master/android-app/app/src/test/java/org/hogel/androidapp/ApiClientTest.java 等を直接読んでもらうとして、ちょっと工夫すると以下の様にアノテーションでどのVCRカセットを利用するかテスト毎に指定することが出来ます。
@Test @VcrCassette("books_index") public void testBookIndex() { apiClient.getBooks().subscribe(new Action1<List<Book>>() { @Override public void call(List<Book> books) { assertThat(books.size(), is(2)); assertThat(books.get(0).getTitle(), is("K&R")); assertThat(books.get(1).getTitle(), is("Camel")); } }, new Action1<Throwable>() { @Override public void call(Throwable throwable) { assertTrue(false); } }); }
android-app/app/src/test/java/org/hogel/androidapp/ApiClientTest.java
弊社での利用例
先に書いた社内汎用APIとomoikaneにおいてはこれらの機能のうち記録とモックのみ利用しています。
Rack::VCRを利用したテスト追加の流れとしては
- omoikaneにspecを追加して実行
- 当然対応するカセットが存在しないのでVCRに怒られます
$ ./bin/rspec FF Failures: 1) BooksController#index show books Failure/Error: get :index VCR::Errors::UnhandledHTTPRequestError: ================================================================================ An HTTP request has been made that VCR does not know how to handle: GET http://api.example.com/books ...
- 上記エラーを元に汎用APIにomoikaneが必要とするリクエストを投げるspecを追加、実行
- 汎用APIのspecが生成したVCRカセットを使ってomoikaneのspecが成功することを確認、コミット
のような流れでおこなっています。
またCIでは
- 汎用APIのCIが走るとomoikane用のVCRカセットが生成され、S3にアップロードされる
- 汎用APIのCIが完了した時にomoikaneのCIがキックされる
- omoikaneはテスト開始時にS3から最新のVCRカセットをダウンロードしてくる
のように常に最新の汎用APIとomoikaneの組み合わせが正しく動作することをテストし続け、汎用APIに破壊的な変更をコミットしてしまった場合にすぐにCIでそれを検知できるようにしています。
テスト追加の流れにある
- 上記エラーを元に汎用APIにomoikaneが必要とするリクエストを投げるspecを追加、実行
という部分は工夫すればもっと自動化できそうな気がするのですが、現状ちょっとがんばって目で読んで手で書く作業をしています。改善の余地があります。
未来
Rack::VCR自体はかなり汎用的な機能を提供するライブラリであり、ここに示した利用方法がベストプラクティスとは限りません。
アプリケーション連携テストに関してはomoikaneへのRack::VCR導入にも大きく貢献してくれた @taiki45 に火がついて、日々 @KazuCocoa などと激論を交わし、Rack::VCRの改善なりそれ以外の何かなり、現状のRack::VCRでは足りないどこかを目指して頑張っているのでそのうちまた面白いものが生まれてくると思うので楽しみにお待ち下さい。
弊社のテストエンジニアはRack::VCRのような開発ツールの開発・運用からテスト・開発プロセスそのものの改善など、弊社のエンジニアリングの中核部分を担う重要な役職です。
すごい面白そうなので私自身も社内配置転換を願って参入しようかと思えるぐらいの役職なので、興味のある方はぜひ一度遊びに来てみてください。*2