トップ «前の日記(2005-09-10) 最新 次の日記(2005-09-15)» 編集

3 日坊主日記


2005-09-14 [長年日記]

_ [Rails] モデルに対するテスト

ActiveRecord の機能をテストする必要はないというご意見には同意します。 しかし、モデルに固有のロジックはモデルクラスに載せますから (それが ActiveRecord クオリティ) モデルに対するテストは必要になると思います。

私が作成したものからいくつか事例を拾ってみます。

validation (validates_* が使えない場合)

条件が合わなくて validates_* が使えない場合、条件を自分で書くのでテストを書きます。

ここでは半角カナが含まれるかチェックしています。

  • customer_test.rb:
 class CustomerTest < Test::Unit::TestCase
   fixtures :customers, :services, :cares, :shops
 ...
   def test_validate_yomi
     @ochi.yomi = 'オチマドカ' # 半角を含む
     assert_equal false, @ochi.save
     assert_equal 1, @ochi.errors.count
   end
 ...
 end
  • customer.rb:
 class Customer < ActiveRecord::Base
 ...
 protected

   def validate
     errors.add(:yomi, "半角カナが含まれています") if /[\xA6-\xDF]/s =~ self.yomi
   end
 ...
 end

計算項目

自分で書くロジックにはテストを書きます。 誕生日から年齢を計算するとか。

計算項目のキャッシュ

フックを使って計算項目をキャッシュするパターンがあります。 フックの結果には確信を持てないのでテストを書きます。

ひとつの table

会員 code の頭2桁が店舗 code で、customers table には code field とは別に shop_code field があるとします (レガシィな香りがします…)。

Customer#code を元に Customer#shop_code を設定します。

  • customer_test.rb:
 class CustomerTest < Test::Unit::TestCase
   fixtures :customers, :children
 ...
   def test_kakuo_set_code
     # code を変更すると shop_code を code[0,2] として設定する。
     @kakuo.code = "0101234"
     @kakuo.save
     assert_equal "01", @kakuo.shop_code
   end
 ...
 end
  • customer.rb:
 class Customer < ActiveRecord::Base
 ...
   before_save 'self.shop_code = self.code[0,2]'
 end
1対多の関連

AR の counter_cache は要素数をキャッシュしますが、 合計値や最大値・最小値のキャッシュも考えられます。

ここに customers 1..* services という関係にあるふたつの table があるとします。ここで

  • Customer#point は self.services.point の合計値
  • Customer#service_at は self.services.service_at のうち最近のもの (最大値)

という計算項目のキャッシュを考えます。

効率の良い (SQL の呼び出しが少ない) 作りにするためにあれこれいじる際には、テストがあると気が楽です。

  • service_test.rb:
 class ServiceTest < Test::Unit::TestCase
   fixtures :customers, :services, :cares
 ...
   # point

   # 既存の service の point を修正すると customer.point が修正される。
   def test_point_update
     assert_equal 8, @ochi.point
     @tamaki.point = 0
     @tamaki.save
     @ochi.reload
     assert_equal 6, @ochi.point
   end

   # 既存の service を削除すると customer.point が修正される。
   def test_point_destroy
     assert_equal 8, @ochi.point
     @tamaki.destroy
     @ochi.reload
     assert_equal 6, @ochi.point
   end

   # service を登録すると customer.point が修正される。
   def test_point_create
     assert_equal 8, @ochi.point
     @ochi.services << Service.create("point"=>-2, "service_at"=>Time.now)
     @ochi.reload
     assert_equal 6, @ochi.point
   end

   def test_point_new
     assert_equal 8, @ochi.point
     @ochi.services << Service.new("point"=>-2, "service_at"=>Time.now)
     @ochi.reload
     assert_equal 6, @ochi.point
   end

   # service_at

   # 既存の service の service_at を修正すると customer.service_at が修正される。
   def test_service_at_update
     assert_equal Time.local(2002,7,27,18,30,0), @ochi.service_at
     @tamaki.service_at = Time.local(2005,3,23,18,50,0)
     @tamaki.save
     @ochi.reload
     assert_equal Time.local(2005,3,23,18,50,0), @ochi.service_at
   end

   # 既存の service を削除すると customer.service_at が修正される。
   def test_service_at_destroy
     assert_equal Time.local(2002,7,27,18,30,0), @ochi.service_at
     @shirakata_2.destroy
     @ochi.reload
     assert_equal Time.local(2002,7,27,15,25,0), @ochi.service_at #== @shirakata_1.service_at
   end

   # point == 0 との組み合わせ
   def test_service_at_destroy_with_point_0
     @shirakata_2.point = 0
     assert_equal Time.local(2002,7,27,18,30,0), @ochi.service_at
     @shirakata_2.destroy
     @ochi.reload
     assert_equal Time.local(2002,7,27,15,25,0), @ochi.service_at #== @shirakata_1.service_at
   end

   # service を登録すると customer.service_at が修正される。
   def test_service_at_create
     assert_equal Time.local(2002,7,27,18,30,0), @ochi.service_at
     @ochi.services << Service.create("service_at"=>Time.local(2005,3,23,18,50,0))
     @ochi.reload
     assert_equal Time.local(2005,3,23,18,50,0), @ochi.service_at
   end

   def test_service_at_create_but_not_latest
     assert_equal Time.local(2002,7,27,18,30,0), @ochi.service_at
     @ochi.services << Service.create("service_at"=>Time.local(2002,7,27,18,15,0))
     @ochi.reload
     assert_equal Time.local(2002,7,27,18,30,0), @ochi.service_at
   end

   def test_service_at_new
     assert_equal Time.local(2002,7,27,18,30,0), @ochi.service_at
     @ochi.services << Service.new("service_at"=>Time.local(2005,3,23,18,50,0))
     @ochi.reload
     assert_equal Time.local(2005,3,23,18,50,0), @ochi.service_at
   end

   def test_service_at_new_but_not_latest
     assert_equal Time.local(2002,7,27,18,30,0), @ochi.service_at
     @ochi.services << Service.new("service_at"=>Time.local(2002,7,27,18,15,0))
     @ochi.reload
     assert_equal Time.local(2002,7,27,18,30,0), @ochi.service_at
   end

   def test_validate_yomi
     @ochi.yomi = 'オチマドカ' # 半角を含む
     @ochi.save_without_validation
     @ochi.reload
     assert_equal 'オチマドカ', @ochi.yomi # 半角を含む
     assert_equal false, @ochi.valid?
     assert_equal false, @tamaki.save
     assert_equal 1, @ochi.errors.count
   end

 end
  • service.rb:
 class Service < ActiveRecord::Base
   has_many :cares, :dependent => true
   belongs_to :customer

   def before_destroy
     point = read_attribute("point")
     if point != 0
       customer.point -= point
       # customer.save
     end
   end

   def after_destroy
     customer.service_at = customer_latest_service_at
     customer.save
   end

   before_save { |record| record.customer && record.customer.valid? }

   def after_save
     if customer
       customer.point = customer_sum_point
       customer.service_at = customer_latest_service_at
       customer.save
     end
   end

 private

   def customer_sum_point
     connection.select_one("select sum(point) from services where customer_id = #{customer.id}")["sum(point)"]
   end

   def customer_latest_service_at
     connection.select_one("select max(service_at) from services where customer_id = #{customer.id}")["max(service_at)"]
   end

 end

