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 に適用しました。