Python - テストデータの定義はどうするのがいいか考えてみる

またはPythonでDataProviderを使う方法

きっかけ

テストデータはsetUpで宣言するべき幾つかの理由を読み、これはDataProviderの方が適切ではないかという気がしました。

DataProvider(データプロバイダー)とは

簡単に言えば、「テストデータの集合を返す関数(orメソッド)」のことです。
元記事においては 代用: テストデータを返してくれる関数を宣言する にあたります。

PHPUnit(PHP)やTestNG(Java)には標準で付いている機構です。
(ググったらそんなに情報なかったのだけど、あまり有名/有用な機能じゃないのかな?)

参考記事を見て分かる通り、DataProviderはアノテーションを介してテストメソッドと紐付けられます。
これにより、テストデータを扱いつつテスト本体は簡潔さを保つことができます。
かつ、setUp版で問題視されていた「関係ないテストでも毎回作っちゃう」ということもありません。

PythonでDataProvider(準備編)

Pythonにはデコレータというイカした機能がありますので、これを使えば簡単に出来そうな気がします。
という思いでググったところ、あるpackageが見つかりました。

unittest-data-provider 1.0.0

これを使えば実現しそうな気がする名前です。

installして試そうとしましたが、私の環境ではDistributionNotFoundが出てしまい断念です。 (Python勉強したてでpipとかまだよく分かっていないので深く調査せず)

幸いにもサイトにはコードが載っていますので、それを拝借して実際に動くか試してみます。

PythonでDataProvider(お試し編)

ここではunittest.pyを使いますが、デコレータですしたぶん他のテストでも大丈夫でしょう。

次の単純な足し算メソッドと引き算メソッドをテストします。
テストデータの作り方をテストメソッド内定義、ヘルパーメソッド、setUp、データプロバイダーで分けてみます。

def plus(a, b):
    return a + b


def minus(a, b):
    return a - b

テストメソッド内で定義

import unittest


class TestDataInTestMethod(unittest.TestCase):
    def test_plus(self):
        testdata_integers = [(1, 2), (-1, 2), (0, 0)]
        for a, b in testdata_integers:
            self.assertEqual(plus(a, b), a + b)

    def test_minus(self):
        testdata_integers = [(1, 2), (-1, 2), (0, 0)]
        for a, b in testdata_integers:
            self.assertEqual(minus(a, b), a - b)
  • メリット
    • テストメソッド内で完結しているので、この程度の量であれば見通しはいい
    • 必要なテストケースでのみ作成される
  • デメリット
    • DRYじゃない
    • テストデータ生成の処理が複雑になると可読性が下がり、テストの本質が分かりにくくなる

ヘルパーメソッド

import unittest


class TestDataHelperMethod(unittest.TestCase):
    def test_plus(self):
        for a, b in self.__testdata_integers():
            self.assertEqual(plus(a, b), a + b)

    def test_minus(self):
        for a, b in self.__testdata_integers():
            self.assertEqual(minus(a, b), a - b)

    def __testdata_integers(self):
        return [(1, 2), (-1, 2), (0, 0)]

setUp

import unittest


class TestDataInSetUp(unittest.TestCase):
    def setUp(self):
        self.__testdata_integers = [(1, 2), (-1, 2), (0, 0)]

    def test_plus(self):
        for a, b in self.__testdata_integers:
            self.assertEqual(plus(a, b), a + b)

    def test_minus(self):
        for a, b in self.__testdata_integers:
            self.assertEqual(minus(a, b), a - b)

まず、元記事にある次の点に関して私はあまり賛成できませんでした。

どのようなテストデータが使われているかが一目瞭然になる

私の解釈が間違っている可能性もありますが、setUpに寄せた場合むしろこの点は可読性が落ちるのではないでしょうか。
テストクラスの単位に寄りますが普通に1クラス1テストクラスで作るとして、
setUpにテストデータをまとめたらごちゃごちゃしちゃう気がしました。
元記事やこの記事のサンプルはテストデータの量が少ないので全然問題にはなりませんが。

もう一つは、setUp関数が膨れることに対してどうアプローチを取るかという問題です。
1行で定義が済むようなテストデータの連続ならばそんなに気にならないかもしれませんが、 そもそも準備に数処理必要なテストデータを複数定義すると、setUpメソッド内が膨れます。

すると可読性を上げるためにその中からまた処理を分けてメソッド化すればいいってなるかもしれませんが、 それなら最初からテストデータ関数でいいのではという気がします。

