読者です 読者をやめる 読者になる 読者になる

マイクロサービス時代を乗り越えるために、Rack::VCRでらくらくアプリケーション間テスト

こんにちは、会員事業部の小室 (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

api/spec/spec_helper.rb

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

rails-app/spec/spec_helper.rb

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

mock/config.ru

これを通常の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);
}

https://github.com/hogelog/rack-vcr-sample/blob/master/android-app/app/src/main/java/org/hogel/androidapp/ApiClient.java

テスト時には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

クックパッド テストエンジニアの募集

それ以外の職種の方ももちろんたくさん募集してます

*1:多くの場合誰が関係各位なのかも自明ではない

*2:でもやっぱりそんな高度なテストエンジニアや高い技術力を持つ技術部、インフラ部などの力をつまみ食いしながらアプリ開発して価値創造していく現在の部署も面白いのでもうしばらくやっていこうと思います

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/