はじめに
Web APIを使って様々なサービスと連携するというアーキテクチャはすっかり定着した感があります。みなさんも、Web APIを使ってデータをやりとりするアプリケーションを書く機会も増えているのではないでしょうか。
Web APIを使うアプリケーションの開発では、テストやデバッグをする際のAPIアクセスが悩みどころとなります。本物のサーバを使ったのではテストデータの初期化などに手間がかかりますし、逆にHTTPアクセス自体をスタブやモックを使って間接化してしまうとそれが本当に有効なテストなのか不安が残ってしまいます。
筆者も、仕事やプライベートでのコーディングでこのような悩みに何度も遭遇しました。これらを解決するために開発したのがwwです(wwと書いて'double-web'と読みます) 。
ダミーWebサーバ作成ライブラリww(Double Web)
wwは、Webサービスの簡単な偽物を容易に作るためのライブラリです。次のような機能を備えています。
軽量WebサービスフレームワークSinatra (原悠さんによるRuby Freaks Loungeでの紹介記事 )を使って簡単なWebサービスAPIを作成できる。
実際にWebサーバとして動作する。アプリケーション側でHTTPライブラリに細工する必要がない。
対象となる外部サービスとのやりとりを目視で確認できる。
事前に期待するリクエストを定義したり(モック) 、呼び出し後にリクエストを検証したり(スパイ)できる。
モック? スパイ? ダブル?
開発中やデバッグの際に、本番とは別のサーバを使うなどして、外部の環境に影響を与えないようにすることは一般的な開発手法の一つです。特に、テスト駆動での開発では、自動化されたテストを使ってプログラムを繰り返し動しながら開発を進めるため、外部サービスへ影響を与えないことがとても重要なポイントとなります。そのため、これまでも外部サービスを一時的に偽装する手段として、「 モック」や「スタブ」といった手法が用いられてきました。
この「偽装」手段は流派によって微妙に呼び方や扱いが異なります。コミュニケーションを円滑にするため、『 xUnit Test Patterns 』では、これらの名前と役割を定義しています。本稿でもこの定義に従うため、ここで簡単に説明しておきます。
ダブル(Double)
モックやスタブ、ダミー、スパイなど、実際の環境から切り離してテスト可能にするための手法の総称です。wwの名前も「WebのDouble」ということでここからとっています。
スタブ(Stub)
自動化テストからの呼び出しを単純に置き換えるための手法です。実際に呼び出されたときに、固定値など簡単な応答をします。呼ばれ方の検証などには関知しません。
モック(Mock)
自動化テストとコンポーネント間で、相互の呼び出しを検証するための手法です。呼ばれ方や、呼ばれる順番などを検証します。また、モックを定義するということは「そのテストで呼び出される」はずであることを意味します。そのため、一度も呼び出されていない場合にその旨を通知することもあります。
スパイ(Spy)
自動化テストから呼び出されたことを事後に検証するための情報をとっておく手法です。簡単な応答をしたあとに、実際の呼ばれ方も検証したい場合に使います。
より詳しい情報は、以下のURLから入手できます。
図1 doubleの関係図
実際にwwを使ってみる
それでは、実際にwwを使ってみましょう。今回はサンプルとして、とても簡単なミニブログサービスのクライアントを作ってみます。ミニブログサービスには次の機能があります。
ユーザごとのエントリをJSON形式で取得できる。
JSON形式で自分のエントリを投稿できる。
このサービスの「ダブル」 、つまり開発用のサーバをwwで実装します。
その上で、クライアントに求められる機能は次のようなものになりそうです。
指定した複数ユーザのエントリを集約し、時系列に表示する。
エントリ本文だけを指定すると、JSON形式でミニブログに投稿する。
とても簡単ですが、これをwwを活用して作っていきましょう。
ユーザごとのエントリをJSON形式で取得する
まずは、wwを使ってミニブログのサーバを実装します。wwはgemでインストールできます。また今回はJSONを扱うので、そのライブラリもインストールしておきましょう。
$ gem install ww json
ユーザごとにエントリの一覧をJSONで返す 機能をwwで実装すると次のようになります。
# miniblog.ru
require 'rubygems'
require 'json'
require 'ww'
app = WW::SpyEye.to_app do
min_offset = 0
get("/messages/:user.json") do |user|
content_type "application/json"
t = Time.local(2010, 3, 13, 12, 34 + (min_offset += 1), 56)
body = [
{:message => "#{user}です。ミニブログ始めました", :posted_on => t.iso8601},
{:message => "2つめの#{user}つぶやきです", :posted_on => (t + 10*60).iso8601},
].to_json
end
end
run app
WW::SpyEyeは、目視確認用のサーバを作るためのクラスです。WW::SpyEye.to_app()メソッドにブロックを渡し、Sinatraの記法でアクションを定義します。この例ではGETで/messages/#{ユーザ名}.jsonというパスにアクセスされた場合のアクションを定義しています。今回は2件のエントリを返します。文章はほぼ固定ですが、ソート順をわかりやすくするため、投稿時刻("posted_on")にバラツキを加えています。
正しく定義できているかを確認するため、次のコマンドでRack経由で起動してみます。
$ rackup -p 3080 miniblog.ru
起動後にcurlコマンドなどでアクセスすると、目的のJSONを取得できます。
$ curl http://localhost:3080/messages/moro.json
[{"message":"moro\u3067\u3059\u3002\u30df\u30cb\u30d6\u30ed\u30b0\u59cb\u3081\u307e\u3057\u305f",
"posted_on":"2010-03-13T12:35:56+09:00"},
{"message":"2\u3064\u3081\u306emoro\u3064\u3076\u3084\u304d\u3067\u3059",
"posted_on":"2010-03-13T12:45:56+09:00"}]
※見やすくするため改行を入れています。
クライアント側のコードは次のようになります。
# miniblog_client.rb
require 'rubygems'
require 'json'
require 'open-uri'
class MiniblogClient
def initialize(conn, *friends)
@connection = conn
@friends = friends
end
def entry_list(newer_first = true)
entries = @friends.
map {|f| JSON.parse(@connection.get("/messages/#{f}.json")) }.
flatten.
sort_by {|e| e["posted_on"] }
newer_first ? entries.reverse : entries
end
end
class Connection
def initialize(host, port)
@host, @port = host, port
end
def get(abs_path)
open("http://#{@host}:#{@port}#{abs_path}").read
end
end
if __FILE__ == $0
conn = Connection.new("localhost", 3080)
client = MiniblogClient.new(conn, "alice", "bob", "charls")
puts "新着順"
client.entry_list.each{|e| puts "#{e["posted_on"]}: #{e["message"]}" }
puts "古い順"
client.entry_list(false).each{|e| puts "#{e["posted_on"]}: #{e["message"]}" }
end
実行すると、次のように表示され、Connection#getのOpenURIを使ったアクセスで意図通りの 結果が返っていることが分かります。
$ ruby miniblog_client.rb
新着順
2010-03-13T13:05:56+09:00: 2つめのcharlsつぶやきです
2010-03-13T13:04:56+09:00: 2つめのbobつぶやきです
2010-03-13T13:03:56+09:00: 2つめのaliceつぶやきです
2010-03-13T12:55:56+09:00: charlsです。ミニブログ始めました
2010-03-13T12:54:56+09:00: bobです。ミニブログ始めました
2010-03-13T12:53:56+09:00: aliceです。ミニブログ始めました
古い順
2010-03-13T12:56:56+09:00: aliceです。ミニブログ始めました
2010-03-13T12:57:56+09:00: bobです。ミニブログ始めました
2010-03-13T12:58:56+09:00: charlsです。ミニブログ始めました
2010-03-13T13:06:56+09:00: 2つめのaliceつぶやきです
2010-03-13T13:07:56+09:00: 2つめのbobつぶやきです
2010-03-13T13:08:56+09:00: 2つめのcharlsつぶやきです
リクエストをスパイする
エントリの取得はうまく実装できていそうですが、サーバ側から見ても本当に正しく動いているでしょうか。wwはサーバ側で受け付けたリクエストの内容をクライアント側で確認する「スパイ」機能と それを目視で確認するためのインターフェースを備えています。
スパイ機能を有効にするには、アクションを定義するget()メソッドの前で、spy()メソッドを呼び出します。
diff --git a/miniblog.ru b/miniblog.ru
index a88d294..24940cf 100644
--- a/miniblog.ru
+++ b/miniblog.ru
@@ -7,7 +7,7 @@ require 'ww/spy_eye'
app = Ww::SpyEye.to_app do
m_offset = 0
- get("/messages/:user.json") do |user|
+ spy.get("/messages/:user.json") do |user|
content_type "application/json"
t = Time.local(2010, 3, 13, 12, 34 + (m_offset += 1), 56)
body = [
サーバを^Cで停止させたうえで修正し、再度起動します。 その後、http://localhost:3080/spy にアクセスすると、リクエスト確認用のインターフェースを表示できます。
再起動したばかりなので、この時点では何も表示されていませんね(図2 ) 。
図2 サーバ再起動後に、http://localhost:3080/spyにアクセスした時の画面
先ほどのクライアントを走らせたあとで確認すると、6リクエスト(3ユーザx2回)の リクエストが来ていることがわかります(図3 ) 。
図3 クライアントを走らせた後にhttp://localhost:3080/spyにアクセスした時の画面
さらに、「 Headers」をクリックすると、リクエストヘッダも確認できます。
図4 リクエストヘッダも確認できる
このように、実際にサーバに届いたリクエストがどのようなものだったか簡単に目視できます。また、POSTリクエストを受け取った場合には、リクエストボディも確認できます。
まとめ
今回は、外部サーバとのやりとりで必要になる偽物の呼び方と役割を説明し、Webサーバのダブルとなるwwを紹介しました。さらに、wwを使ってWebリクエストをスパイする方法も説明しました。次回は、エントリの投稿機能の実装を通じて、wwの自動化テストへの適用方法を説明します。