Ruby用単体テストフレームワークtest-unitでのデータ駆動テストの紹介 - 2013-01-23 - ククログ

ククログ

株式会社クリアコード > ククログ > Ruby用単体テストフレームワークtest-unitでのデータ駆動テストの紹介

Ruby用単体テストフレームワークtest-unitでのデータ駆動テストの紹介

test-unitはRuby用のxUnit系の単体テストフレームワークです。2.3.1からデータ駆動テスト機能が追加されていたのですが、2.5.3まではリファレンスに記述がなく、知る人ぞ知る機能でした。

2013-01-23にリリースされた2.5.4ではデータ駆動テスト機能についてのドキュメントが追加されています。

データ駆動テスト自体の説明はUxUを用いたデータ駆動テストの記述を参照してください。

Cucumberのscenario outlinesに似ていると言えばピンと来る人もいるのではないでしょうか。 Cucumberのscenario outlinesも前述のククログ記事の通り、テストのデータとロジックを分離しているのでデータ駆動テストの一種と言えます。

今回は、データ駆動テストを導入した例を見ながらtest-unitでのデータ駆動テスト機能の使い方を紹介します。なお、以降の説明では「テスト対象のデータ」のことを「テストデータ」とします。

データ駆動テスト導入例

データ駆動テストの導入例としてBitClust1での使い方を紹介します。

データ駆動テスト導入前のBitClustのnameutils.rbには次のようなテストメソッドがありました。test_nameutils.rb@r5333から一部を抜粋します。

class TestNameUtils < Test::Unit::TestCase

  include BitClust::NameUtils

  def test_libname?
    assert_equal true, libname?("_builtin")
    assert_equal true, libname?("fileutils")
    assert_equal true, libname?("socket")
    assert_equal true, libname?("open-uri")
    assert_equal true, libname?("net/http")
    assert_equal true, libname?("racc/cparse")
    assert_equal true, libname?("test/unit/testcase")
    assert_equal false, libname?("")
    assert_equal false, libname?("fileutils ")
    assert_equal false, libname?(" fileutils")
    assert_equal false, libname?("file utils")
    assert_equal false, libname?("fileutils\n")
    assert_equal false, libname?("fileutils\t")
    assert_equal false, libname?("fileutils.rb")
    assert_equal false, libname?("English.rb")
    assert_equal false, libname?("socket.so")
    assert_equal false, libname?("net/http.rb")
    assert_equal false, libname?("racc/cparse.so")
  end
# ...省略
end

上記の抜粋箇所だけ取り出してテストを実行すると、結果はこのようになります2

$ ruby test/run_test.rb -n /test_libname.$/ -v
Loaded suite test
Started
TestNameUtils: 
  test_libname?:                                        .: (0.000636)

Finished in 0.000982531 seconds.

1 tests, 18 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed

1017.78 tests/s, 18320.03 assertions/s

テストが1つだけ実行されています。この1つのテストの中で様々なデータに対するアサーションを実行しています。ただ、上のコードでは、どのようなデータに対してテストしているのかを知るためにはソースコードを確認する必要があります。

これをデータ駆動テスト機能を使用して書き換えると以下のようになります。コード全体は現在のtest_nameutils.rb@r5551を参照してください。

class TestNameUtils < Test::Unit::TestCase

  include BitClust::NameUtils

  data("_builtin"                       => [true, "_builtin"],
       "fileutils"                      => [true, "fileutils"],
       "socket"                         => [true, "socket"],
       "open-uri"                       => [true, "open-uri"],
       "net/http"                       => [true, "net/http"],
       "racc/cparse"                    => [true, "racc/cparse"],
       "test/unit/testcase"             => [true, "test/unit/testcase"],
       "empty string"                   => [false, ""],
       "following space"                => [false, "fileutils "],
       "leading space"                  => [false, " fileutils"],
       "split by space"                 => [false, "file utils"],
       "following new line"             => [false, "fileutils\n"],
       "folowing tab"                   => [false, "fileutils\t"],
       "with extension .rb"             => [false, "fileutils.rb"],
       "CamelCase with extension .rb"   => [false, "English.rb"],
       "with extension .so"             => [false, "socket.so"],
       "sub library with extension .rb" => [false, "net/http.rb"],
       "sub library with extension .so" => [false, "racc/cparse.so"])
  def test_libname?(data)
    expected, target = data
    assert_equal(expected, libname?(target))
  end
