TDD で作る RakuAPI ライブラリ

RakuAPI - 楽天市場 非公式ウェブサービス
という楽天の非公式 API のライブラリを作るのが流行みたいなので作ってみました。ただそれだけでは面白くないので、最近自分が TDD でライブラリ作るときの方法も軽くご紹介します。

まずはインターフェイスの構想

何はともあれ、どんなインターフェイスを定義して、どんな結果が返ってくるのかがイメージできないとライブラリは作りにくいです。というわけでざっくり最初に構想を練ります。
RakuAPI の場合は WebAPI がシンプルに使えて良い感じなので、構想を練るのに考え込む、というのはありませんでした。
そんなんで、RakuAPI.new でインスタンスを取得して、search メソッドで第一引数に検索文字列、第二引数はオプションでジャンルやプライスを渡せるように、結果は配列にStruct が格納されてる感じにしよう。と考えました。

テストを書く

構想があればそれに沿って書くだけですので超簡単。search メソッドで検索した結果が構造体で中身のメンバは云々、というのを書くだけでした。ちなみにいつもこんな感じのディレクトリ/ファイル構成でやってます。

RakuAPI
|-- lib
|   `-- raku_api.rb
`-- test
    `-- test_rakuapi.rb

じゃあ早速 test 書きますを。xUnit 互換の Test::Unit を使います。

# 上の階層の lib ディレクトリをライブラリパスに追加
$LOAD_PATH << File.dirname(__FILE__) + '/../lib'
require 'test/unit'
require 'raku_api'

class RakuAPITest < Test::Unit::TestCase
  def setup
    @raku_api = RakuAPI.new
  end

  def test_instance
    assert_instance_of RakuAPI, @raku_api
  end

  def test_search
    results = @raku_api.search 'Core 2 Duo', :genre => :pc
    assert_instance_of Array, results
    results.each do |result|
      # 構造体かどうか
      assert_kind_of Struct, result
      # 構造体の指定したメンバの型チェック
      assert_instance_of Fixnum, result.price
      struct_methods_call result, %w(title tax url thumbnail_url shop_name shop_url) do |method|
        assert_instance_of String, method
      end
      # url は http で始まってるかどうか
      struct_methods_call result, %w(url thumbnail_url shop_url) do |method|
        assert_match /^http/, method
      end
    end
  end

  def struct_methods_call(struct, methods)
    methods.each do |method|
      yield struct.send(method)
    end
  end
end

さて、search メソッドで検索して、結果がちゃんと取得できるかどうか、のテストがこれで書き終わりました。早速実行してみると…。

Loaded suite test_rakuapi
Started
EE
Finished in 0.006955 seconds.

  1) Error:
test_instance(RakuAPITest):
NameError: uninitialized constant RakuAPITest::RakuAPI
    test_rakuapi.rb:8:in `setup'

  2) Error:
