この記事は RubyMotion Advent Calendar 2012 の 23 日目の記事です。
#いつものように自分のブログに書こうとしたのですがコードハイライターをGistに依存してて今回はちまちま貼るのが面倒になったのでGistをオリジナルにしちゃいます(汗)
さてRubyMotion Advent Calendarも最後の方まで来ましたがテストに関するエントリーが依然としてない。私も実際のところRubyMotionでテスト書いたことない(ていうかまだまともなアプリ書いてないw)ですがチャレンジしてみようと思います。そんな目的なのでUIのテストは扱いません(汗)
またRubyMotionは少し制限はありますがRubyですのでメタプログラミングも魅力の一つのはずです。その辺も絡めながら書けたらいいなと思っておりますが、今日のネタは行き当たりばったりなのでどこに向かうかは分かりません(笑)というかRubyMotion Advent Calendar用のエントリーですがRuby寄りのネタになってしまうと思われます(・ω・)
メタプログラミングと言えばActiveRecordじゃん?という事で、書籍を表現するシンプルなモデルクラスBookを考えてみましょう。ただし私はしばらくActiveRecordなモデルじゃなくてMongoDB用のMongoMapperやMongoidを使っていたためそっちっぽくなります。
TDDのRed-Green-Refactorを実践するためにテストから書きます。
$ motion create try_model
$ cd try_model
## spec/models/book_spec.rb
# -*- encoding: utf-8 -*-
describe Book do
end
#最初から存在するテストは削除しておいてください…
#現在最新の1.30ではfreeの警告が出るのが気持ち悪かったので1.29で動作確認しています。バージョンを下げたい場合はこちら(`・ω・´)
$ rm spec/main_spec.rb
$ rake spec
:
2012-12-23 11:14:31.435 try_model[38729:11303] *** Terminating app due to uncaught exception 'NameError', reason: 'uninitialized constant Book (NameError)
Bookクラスがないので怒られました。最低限のBookクラスを作ります。
## app/models/book.rb
class Book
end
$ rake spec
:
Book
0 specifications (0 requirements), 0 failures, 0 errors
まだspecの内容を書いてないので0 specificationsではありますが、成功(Green)となりました。
まだリファクタリングするほどじゃないので続いて、生成やりましょうか。空のパラメータでnewするのはRubyのデフォルト実装なのでHashedなパラメータで生成するところから。
で、おもむろにrspecだと思って書き始めたら全然違った(汗)rspecだとコメントみたいな感じになるはず。letが使えないのはlambdaとか使ってなんとかできそうな気はしますが時間がないのでかっこわるいけどbeforeでやりきる(-ω-)
contextはなさそうだったのでnestは場合分けしたい時も全てdescribeを使わなきゃだめですか?rspecに比べて読みにくい感じになってしまってます…
こんな感じか。(コメントにrspecだったらこう書くだろうなというのを残してあります。あ、私、英語はめちゃくちゃなんで済みませんw)
## spec/models/book_spec.rb
# -*- encoding: utf-8 -*-
describe Book do
describe "#new" do
# let(:obj) { Book.new(params) }
describe "empty parameters" do
# let(params) { {} }
before { @obj = Book.new }
it "should not be nil" do
#obj.should_not be_nil
@obj.should.not.equal nil
end
end
describe "valid parameters" do
# let(params) { {title:'書名'} }
before do
@params = {title:'書名'}
@obj =Book.new(@params)
end
it "should not be nil" do
#obj.should_not be_nil
@obj.should.not.equal nil
end
it "title is expect" do
#obj.instance_variable_get('@title').should eql(params[:title])
@obj.instance_variable_get('@title').should.equal @params[:title]
end
end
end
end
フィールドの定義をインスタンス変数を持つという実装決めうちにしてあるけど後でリファクタリングすると言うことで。
$ rake spec
:
Book
#new
empty parameters
- should not be nil
valid parameters
- should not be nil
- title is expect [FAILED]
Bacon::Error: nil.eql?("書名") failed
spec.rb:553:in `satisfy:': valid parameters - title is expect
spec.rb:567:in `method_missing:'
spec.rb:183:in `block in run_spec_block'
spec.rb:307:in `execute_block'
spec.rb:183:in `run_spec_block'
spec.rb:198:in `run'
3 specifications (3 requirements), 1 failures, 0 errors
#ココにたどり着くまでもMacBaconのノリが分からず苦労しました…
@titleがなくてエラーになったので実装します。
## app/models/book.rb
:
def initialize params={}
@title = params[:title]
end
:
$ rake spec
:
3 specifications (3 requirements), 0 failures, 0 errors
そろそろリファクタリングしましょうか。フィールド定義を動的にしたいので@titleをread_attribute(:title)というメソッドで取得するように変更しました。
## spec/models/book_spec.rb
:
it "title is expect" do
@obj.read_attribute(:title).should.equal @params[:title]
end
$ rake spec
:
NoMethodError: undefined method `read_attribute' for #<Book:0x73b3520 ...>
:
3 specifications (2 requirements), 0 failures, 1 errors
rake aborted!
Redになったので直します。
## app/models/book.rb
:
def initialize params={}
@fields = params
end
def read_attribute key
@fields[key]
end
$ rake spec
:
3 specifications (3 requirements), 0 failures, 0 errors
またGreen。モジュールに抽出するリファクタリングしてみます。
## app/models/document.rb
module Document
def read_attribute key
@fields[key]
end
end
## app/models/book.rb
class Book
include Document
def initialize params={}
@fields = params
end
end
まだGreen。
## app/models/document.rb
:
def initialize params={}
@fields = params
end
def read_attribute key
@fields[key]
end
## app/models/book.rb
class Book
include Document
end
Greenのままモジュールへ機能の移動終了。
ファイルが2つになったのでspecも分けます。specのリファクタリングはGeenの時にやるのが肝心。Document用のspecを追加
## spec/models/document_spec.rb
# -*- encoding: utf-8 -*-
describe Document do
end
もちろんGreenなので、Documentのspecを書き足していきます。
属性になんでもかんでも入れられるのはモデルとして自由すぎるのでfieldというDSL的な構文で定義するようにしてみます。
## spec/models/document_spec.rb
describe Document do
before do
@Model = Class.new do
include Document
end
end
describe ".field" do
before do
@Model.instance_eval do
field :valid_field, type: String
end
end
it "should receive valid_field param" do
@Model.new(valid_field: "a value").should.not.equal nil
end
it "should not receive invalid_field" do
should.raise() { @Model.new(invalid_field: "a value") }
end
end
end
$ rake spec
:
.field
- should receive valid_field param [ERROR: NoMethodError]
- should not receive invalid_field [ERROR: NoMethodError]
:
NoMethodError: undefined method `field' for #<Class:0x102c5130>
:
NoMethodError: undefined method `field' for #<Class:0x10276550>
:
5 specifications (3 requirements), 0 failures, 2 errors
クラスメソッドのフィールドが定義されていないのでRedになりました。
RubyMotionでもClass.newによるクロージャ的なクラスの生成やinstance_evalも定義されていて動いてそうです。
余談ですが、上記のspecは次の内容と等価です。
class ModelClass
include Document
field :valid_field, type: String
end
ModelClass.new(valid_field: "a value")
# => インスタンス生成される
ModelClass.new(invalid_field: "a value")
# => 例外
それでは、フィールドを定義するクラスメソッドを実装してみます。includeしたクラスから呼べる必要があるので、Documentモジュールではincludeされた際に自動でクラスメソッド用に用意したメソッドをextendすれば可能です(他のやり方もあると思いますが)。
## app/models/document.rb
module Document
def self.included(base)
base.extend(ClassMethods)
end
:
module ClassMethods
def field name, options={}
end
end
end
この時点でspecを実行すると
$ rake spec
:
.field
- should receive valid_field param
- should not receive invalid_field [FAILED]
:
Bacon::Error: #<Bacon::Context: … @ModelClass=#<Class:0x104c10e0>>.raise?() failed
:
5 specifications (5 requirements), 1 failures, 0 errors
新たにひとつ成功するようになって、失敗はもうひとつになりました。ただ成功するようになった方はfieldクラスメソッドが必要なパラメータを受け取って使えるようにだけできちんと実装されたわけではありませんから、他のテストを追加して実装の追加が必要です。
しかしまずは、失敗した方のテストが通るようにします。こちらはfieldで定義されてないフィールドがインスタンス生成時にエラーにならないので失敗していますからエラーになるように実装します。
## app/models/document.rb
def self.included(base)
base.extend(ClassMethods)
(@@defined_fields ||= {})[base] = []
end
def initialize params={}
@fields = {}
params.each do |key,value|
if @@defined_fields[self.class].include?(key)
@fields[key] = value
else
raise "ERROR: Invalid Field #{key}"
end
end
end
:
module ClassMethods
def field key, options={}
Document.class_variable_get('@@defined_fields')[self] << key
end
end
:
やってることはincludeされる毎に定義されたクラス用の配列を定義して、field定義されると、その配列にkeyを保存しておき、インスタンス生成時に初期化対象のフィールドが定義されているかを配列に含まれているかどうかでチェックして必要ならエラーとしました。
specを実行すると
$ rake spec
:
empty parameters
- should not be nil
valid parameters
- should not be nil [ERROR: RuntimeError]
- title is expect [ERROR: RuntimeError]
:
.field
- should receive valid_field param
- should not receive invalid_field
:
5 specifications (3 requirements), 0 failures, 2 errors
また別のspecが2つエラーになりました。しかしこれは当たり前です。BookクラスはDocumentをincludeしていてfield定義していない:titleフィールドで初期化しようとしているからです。Bookクラスも修正します。
## app/models/book.rb
class Book
include Document
field :title, type: String
end
$ rake spec
:
5 specifications (5 requirements), 0 failures, 0 errors
ひゃっはー!、通るようになった。さて次は…
どんどんTDDし続けようとしたのですが、慣れないRubyMotionのMacBaconに四苦八苦していた時間が長く、Advent Calendarのタイムリミットまであと1時間を切ってしまいました(-ω-)
今回やったことは
- RubyのTDD開発っぽい事をMacRubyでもやってみる
- しかしUIのテストは難しそうなので避けた
- MacBaconはrspecとはだいぶ違うようだ
- メタプログラミングはほんのちょっと入れただけだけど動いた
という内容でした。
実装方法やそもそもやろうとしてることに対してふさわしくないなど、みなさんにも一家言あるかと思いますが、世の中の実装方法を全く見ずに勢いで書いたのでごめんなさい。その代わりコードが下手な分、TDDっぽい雰囲気が出たんじゃないかな。ごめんなさい。