# ...省略
end

上記の抜粋箇所だけ取り出してテストを実行すると、結果はこのようになります。

$ ruby test/run_test.rb -n /test_libname.$/ -v
Loaded suite test
Started
TestNameUtils: 
  test_libname?[_builtin]:                              .: (0.000591)
  test_libname?[fileutils]:                             .: (0.000389)
  test_libname?[socket]:                                .: (0.000365)
  test_libname?[open-uri]:                              .: (0.000354)
  test_libname?[net/http]:                              .: (0.000355)
  test_libname?[racc/cparse]:                           .: (0.000354)
  test_libname?[test/unit/testcase]:                    .: (0.000349)
  test_libname?[empty string]:                          .: (0.000397)
  test_libname?[following space]:                       .: (0.000346)
  test_libname?[leading space]:                         .: (0.000343)
  test_libname?[split by space]:                        .: (0.000346)
  test_libname?[following new line]:                    .: (0.000353)
  test_libname?[folowing tab]:                          .: (0.000344)
  test_libname?[with extension .rb]:                    .: (0.000344)
  test_libname?[CamelCase with extension .rb]:          .: (0.000347)
  test_libname?[with extension .so]:                    .: (0.000343)
  test_libname?[sub library with extension .rb]:        .: (0.000346)
  test_libname?[sub library with extension .so]:        .: (0.000345)

Finished in 0.007920974 seconds.

18 tests, 18 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed

2272.45 tests/s, 2272.45 assertions/s

アサーションの数は修正前と同じですが、テストの数が修正前よりも増えています。具体的には「1 tests」から「18 tests」に増えています。また、実行結果にテストしたテストデータの名前が表示されるようになったので、どのようなテストデータに対してテストを実行したのかを実行時にも確認できます。

dataとload_dataの使い方

テストデータを登録するためにはdataメソッドまたはload_dataメソッドを使います。それぞれのメソッドの使い方を説明します。

dataメソッドとload_dataメソッドはTest::Unit::TestCaseに定義されている特異メソッドです。publicprivateのようにメソッド定義の直前に書いて使用します。例えば、以下のように書きます。

class TestDataDrivenTest < Test::Unit::TestCase
  data("...")
  def test_xxx(test_data)
    # ...
  end
end

dataメソッドの使い方には次の三種類があります。

  • data(label, data)
  • data(data_set)
  • data(&block)

load_dataメソッドの使い方は次の一種類だけです。

  • load_data(file_name)

それぞれの使い方を順に説明します。

data(label, data)

labelにはテストデータの名前を指定します。dataにはテストデータとして任意のオブジェクトを指定します。ここに指定したオブジェクトがテストメソッドにそのまま渡されます。

require "test-unit"

class TestData < Test::Unit::TestCase
  data("empty string", [true, ""])
  data("plain string", [false, "hello"])
  def test_empty?(data)
    expected, target = data
    assert_equal(expected, target.empty?)
  end
end

この例ではテストデータを配列で指定していますが、複雑なデータを渡すときは、Hashやテストで使いやすいようにラップしたオブジェクトを使うとテストコードが読みやすくなります。

data(data_set)

data_setにはテストデータの名前をキー、テストデータを値とする要素を持つHashを指定します。この使い方の場合は、Hashの各要素の値がテストメソッドにそのまま渡されます。

require "test-unit"

class TestData < Test::Unit::TestCase
  data("empty string" => [true, ""],
       "plain string" => [false, "hello"])
  def test_empty?(data)
    expected, target = data
    assert_equal(expected, target.empty?)
  end
end

data(&block)

ブロックでテストデータを生成することもできます。

ブロックはテストデータの名前をキー、テストデータを値とする要素を持つHashを返すようにします。ランダムな値を生成するテストや、網羅的な値を生成して使うテストが書きやすくなります。外部からテストデータを読み込んで使うようなテストも書きやすくなるでしょう。

以下のようにテストデータの生成部分とテストのロジック部分を独立して書くことができるので、テストが書きやすくなります。

require "test-unit"

