3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-08-13

論よりコード

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; 見た目のわかりやすさ) を重視した方が良いというのは、よく聞く話です。

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

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

  • 消費税計算ロジックの検証。商品マスタ件数は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

3
0
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?