Create a gist now

Instantly share code, notes, and snippets.

@iwazer /gist:4363573
Last active Dec 10, 2015

What would you like to do?
RubyMotionでTDDしつつどこまでMetaProgrammingできるかな?の実験

この記事は RubyMotion Advent Calendar 2012 の 23 日目の記事です。

#いつものように自分のブログに書こうとしたのですがコードハイライターをGistに依存してて今回はちまちま貼るのが面倒になったのでGistをオリジナルにしちゃいます(汗)

RubyMotionでTDDしつつどこまでMetaProgrammingできるかな?の実験

さてRubyMotion Advent Calendarも最後の方まで来ましたがテストに関するエントリーが依然としてない。私も実際のところRubyMotionでテスト書いたことない(ていうかまだまともなアプリ書いてないw)ですがチャレンジしてみようと思います。そんな目的なのでUIのテストは扱いません(汗)

またRubyMotionは少し制限はありますがRubyですのでメタプログラミングも魅力の一つのはずです。その辺も絡めながら書けたらいいなと思っておりますが、今日のネタは行き当たりばったりなのでどこに向かうかは分かりません(笑)というかRubyMotion Advent Calendar用のエントリーですがRuby寄りのネタになってしまうと思われます(・ω・)

メタプログラミングと言えばActiveRecordじゃん?という事で、書籍を表現するシンプルなモデルクラスBookを考えてみましょう。ただし私はしばらくActiveRecordなモデルじゃなくてMongoDB用のMongoMapperやMongoidを使っていたためそっちっぽくなります。

TDDでやってみる

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っぽい雰囲気が出たんじゃないかな。ごめんなさい。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment