HTTP通信を伴うライブラリのRSpecを書く

伴う、というかそれがメインの機能だったりもするわけですが。

少し前、以下の記事でごく単純なOAuthコンシューマの実装を行いました。

この小さなライブラリの使い方は以下の通りです。

require 'simple-oauth'
simple_oauth = SimpleOAuth.new('COMSUMER-KEY', 'COMSUMER-SECRET',
                               'ACCESS-TOKEN', 'TOKEN-SECRET' )
response = simple_oauth.get('http://example.com/')
response = simple_oauth.post('http://example.com/', :foo => 'bar')

これで全機能です。*1

getやpostメソッドでは、内部でNet::HTTPを利用してHTTP通信を行っています。

さて、このライブラリのRSpecを書こうとしたとき、どのように書けばよいのでしょうか?*2

テストを実行する度に、実際にどこかのWebサーバにアクセスするというのもなんだか気持ち悪いです。

SimpleOAuthに限って言えば、キモはAuthorizationヘッダの生成部分なので、最悪そのメソッドだけをテストするようなコードにして済ませてしまう、という選択肢もあるかもしれません。

しかしそれではRSpecの「振舞を記述する」という目的に沿いませんし、やはりちゃんとHTTP通信が行えるかどうか、せめてNet::HTTPに処理が渡るところまでは正しく動くことを確認できるようにしたいですよね。

このようなケースでは、次の2パターンの解決策があると思います。

  1. Net::HTTPをモックオブジェクト化する
  2. WEBrickを使ってダミーサーバを立てる

以下、それぞれの手法で実際にスペックを書いていきます。

Net::HTTPをモックオブジェクト化する

ライブラリの中でNet::HTTP及びその関連クラスのメソッドをどう呼び出しているかにも関わってくるのですが、幸いSimpleOAuthではリクエストの送出はNet::HTTP#requestメソッドに集約されています。

つまり、このメソッドをフックしてそこにスタブを仕込むことで、実際のHTTP通信を伴うことなく、かつライブラリの全機能を漏れなくテストすることができます。

この手法を用いて、SipmleOAuth#getメソッドに対する1つのexampleが定義されただけの最小のスペックファイルを書いてみます。

require 'rubygems'
require 'json'
require 'simple-oauth'

describe SimpleOAuth do
  before do
    @simple_oauth = SimpleOAuth.new('CONSUMER-KEY', 'CONSUMER-SECRET',
                                    'ACCESS-TOKEN', 'TOKEN-SECRET')
  end

  def create_http_mock(request_class)
    http = Net::HTTP.new('localhsot')
    http.should_receive(:request)
        .with(kind_of(request_class))
        .and_return do |request|
      response = Net::HTTPOK.new(nil, 200, nil)
      response.stub!(:body).and_return({
        :method => request.method,
        :path => request.path,
        :body => request.body,
        :authorization => request['Authorization'],
      }.to_json)
      response
    end
    Net::HTTP.should_receive(:new).at_least(1).and_return(http) # Point
  end

  it 'should access a protected resource by using #get method' do
    create_http_mock(Net::HTTP::Get)
    response = @simple_oauth.get('http://localhost/')
    response.should be_an_instance_of(Net::HTTPOK)
    body = JSON.parse(response.body)
    body['authorization'].should match(/^OAuth/)
    # other expectations...
  end

  # other examples...
end

create_http_mockメソッドでNet::HTTPをモックオブジェクト化し、その後にSimpleOAuth#getメソッドを実行することで、HTTP通信を伴うことなくテストを実行します。また、モックオブジェクト化したNet::HTTPが、その呼び出しを検証可能なパラメータを含んだレスポンスを返すようにすることで、きちんと結果に対するexpectationを書くことができます。

この手法のポイントとなるのは、「Net::HTTP.newをフックする」というところです。実際にフックしたいのはNet::HTTP#requestなのですが、Net::HTTPのインスタンスはライブラリの内部で生成されているため、直接は手が出せません。そこでNet::HTTP.newをフックし、予めNet::HTTP#requestをスタブ化したモックオブジェクトを返すようにすることで、ライブラリ内部で呼び出されるNet::HTTP#requestもフックすることができます。