class TestData < Test::Unit::TestCase
  data do
    data_set = {}
    data_set["empty string"] = [true, ""]
    data_set["plain string"] = [false, "hello"]
    data_set
  end
  def test_empty?(data)
    expected, target = data
    assert_equal(expected, target.empty?)
  end
end

最初に紹介したnameurils.rbのテストでも網羅的なテストを実行するためにこの機能を使用しています。興味のある人はtest_typemark?test_typechar?を見てください。

load_data(file_name)

load_dataメソッドは外部のファイルからデータを読み込みます。

load_dataはファイルの拡張子によって、ファイル形式を自動的に判断してデータを読み込みます。現在の最新版であるtest-unit-2.5.4では、CSVとTSVに対応しています。

例えば、次の表のようなtest-data.csvという名前のCSVファイルを用意します。

labelexpectedtarget
empty stringtrue""
plain stringfalsehello

ヘッダーの最初の要素(一番左上の要素)は必ず「label」にしてください。

CSVファイルだと以下のようになります。

label,expected,target
empty string,true,""
plain string,false,hello

このCSVファイルを使って書いたテストコードはこのようになります。このファイルをtest-sample.rbとします。

require "test-unit"

class TestData < Test::Unit::TestCase
  load_data("test-data.csv")
  def test_empty?(data)
    assert_equal(data["expected"], data["target"].empty?)
  end
end

実行結果はこのようになります。

$ ruby test-sample.rb -v
Loaded suite TestData
Started
test_empty?[empty string]:                              .: (0.000572)
test_empty?[plain string]:                              .: (0.000424)

Finished in 0.001337316 seconds.

2 tests, 2 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed

1495.53 tests/s, 1495.53 assertions/s

CSVファイルを使ったこのテストコードは、以下のように書いたテストコードと同じテストになります。

# test-sample.rb
require "test-unit"

class TestData < Test::Unit::TestCase
  data("empty string" => {"expected" => true,  "target" => ""},
       "plain string" => {"expected" => false, "target" => "hello"})
  def test_empty?(data)
    assert_equal(data["expected"], data["target"].empty?)
  end
end

また、次のようなヘッダーのないCSVファイルにも対応しています。一番左上の要素が「label」にならないように注意してください。「label」となっていると最初の行をヘッダーとみなします。

empty stringtrue""
plain stringfalsehello

CSVファイルだと以下のようになります。

empty string,true,""
plain string,false,hello

この場合は、次のようなテストコードになります。

# test-sample.rb
require "test-unit"

class TestData < Test::Unit::TestCase
  load_data("test-data.csv")
  def test_empty?(data)
    expected, target = data
    assert_equal(expected, target.empty?)
  end
end

実行結果はこのようになります。

$ ruby test-sample.rb -v
Loaded suite TestData
Started
test_empty?[empty string]:                              .: (0.000584)
test_empty?[plain string]:                              .: (0.000427)

Finished in 0.001361219 seconds.

2 tests, 2 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed

1469.27 tests/s, 1469.27 assertions/s

このようなCSVファイルを読み込んだ場合はdata(data_set)の例と同じように解釈されます。サンプルコードを再掲します。

require "test-unit"

class TestData < Test::Unit::TestCase
  data("empty string" => [true, ""],
       "plain string" => [false, "hello"])
  def test_empty?(data)
    expected, target = data
    assert_equal(expected, target.empty?)
  end
end

CSVファイルやTSVファイルでテストデータを作成できると、テストデータの作成に表計算ソフトやデータ生成用スクリプトを利用できます。そのため、たくさんのパターンのテストケースを作成しやすくなります。ただし、テストデータは多ければ多いほどよいというものではないことに注意してください。テストデータが多くなるとその分テスト実行時間が長くなり、テスト実行コストが高くなります。テストデータを作りやすくなったからといって、必要以上にテストデータを作らないようにしましょう。

まとめ

test-unitでのデータ駆動テスト機能について紹介しました。いろいろなパターンがあるテストをメンテナンスしやすい状態に保つために、データ駆動テスト機能を使ってみてはいかがでしょうか。

  1. るりまプロジェクトで使用しているドキュメント生成ツール。

  2. test/run_test.rbはtest-unitでテストを実行するためのスクリプトです。