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でのデータ駆動テスト機能の使い方を紹介します。なお、以降の説明では「テスト対象のデータ」のことを「テストデータ」とします。
データ駆動テストの導入例としてBitClust*1での使い方を紹介します。
データ駆動テスト導入前のBitClustのnameutils.rbには次のようなテストメソッドがありました。test_nameutils.rb@r5333から一部を抜粋します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
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を参照してください。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
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
メソッドはTest::Unit::TestCase
に定義されている特異メソッドです。public
やprivate
のようにメソッド定義の直前に書いて使用します。例えば、以下のように書きます。
1 2 3 4 5 6 |
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)
それぞれの使い方を順に説明します。
label
にはテストデータの名前を指定します。data
にはテストデータとして任意のオブジェクトを指定します。ここに指定したオブジェクトがテストメソッドにそのまま渡されます。
1 2 3 4 5 6 7 8 9 10 |
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_set
にはテストデータの名前をキー、テストデータを値とする要素を持つHash
を指定します。この使い方の場合は、Hash
の各要素の値がテストメソッドにそのまま渡されます。
1 2 3 4 5 6 7 8 9 10 |
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 |
ブロックでテストデータを生成することもできます。
ブロックはテストデータの名前をキー、テストデータを値とする要素を持つHash
を返すようにします。ランダムな値を生成するテストや、網羅的な値を生成して使うテストが書きやすくなります。外部からテストデータを読み込んで使うようなテストも書きやすくなるでしょう。
以下のようにテストデータの生成部分とテストのロジック部分を独立して書くことができるので、テストが書きやすくなります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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メソッドは外部のファイルからデータを読み込みます。
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とします。
1 2 3 4 5 6 7 8 |
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ファイルを使ったこのテストコードは、以下のように書いたテストコードと同じテストになります。
1 2 3 4 5 6 7 8 9 10 |
# 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
この場合は、次のようなテストコードになります。
1 2 3 4 5 6 7 8 9 10 |
# 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)
の例と同じように解釈されます。サンプルコードを再掲します。
1 2 3 4 5 6 7 8 9 10 |
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でのデータ駆動テスト機能について紹介しました。いろいろなパターンがあるテストをメンテナンスしやすい状態に保つために、データ駆動テスト機能を使ってみてはいかがでしょうか。