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
に定義されている特異メソッドです。public
やprivate
のようにメソッド定義の直前に書いて使用します。例えば、以下のように書きます。
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ファイルを用意します。
label | expected | target |
---|---|---|
empty string | true | "" |
plain string | false | hello |
ヘッダーの最初の要素(一番左上の要素)は必ず「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 string | true | "" |
plain string | false | hello |
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でのデータ駆動テスト機能について紹介しました。いろいろなパターンがあるテストをメンテナンスしやすい状態に保つために、データ駆動テスト機能を使ってみてはいかがでしょうか。