DB 依存の機能

AR アダプタに実装されてないために自分で書くところや、DB の仕様に依存しそうなところにテストを書きます。

FOUND_ROWS() (MySQL)

(LIMIT に関わらず) マッチした行数を得るために FOUND_ROWS() を使います。

  • customer_test.rb:
 class CustomerTest < Test::Unit::TestCase
   fixtures :customers, :services, :cares, :shops
 ...
   def test_found_rows
     Customer.find_all_with_calc_found_rows
     assert_equal 3, Customer.found_rows
   end
 ...
 end
  • customer.rb:
 class Customer < ActiveRecord::Base
 ...
   def self.find_all_with_calc_found_rows(conditions = nil, orderings = nil, limit = nil, joins = nil)
     sql  = "SELECT SQL_CALC_FOUND_ROWS * FROM #{table_name} "
     sql << "#{joins} " if joins
     add_conditions!(sql, conditions)
     sql << "ORDER BY #{orderings} " unless orderings.nil?
     add_limit!(sql, :limit => limit)

     find_by_sql(sql)
   end

   def self.found_rows
     self.connection.select_one("SELECT FOUND_ROWS()")["FOUND_ROWS()"].to_i
   end
 ...
 end
更新時刻の設定

save 時に更新時刻を設定します。 ここでは最初の timestamp field に NULL を設定すると現在時刻を設定するという MySQL の仕様を利用します。

topic_test.rb:

 class TopicTest < Test::Unit::TestCase
   fixtures :topics
 ...
   def test_topic_503_update_on
     update_on = @topic_503.update_on
     # p update_on
     sleep(1)
     @topic_503.content = "..."
     assert @topic_503.save
     @topic_503.reload
     new_update_on = @topic_503.update_on
     # p new_update_on
     assert new_update_on > update_on
   end

   def test_reply_503_1_update_on
     update_on = @topic_503.update_on
     # p update_on
     sleep(1)
     @reply_503_1.content = "..."
     assert @reply_503_1.save
     @topic_503.reload
     new_update_on = @topic_503.update_on
     # p new_update_on
     assert new_update_on > update_on
   end
 ...
 end

Reply は Topic に対する single table inheritance.

  • topic.rb
 class Topic < ActiveRecord::Base
   has_many :replies, :dependent => true, :foreign_key => "parent_id"
   def before_update
     self.update_on = nil#Time.now
   end
 end
  • reply.rb
 class Reply < Topic
   belongs_to :topic, :foreign_key => "parent_id", :counter_cache => true
   def before_update
     super
     # self.topic.update_on = nil#Time.now
     self.topic.save
   end
 end

やっぱり AR もテスト

AR の挙動に確信を持てないところにテストを書きます。 habtm のカラム値が String で返るとか。

_ [tDiary][第五] Amazon プラグインの更新

第五の amazonimg.rb に適用しました。

[]