論よりコード
Ruby は Class や Method すら動的コード生成ができるので、以下のように書くことができる。
ARR = [true, false, true]
Class.new(Test::Unit::TestCase) do
ARR.each_with_index do |e, idx|
define_method "test_#{idx}" do
assert e
end
do
end
これは以下のように書いたのと同義。
ARR = [true, false, true]
class AnyTestCase < Test::Unit::TestCase
def test_0
assert ARR[0]
end
def test_1
assert ARR[1]
end
def test_2
assert ARR[2]
end
end
テストコードは DRY より DAMP
テストコードは DRY(Don’t Repeat Yourself; 繰り返しを避ける)よりも DAMP(Descriptive and Meaningful Phrases; 見た目のわかりやすさ) を重視した方が良いというのは、よく聞く話です。
- What does “DAMP not DRY” mean when talking about unit tests?
- テストコードの期待値はDRYを捨ててベタ書きする ~テストコードの重要な役割とは?~
この件については、私も基本的に賛成です。
テストコードは "仕様" であるべきで、そういう意味では「自然言語で書かれた仕様書」でも何度も同じ表現が出るのは、わかりやすさを優先した結果としては仕方のないことです。
とはいえ、何ごともバランスが大事であることは言うまでもありませんが。
さて、ここで悩ましいのが「大量のデータに対する検証処理」です。
例えばこんなものがあります
- 消費税計算ロジックの検証。商品マスタ件数は10万件 (^^;;
- ブログの引っ越し。エントリー数は1000件 (つq`)
愚直に実装すると
ブログの引っ越しを対象としてみましょう。
引っ越し先のブログで <article>
タグ内に 1 バイト以上のコンテンツが存在する事を愚直に検証すると、以下のようになるでしょう。
require 'test/unit'
require 'httpclient'
require 'nokogiri'
class WebTestCase < Test::Unit::TestCase
def setup
@c = HTTPClient.new
end
def test_0
doc = Nokogiri::HTML(@c.get("https://example.com/entry/one").body)
assert_compare 1, "<", doc.css('article').text.length
end
def test_1
doc = Nokogiri::HTML(@c.get("https://example.com/entry/two").body)
assert_compare 1, "<", doc.css('article').text.length
end
def test_2
doc = Nokogiri::HTML(@c.get("https://example.com/entry/three").body)
assert_compare 1, "<", doc.css('article').text.length
end
end
これをあと def test_1000
まで書くと言ったら微妙感あります。
イテレーターで繰り返す実装
検証条件(assert_compare)は変化が無いので、URL 一覧をイテレーター(配列)にして繰り返しテストする案が思いつきますが、これは後述する理由からお勧めできません。
require 'test/unit'
require 'httpclient'
require 'nokogiri'
URLS = %w(
https://example.com/entry/one
https://example.com/entry/two
https://example.com/entry/three
)
class WebTestCase < Test::Unit::TestCase
def setup
@c = HTTPClient.new
end
def test_content_length
URLS.each do |url|
doc = Nokogiri::HTML(@c.get(url).body)
assert_compare 1, "<", doc.css('article').text.length
end
end
end
%w 記法は便利ですねー。さて、一見良さそうです。が、実は assert 失敗時に問題が発生します。
以下が実行結果ですが、どの URL で失敗したのかわからない のです。
$ bundle exec ruby test_using_array.rb
Started
F
=========================================================================================================================================================================================
test_using_array.rb:25:in `test_content_length'
test_using_array.rb:25:in `each'
24: def test_content_length
25: URLS.each do |url|
26: doc = Nokogiri::HTML(@c.get(url).body)
=> 27: assert_compare 1, "<", doc.css('article').text.length
28: end
29: end
30: end
test_using_array.rb:27:in `block in test_content_length'
Failure: test_content_length(WebTestCase):
<1> < <0> should be true
<1> was expected to be less than
<0>.
=========================================================================================================================================================================================
Finished in 0.0074571 seconds.
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
1 tests, 3 assertions, 1 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
0% passed
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
これはテストの単位がメソッド(上の例なら def test_content_length
)毎であるためです。
コード生成するくらいなら、Ruby 自身に動的に作らせればいいじゃない
イテレーター(配列)の1つ1つに対して、個々のテストのメソッドが作られて、その中でテストされるようにしたいわけです。
やはり愚直にコピペで実装するしかないのか?秀丸のマクロでコード生成するしかないのか?という思考に至った時に思い出していただきたいのが、Rubyの黒魔術(メタプログラミング)である動的コード生成です。
冒頭でも紹介した通り Ruby は動的にコードを生成できます。 Class や Method も例外じゃありません。それを使えば、イテレーターの中身に対して1つ1つ動的にテストのメソッドを作ることができます。
URLS = %w(
https://example.com/entry/one
https://example.com/entry/two
https://example.com/entry/three
)
Class.new(Test::Unit::TestCase) do
def setup
@c = HTTPClient.new
end
URLS.each_with_index do |url, idx|
define_method "test_#{idx}" do
doc = Nokogiri::HTML(@c.get(url).body)
assert_compare 1, "<", doc.css('article').text.length
end
end
end
このコードは以下と等価で、愚直に実装したものと同じになります。
URLS = %w(
https://example.com/entry/one
https://example.com/entry/two
https://example.com/entry/three
)
class AnyTestCase < Test::Unit::TestCase
def setup
@c = HTTPClient.new
end
def test_0
doc = Nokogiri::HTML(@c.get(URLS[0]).body)
assert_compare 1, "<", doc.css('article').text.length
end
def test_1
doc = Nokogiri::HTML(@c.get(URLS[1]).body)
assert_compare 1, "<", doc.css('article').text.length
end
def test_2
doc = Nokogiri::HTML(@c.get(URLS[2]).body)
assert_compare 1, "<", doc.css('article').text.length
end
end
以下の実行例は、失敗した場所が Failure: test_2()
と明示されています。
$ bundle exec ruby test_using_black_magic.rb
Loaded suite test_using_black_magic
Started
..F
=========================================================================================================================================================================================
test_using_black_magic.rb:27:in `block (3 levels) in <main>'
Failure: test_2():
<1> < <0> should be true
<1> was expected to be less than
<0>.
=========================================================================================================================================================================================
Finished in 0.007288 seconds.
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
3 tests, 3 assertions, 1 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
66.6667% passed
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
#each_with_index
は 0 オリジン(はじまり)です。 test_2 で失敗したという事は ARR[2]
の値の時に失敗したという事になるので、その値を検証していけばよいわけです。
--name
での指定にも対応できる
イテレーターに格納された値の順番が保証されていれば --name
での指定にも対応してくれます。例えば ARR[1]
の値に対してのみテストを行いたい場合は、以下のように指定します。
$ bundle exec ruby test_multiple.rb --name test_1
実装のポイント
Class.new
による動的クラス生成と define_method
による動的メソッド生成がキーです。
このへんに興味が出たら "Ruby 黒魔術" とかで検索してみてください。
やりすぎに注意
assertion 条件が画一的な時には有効です。
逆に、入力された値によって適用する assertion が異なる(= 条件分岐が発生する)ようであれば、愚直に実装した方が DAMP となります。
※そもそもテストケース内で条件分岐が発生している時点で、テストケース自体を見直すべきでしょう。
あとがき
この分野は素人なので、これが合ってるのかわからん。
EoT