ククログ

株式会社クリアコード > ククログ > Ruby 2.6.0とtest-unitとデータ駆動テスト

Ruby 2.6.0とtest-unitとデータ駆動テスト

Rubyのbundled gemのtest-unitをメンテナンスしている須藤です。

歴史

test-unitはxUnitスタイルのテスティングフレームワークです。Rubyのテスティングフレームワークの歴史(2014年版)にまとめてある通り、Ruby本体に標準添付されています。

Rubyに標準添付されているライブラリーには実は次の3種類あります。

  • ただの標準添付ライブラリー(例:URI

    • requireするだけで使えるライブラリー
  • default gem(例:csv)

    • requireするだけで使えるライブラリー

    • RubyGemsで更新できる

    • Gemfileでgemを指定しなくても使える

  • bundled gem(例:test-unit)

    • requireするだけで使えるライブラリー

    • RubyGemsで更新できる

どれも標準添付ライブラリーなのでrequireするだけで使えます。違いはRubyGems・Bundlerとの関係です。

ただの標準添付ライブラリーはRubyGemsでアップグレードすることはできませんし、Bundlerで特定のバージョンを指定することもできません。使っているRubyに含まれているものを使うだけです。

default gemはRubyGemsでアップグレードすることもできますし、Bundlerで特定のバージョンを指定することもできます。Bundlerを使っていてgem名を指定しなかった場合は使っているRubyに含まれているものを使います。

bundled gemはRubyGemsでアップグレードすることもできますし、Bundlerで特定のバージョンを指定することもできます。Bundlerを使っていてgem名を指定しなかった場合は使えません。Bundlerを使っていなければrequireするだけで使えます。

Ruby 2.6.0でより高速になったcsvはRuby 2.6.0からdefault gemになっています。

test-unitはRuby 2.2.0で再度標準添付されるようになってからbundled gemになっています。

そんなtest-unitのデータ駆動テスト機能をさらに便利にしたものがRuby 2.6.0に入っています。

データ駆動テスト

データ駆動テストとは同じテスト内容をいろいろなデータで実行するテスト方法です。パラメーター化テストと呼ばれることもあります。いろいろな入力に対するテストを簡潔に書きたいときに便利です。

test-unitでは結構前からデータ駆動テストをサポートしています。

たとえば、正の数同士の足し算と負の数同士の足し算をテストすることを考えます。データ駆動テスト機能を使わない場合は次のようにそれぞれのケースについてテストを作ります。

require "test-unit"

class TestAdd < Test::Unit::TestCase
  def test_positive_positive
    assert_equal(3, my_add(1, 2))
  end

  def test_negative_negative
    assert_equal(-3, my_add(-1, -2))
  end
end

データ駆動テスト機能を使う場合はテストは1つで、テストに使うデータを複数書きます。

require "test-unit"

class TestAdd < Test::Unit::TestCase
  data("positive + positive", [3, 1, 2])
  data("negative + negative", [-3, -1, -2])
  def test_add(data)
    expected, augend, addend = data
    assert_equal(expected, my_add(augend, addend))
  end
end

データが増えてくるほど、データ駆動テスト機能を使った方がテストを書きやすくなります。データを追加するだけで済むからです。ただ、読みやすさは従来のテストの方が上です。テストに使うデータがベタ書きされているからです。

test-unitのデータ駆動テスト機能をもっと知りたくなった人はRuby用単体テストフレームワークtest-unitでのデータ駆動テストの紹介を参照してください。

データ表生成機能

Ruby 2.6.0に入っているtest-unitではデータ駆動テストがさらに便利になっています。

まだなんと呼ぶのがよいか決めかねているのですが、今のところデータ表(data matrix)と呼んでいるものを生成する機能が入っています。

データ表というのは各テストで使うデータをまとめたものです。前述のテストの場合は次のようになります。dataを使う毎に1行増えます。

ラベル expected augend addend
"positive + positive" 3 1 2
"negative + negative" -3 -1 -2

このデータ表をいい感じに生成する機能が入っています。

前述のテストで正の数と負の数を足す場合もテストしたくなったとします。その場合、従来のデータ駆動テスト機能の書き方では次のように書きます。dataを2つ増やしています。

require "test-unit"

class TestAdd < Test::Unit::TestCase
  data("positive + positive", [3, 1, 2])
  data("negative + negative", [-3, -1, -2])
  data("positive + negative", [-1, 1, -2]) # 追加
  data("negative + positive", [1, -1, 2])  # 追加
  def test_add(data)
    expected, augend, addend = data
    assert_equal(expected, my_add(augend, addend))
  end
end

データ表は次のようになります。

ラベル expected augend addend
"positive + positive" 3 1 2
"negative + negative" -3 -1 -2
"positive + negative" -1 1 -2
"negative + positive" 1 -1 2

データ表生成機能を使うと次のように書けます。dataの第一引数にSymbolを指定しているところがポイントです。テストに渡されるデータはHashになっていてキーがシンボルで値が対象データです。

require "test-unit"

class TestAddDataMatrix < Test::Unit::TestCase
  data(:augend, [1, -1])
  data(:addend, [2, -2])
  def test_add(data)
    augend = data[:augend]
    addend = data[:addend]
    assert_equal(augend + addend,
                 my_add(augend, addend))
  end
end

これで次のデータ表を生成できます。

ラベル augend addend 備考
"addend: 2, augend: 1" 2 1 正+正
"addend: 2, augend: -1" 2 -1 正+負
"addend: -2, augend: 1" -2 1 負+正
"addend: -2, augend: -1" -2 -1 負+負

期待する結果(expected)は生成できないのでRuby組み込みのInteger#+の結果を使っています。これは実は大事なポイントです。データ表生成機能を使えるのは次の場合だけです。

  • 期待する結果がデータに依らず一意に定まる

  • データから期待する結果を計算できる

今回の場合は期待する結果を計算できるので使えました。

なお、期待する結果は必ずしも正しい結果を返すはずの既存の実装(今回の場合はInteger#+)を使わなくても大丈夫です。次のように「エンコードしてデコードしたら元に戻る」ようなときでもデータ表生成機能を使えます。これは性質をテストしているケースです。(性質をテストすることについてはここを参照してください、とか書いておきたいけど、どこがいいかしら。)

assert_equal(raw_data,
             decode(encode(raw_data)))

この例ではパラメーターはaugendaddendの2つでそれぞれに正と負があるので、4パターンでしたが、パラメーター数が増えたりバリエーションが増えると一気にパターンが増えます。そのときはこのデータ表生成機能が便利です。

なお、この機能はRed Chainer(Rubyだけで実装しているディープラーニングフレームワーク)で使うために作りました。もともとRed Chainerのテスト内でデータ表を生成していたのですがこの機能を使うことでだいぶスッキリしました。

データを使い回す

実はRed Chainerのテストをスッキリさせるためにはデータ表を生成するだけでは機能が足りませんでした。同じデータ表を複数のテストで共有する機能が必要でした。

前述の例で言うと、同じデータ表を足し算のテストでも引き算のテストでも使いたいという感じです。コードで言うと、以下をもっといい感じに書きたいということです。

require "test-unit"

class TestCalc < Test::Unit::TestCase
  data(:number1, [1, -1])
  data(:number2, [2, -2])
  def test_add(data)
    number1 = data[:number1]
    number2 = data[:number2]
    assert_equal(number1 + number2,
                 my_add(number1, number2))
  end

  data(:number1, [1, -1])
  data(:number2, [2, -2])
  def test_subtract(data)
    number1 = data[:number1]
    number2 = data[:number2]
    assert_equal(number1 - number2,
                 my_subtract(number1, number2))
  end
end

そこで、dataメソッドにkeep: trueオプションを追加しました。これで一度dataを書けば後続するテストでも同じデータを使うようになります。

require "test-unit"

class TestCalc < Test::Unit::TestCase
  data(:number1, [1, -1], keep: true) # keep: trueを追加
  data(:number2, [2, -2], keep: true) # keep: trueを追加
  def test_add(data)
    number1 = data[:number1]
    number2 = data[:number2]
    assert_equal(number1 + number2,
                 my_add(number1, number2))
  end

  # ここにdataはいらない
  def test_subtract(data)
    number1 = data[:number1]
    number2 = data[:number2]
    assert_equal(number1 - number2,
                 my_subtract(number1, number2))
  end
end

データ表を複数生成する

実はRed Chainerのテストをスッキリさせるためにはデータを使い回せても機能が足りませんでした。1つのテストに対して複数のデータ表を生成する機能が必要でした。

前述の例で言うと、小さい数同士と大きい数同士で別のデータ表を作りたい、ただし、小さい数と大きい数の組み合わせはいらないという感じです。(わかりにくい。)

データ表で言うと次の2つのデータ表を使う感じです。

小さい数用のデータ表:

内容 augend addend
小さい正 + 小さい正 2 1
小さい正 + 小さい負 2 -1
小さい負 + 小さい正 -2 1
小さい負 + 小さい負 -2 -1

大きい数用のデータ表:

内容 augend addend
大きい正 + 大きい正 20000 10000
大きい正 + 大きい負 20000 -10000
大きい負 + 大きい正 -20000 10000
大きい負 + 大きい負 -20000 -10000

コードで言うと、以下をもっといい感じに書きたいということです。

require "test-unit"

class TestAdd < Test::Unit::TestCase
  data(:augend, [1, -1])
  data(:addend, [2, -2])
  def test_add_small(data)
    augend = data[:augend]
    addend = data[:addend]
    assert_equal(augend + addend,
                 my_add(augend, addend))
  end

  data(:augend, [10000, -10000])
  data(:addend, [20000, -20000])
  def test_add_large(data)
    augend = data[:augend]
    addend = data[:addend]
    assert_equal(augend + addend,
                 my_add(augend, addend))
  end
end

そこで、dataメソッドにgroup:オプションを追加しました。同じグループ毎にデータ表を生成します。

require "test-unit"

class TestAdd < Test::Unit::TestCase
  data(:augend, [1, -1], group: :small) # 小さい数用
  data(:addend, [2, -2], group: :small) # 小さい数用
  data(:augend, [10000, -10000], group: :large) # 大きい数用
  data(:addend, [20000, -20000], group: :large) # 大きい数用
  def test_add(data)
    augend = data[:augend]
    addend = data[:addend]
    assert_equal(augend + addend,
                 my_add(augend, addend))
  end
end

setupでもデータを参照可能にする

実はRed Chainerのテストをスッキリさせるためにはデータ表を複数作れても機能が足りませんでした。テスト実行中にデータを参照しやすくする機能が必要でした。

従来のデータ駆動テスト機能ではテストメソッドの引数でデータを渡していました。そのため、setup中でデータを参照できませんでした。

require "test-unit"

class TestAdd < Test::Unit::TestCase
  def setup
    # ここでデータを参照できない
  end

  data(:augend, [1, -1])
  data(:addend, [2, -2])
  def test_add(data)
    augend = data[:augend]
    addend = data[:addend]
    assert_equal(augend + addend,
                 my_add(augend, addend))
  end
end

Red Chainerのテストではデータを前処理したかったので次のように明示的に前処理メソッドを呼んでいました。

require "test-unit"

class TestAdd < Test::Unit::TestCase
  def my_setup(data)
    # 前処理
  end

  data(:augend, [1, -1])
  data(:addend, [2, -2])
  def test_add(data)
    my_setup(data)
    augend = data[:augend]
    addend = data[:addend]
    assert_equal(augend + addend,
                 my_add(augend, addend))
  end
end

これは微妙なのでdataでデータを参照できるようにしました。

require "test-unit"

class TestAdd < Test::Unit::TestCase
  def setup
    p data # データを参照できる!
  end

  data(:augend, [1, -1])
  data(:addend, [2, -2])
  def test_add(data)
    augend = data[:augend]
    addend = data[:addend]
    assert_equal(augend + addend,
                 my_add(augend, addend))
  end
end

また、テストでも引数でデータを受け取らなくてもよくなりました。(従来どおり受け取ってもよいです。)

require "test-unit"

class TestAdd < Test::Unit::TestCase
  def setup
    p data # データを参照できる!
  end

  data(:augend, [1, -1])
  data(:addend, [2, -2])
  def test_add # test_add(data)としなくてもよい!
    augend = data[:augend]
    addend = data[:addend]
    assert_equal(augend + addend,
                 my_add(augend, addend))
  end
end

まとめ

Red Chainerのためにtest-unitにデータ表生成機能を追加しました。Ruby 2.6.0にもこの機能を使えるtest-unitが入っています。ぜひ活用してください。

なお、Ruby 2.6.0でなくてもRubyGemsで新しいtest-unit(3.2.9以降)にアップグレードすれば使えます。Red Chainerでもそうやって使っています。

Red Chainerの開発に参加したい人はRed Data Toolsに参加してください。オンラインのチャット東京で毎月開催している開発の集まり(次回は2018年1月22日)でどうやって進めていくか相談しましょう。