WEBrickを使ってダミーサーバを立てる

テストの実行中にのみローカルにダミーのWebサーバを立て、そこに向けてリクエストを送るようにすることで、実際のHTTP通信まで含めた挙動をチェックすることもできます。ダミーサーバにはWEBrickを利用するのが簡単でしょう。

この手法を用いて、先程と同じSimpleOAuth#getメソッドのexampleのみのスペックファイルを書いてみます。

require 'webrick'
require 'rubygems'
require 'json'
require 'simple-oauth'

describe SimpleOAuth do
  before :all do
    @server_thread = Thread.new do                          # (1)
      server = WEBrick::HTTPServer.new(
        :Port => 10080,
        :Logger => WEBrick::Log.new('/dev/null'),           # (7)
        :AccessLog => [],                                   # (7)
        :StartCallback => Proc.new { Thread.main.wakeup }   # (3)
      )
      server.mount_proc('/test') do |request, response|
        response.content_type = 'application/json'
        response.body = {
          :method => request.request_method,
          :query => request.query,
          :authorization => request['Authorization'],
        }.to_json
      end
      Signal.trap(:INT) { server.shutdown }                 # (4)
      server.start
    end
    Thread.stop                                             # (2)
  end

  after :all do
    Process.kill(:INT, Process.pid)                         # (5)
    @server_thread.join                                     # (6)
  end

  before :each do
    @simple_oauth = SimpleOAuth.new('CONSUMER-KEY', 'CONSUMER-SECRET',
                                    'ACCESS-TOKEN', 'TOKEN-SECRET')
  end

  it 'should access a protected resource by using #get method' do
    response = @simple_oauth.get('http://localhost:10080/test')
    response.should be_an_instance_of(Net::HTTPOK)
    body = JSON.parse(response.body)
    body['authorization'].should match(/^OAuth/)
    # other expectations...
  end

  # other examples...
end

先程のモックオブジェクト化したNet::HTTPと同様、レスポンスとして検証可能なパラメータを含んだJSONオブジェクトを返すことで、呼び出し側のexampleの中できちんとexpectationを書くことができます。

先程と比較して注意すべきポイントが多くなりますので、以下に箇条書きします。サンプルコード中のコメントの番号と対応しています。

  1. ダミーサーバをメインスレッドで動かすとそこで処理が止まってしまいますので、別スレッドで動かします。
  2. スレッドを立ち上げた後、WEBrickがstartするまでメインスレッドを停止しておきます。こうしないと、別スレッドでWEBrickがstartする前にメインスレッドでテストが始まってしまいます。
  3. StartCallbackに渡したProcオブジェクトは、WEBrickがstartしたタイミングで呼び出されます。ここで停止しておいたメインスレッドを再開します。
  4. WEBrickはSIGINTをキャッチしたら終了するようにしておきます。
  5. 全てのテストが完了したら、自プロセスに対してSIGINTを投げます。これを上で設定したtrapに捕捉させ、WEBrickを終了します。
  6. スレッドをjoinして後始末完了です。
  7. ログは邪魔なので全部捨てるようにしておきます。

この手法の利点は、ダミーサーバ側にいくつか定義の追加が必要ですが、401や404など、リクエストが失敗した場合の振舞も手軽にチェックできることでしょうか。

HTTP通信以外の場合

HTTP以外のソケット通信を伴うライブラリの場合は、Net::HTTPの代わりにTCPSocketをモックオブジェクト化したり、WEBrickの代わりにGServerを使ったりする必要があると思います。実際に試してはいませんが、今回書いたサンプルよりは複雑になってしまうでしょう。

多分、探せばその辺の面倒な部分をサポートしてくれるgemはどこかにありそうな気はしますが…。

HTTP通信に関しても、もっとエレガントな手法があるかもしれません。その辺りをご存知の方がいらっしゃいましたら、是非ご教授お願い致します。

*1:一応他にhead, put, deleteメソッドもありますが、使い方は変わりません。

*2:Test::Unitでも構いません。