最後に、テストクラスを跨いでのテストデータ共有に関して。
テストメソッド間で共有できるテストデータというのは、汎用的なデータの場合が多いことでしょう。
たとえば整数テストデータとして[負数, 0, 正数]のような。
汎用的なデータの場合、複数のテストクラスで使われる可能性があります。
その際にsetUp方式だと、結局は「コピペで済まされる」事態が発生します。

TestCaseを継承したクラスをかませばいいという意見があるかもしれません。
それもアリかもしれませんが、テストデータに関して言えばやはり上記問題点1つ目あたりが気になります。

元記事でも仰られていますが、

当然のことながら、データがそのケースの近くにあったほうがいい場合もあります。というのも、そのほうが参照が早い。setUpによせると、そこまで遡らなければいけなくなったりもします。

ということで、テストメソッドから遠ざかるほど可読性が落ちる危険性があります。

DataProvider

上述のunittest-data-providerをinstall出来ればしておきます。
下記コードではinstall出来ない場合のために、デコレータを自前定義しています。

import unittest


# see https://pypi.python.org/pypi/unittest-data-provider/1.0.0
def data_provider(fn_data_provider):
    def test_decorator(fn):
        def repl(self, *args):
            for i in fn_data_provider():
                try:
                    fn(self, *i)
                except AssertionError:
                    print "Assertion error caught with data set ", i
                    raise
        return repl
    return test_decorator


class TestDataByDecorator(unittest.TestCase):
    __testdata_integers = lambda: [(1, 2), (-1, 2), (0, 0)]

    @data_provider(__testdata_integers)
    def test_plus(self, a, b):
        self.assertEqual(plus(a, b), a + b)

    @data_provider(__testdata_integers)
    def test_minus(self, a, b):
        self.assertEqual(minus(a, b), a - b)
  • メリット
    • DRY
    • 必要なテストケースでのみ作成される
  • デメリット
    • ヘルパーメソッドと同様

そもそもテストデータを共通化することの難しさ

私は業務でPHPUnit使っていくつものユニットテストを書いたことがあります。
PHPUnitは標準でDataProviderがありますので、最初はそれを頻繁に使用していました。

しかしテストを作成/メンテナンスしていくうちに、DataProviderは使いどころが難しいと感じるようになりました。

というのも、基本的に入力(データ)と出力(実行結果)はひもづいているからです。
「A」というデータを与えれば「X」が返ること。そしてそれは出来るだけ固定値の方がよいと思っています。

上記サンプルコードではassertの対象としてわざと「a + b」とかで書きましたが、
本当はそれは微妙じゃないかなと思っています。
本来はassertEqual(plus(a, b), 3) など極力固定値の方がいいと考えています。

これに対応するために、DataProviderが渡すデータとして[引数、期待値]のようなペアを使うこともありました。
上記サンプルで言えば、例えばこのようになります。

class TestDataByDecorator2(unittest.TestCase):
    __testdata_integers = lambda: [[1, 2, 3], [-1, 2, 1], [0, 0, 0]]

    @data_provider(__testdata_integers)
    def test_plus(self, a, b, expected):
        self.assertEqual(plus(a, b), expected)

この対応の大きな問題点は、結局テストメソッドごとにデータを用意するのと変わらない、ということです。
たとえば上記はそのままtest_minusに適用すると、当たり前ですがテストが失敗します。

まとめ

以上を踏まえて、私個人の考えは以下のとおりです。

  • DRYにしたくて、かつ純粋に「テストデータ」だけの切り出しだけで構わない

=> DataProviderもしくはヘルパーメソッド

例えば「特定範囲のデータに関しては必ず決まった値を返す」関数のテストデータなど

  • テストデータ以外で共通処理を行わせたい

=> setUp()

  • それ以外

=> テストメソッド内で定義

setUpのデメリットでもいろいろ述べましたが、私はテストコードに関しては必ずしもDRYじゃなくてもいいと思っています。
(DRYにしてはいけないではなく DRYに固執しなくてもいいという意味です)
プログラマ5,6年と浅いですが、経験上無理にDRYにすると逆に可読性やメンテナンス性を下げることがありましたので...。
もちろん私のテストの書き方がまだまだ下手な可能性も十分にあります。
(念のため補足しますが、実装コードは絶対DRYがいいです)

あれこれと書きましたが必ずしも上記の通りではなく、結局はバランスかと思います。(便利な逃げ言葉)

この記事のサンプルコードはGistにあがっています。