test_search(RakuAPITest):
NameError: uninitialized constant RakuAPITest::RakuAPI
    test_rakuapi.rb:8:in `setup'

2 tests, 0 assertions, 0 failures, 2 errors

とまあ当たり前のように失敗します。で、テストを先に書くとここからが楽で、raku_api.rb の方で実装を書いたらテストを実行するだけ。で、最終的に全部テストが通るまで raku_api.rb を実装してとりあえず動く形にして、その後リファクタリングでわかりやすい or 速く動作するコードなど好きにアレンジします。
実際にかかった時間は、テスト書くのに10分、実装に15分ぐらいでテスト書くのに全体の 40%もかけてるじゃん!と思われるかもしれません。が、テストを書くことによって、実装時のチェックをいちいち print デバッグのような方法を行わなくてもすんで時間が削減できますし、なにより実装時のストレスが軽減されるので楽です。なのでライブラリのような物を作るときは、小物でもテストを先に書いておくとだいぶ楽になります。
で、最終形の raku_api.rb はこんな感じに。

#!/usr/bin/env ruby
begin
  require 'rubygems'
  require_gem 'scrapi'
rescue LoadError
  require 'scrapi'
end
require 'uri'

class RakuAPI
  RAKU_API_URI = 'http://rakuapi.ddo.jp/api'

  class RakuScraper < Scraper::Base
    def self.decamelize(str)
      str.gsub(/(^.+)([A-Z])/, '\1_\2').downcase
    end

    elements = %w(Title Price Tax Url ThumbnailUrl ShopName ShopUrl)
    elements.each {|el| process el, decamelize(el) => :text }

    def collect
      self.price = price.to_i
    end

    result *elements.map {|el| decamelize(el) }
  end

  attr_accessor :options
  def initialize(options = {})
    @options = {
      :parser => :html_parser
    }.update options
  end

  def search(keyword, options = {})
    uri = URI.parse(RAKU_API_URI)
    uri.query = queryize options.update(:keyword => keyword)
    Scraper.define do
      process 'Result', 'results[]' => RakuScraper
      result :results
    end.scrape(uri, self.options)
  end

  private
  def queryize(hash)
    hash.map {|i| i.map {|j| URI.escape j.to_s }.join '=' }.join('&')
  end
end

XML なんだからパースには rexml とかでいいじゃん、と思うかもしれませんが、 scrAPI を使うことで、わざわざ Struct 作らなくても、結果が自動で構造体にマッピングされてちょう楽、です。collect メソッドを定義することで、最終的な型変換なども思いのままに行えます。scrAPI つかいたいだけちゃうんかと云われたらそんな気もしますが気にしません。
で、テストも通ってすっきりですね。

Loaded suite test_rakuapi
Started
..
Finished in 0.213071 seconds.

2 tests, 112 assertions, 0 failures, 0 errors

このライブラリの使い方はこんな感じで。(といっても test まんまですけど)

require 'raku_api'
$KCODE = 'u'

raku_api = RakuAPI.new
results = raku_api.search 'Perfume', :genre => :cddvd, :row => 2

require 'pp'
pp results

結果

[#<struct
  title="Perfume〜Complete Best〜",
  price=2999,
  tax="税込、送料込",
  url="http://item.rakuten.co.jp/book/4061751/",
  thumbnail_url=
   "http://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/ogs_410606/4106060429.jpg?_ex=64x64",
  shop_name="楽天ブックス",
  shop_url="http://www.rakuten.co.jp/book/">,
 #<struct
  title="Perfume",
  price=3058,
  tax="税込、送料別",
  url="http://item.rakuten.co.jp/book/3991671/",
  thumbnail_url=
   "http://thumbnail.image.rakuten.co.jp/@0_mall/book/cabinet/ogs_410603/4106031362.jpg?_ex=64x64",
  shop_name="楽天ブックス",
  shop_url="http://www.rakuten.co.jp/book/">]

というわけで、些細なライブラリでも TDD だと楽だよー、というお話でした。ちなみに TDD には↓の本から入りました。

テスト駆動開発入門
テスト駆動開発入門
posted with amazlet on 06.09.27
ケント ベック Kent Beck 長瀬 嘉秀 テクノロジックアート
ピアソンエデュケーション (2003/09)
売り上げランキング: 126,549

価格.com API for ruby

価格.com WEBサービス API マニュアル
http://rails2u.com/misc/kakaku_com_api.rb.txt

価格.com WEBサービス APIがリリースされた!というわけで作ってみました。ほぼ RakuAPI ライブラリのコピペですんだ、という…。これはオフィシャルなサービスなのでそのうち rubyforge に上げる…かも(コペ)。ひょっとしたらインターフェイス周り変えるかも。id:naoya が作った naoyaのはてなダイアリー - WebService::KakakuCom で使ってる Data::Page がなにやら便利そうなのでそれっぽいページャクラス作るかもー。
使い方は

require 'kakaku_com_api'
require 'pp'
$KCODE = 'u'
k = KakakuComAPI.new
results = k.search 'Sony', 'CategoryGroup' => 'Camera'
puts "total_count: #{results.num_of_result}"
results.items[0..1].each {|i| pp i }

な感じで。結果は

total_count: 390
#<struct
 product_id="20203010268",
 product_name="HDR-HC3",
 maker_name="SONY",
 category_name="カメラ本体>ビデオカメラ",
 pv_ranking="1",
 image_url="http://img.kakaku.com/images/productimage/m/20203010268.jpg",
 item_page_url="http://kakaku.com/item/20203010268/",
 bbs_page_url="http://kakaku.com/bbs/Main.asp?PrdKey=20203010268",
 review_page_url=
  "http://kakaku.com/prdevaluate/evaluate.asp?PrdKey=20203010268",
 lowest_price=86699,
 num_of_bbs=3973>
#<struct
 product_id="00502411015",
 product_name="サイバーショット DSC-T10",
 maker_name="SONY",
 category_name="カメラ本体>デジカメ",
 pv_ranking="11",
 image_url="http://img.kakaku.com/images/productimage/m/00502411015.jpg",
 item_page_url="http://kakaku.com/item/00502411015/",
 bbs_page_url="http://kakaku.com/bbs/Main.asp?PrdKey=00502411015",
 review_page_url=
  "http://kakaku.com/prdevaluate/evaluate.asp?PrdKey=00502411015",
 lowest_price=27290,
 num_of_bbs=257>