test/unit(Test::Unit) を使った繰り返しのテストケースを動的に書く方法

## 論よりコード

Ruby は Class や Method すら動的コード生成ができるので、以下のように書くことができる。

```ruby
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
```

これは以下のように書いたのと同義。

```ruby
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?](https://stackoverflow.com/questions/6453235/what-does-damp-not-dry-mean-when-talking-about-unit-tests)
* [テストコードの期待値はDRYを捨ててベタ書きする ～テストコードの重要な役割とは？～](https://qiita.com/jnchito/items/eb3cfa9f7db752dcb796)

この件については、私も基本的に賛成です。
テストコードは "仕様" であるべきで、そういう意味では「自然言語で書かれた仕様書」でも何度も同じ表現が出るのは、わかりやすさを優先した結果としては仕方のないことです。
とはいえ、何ごともバランスが大事であることは言うまでもありませんが。

さて、ここで悩ましいのが「大量のデータに対する検証処理」です。
例えばこんなものがあります

* 消費税計算ロジックの検証。商品マスタ件数は10万件 (^^;;
* ブログの引っ越し。エントリー数は1000件 (つq`)

## 愚直に実装すると

ブログの引っ越しを対象としてみましょう。
引っ越し先のブログで `<article>` タグ内に 1 バイト以上のコンテンツが存在する事を愚直に検証すると、以下のようになるでしょう。

```ruby
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 一覧をイテレーター(配列)にして繰り返しテストする案が思いつきますが、これは後述する理由からお勧めできません。

```ruby
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 記法](https://docs.ruby-lang.org/ja/latest/doc/spec=2fliteral.html#percent)は便利ですねー。さて、一見良さそうです。が、実は assert 失敗時に問題が発生します。
以下が実行結果ですが、**どの URL で失敗したのかわからない** のです。

```console
$ 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つ動的にテストのメソッドを作ることができます。

```ruby
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
```

このコードは以下と等価で、愚直に実装したものと同じになります。

```ruby
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()` と明示されています。

```console
$ 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]` の値に対してのみテストを行いたい場合は、以下のように指定します。

```console
$ bundle exec ruby test_multiple.rb --name test_1
```

## 実装のポイント

`Class.new` による動的クラス生成と `define_method` による動的メソッド生成がキーです。
このへんに興味が出たら "Ruby 黒魔術" とかで検索してみてください。

## やりすぎに注意

assertion 条件が画一的な時には有効です。
逆に、入力された値によって適用する assertion が異なる(= 条件分岐が発生する)ようであれば、愚直に実装した方が DAMP となります。
※そもそもテストケース内で条件分岐が発生している時点で、テストケース自体を見直すべきでしょう。

## あとがき

この分野は素人なので、これが合ってるのかわからん。

EoT