djangoでのテストデータの生成方法について考えてみた

djangoでのテストデータの生成方法について考えてみた。

集計や検索などのテストが辛い

今までdjangoでテストデータを作ってきてfactory boyを使ったテストを前提にするというのがあったのだけれど。 これは、必要となるモデルの数が少ないものでは上手くいっていたもののそうでないものについては上手くいっていなかった。 factory boyのFactoryでデータを生成するということは「人間があたたかみのある手書きのコードを書く」ということに他ならずデータ生成用の処理を書くというのはひどく刺し身たんぽぽを連想させるような単純作業になる。(もちろんどのようなデータセットを作るかというような部分で考えることはあるが、考え終わった後のその実装の話)。

例えば上手くいかない例として集計処理が上げられる。これは実装の確認のために集計対象のデータセットが必要となる。 集計というからには当然作成されるのはサマリーデータの訳で、サマリーを作るための元となるデータがある程度大量に必要となる。

既存のDBからテスト用のデータセットをfixtureとして取り出すことはできない

fixture(json,xml)の管理はしたくない

ただ、なるべくならfixtureの管理はしたくない。 あるフィールドの追加や削除についてO(N)の作業が必要になるので。 一方共通部分がfactory boyのFactoryで括りだされていた場合はO(1)の作業となる。 だからなるべくならfactory経由でデータを生成したい。

fixtureからあたたかみのあるコードの自動生成

ここ何日かで既存のfixtureデータから、factory boy経由でデータを生成するコードを生成するコードを出力するためのライブラリを作っていて、それが少しずつ形になってきた。実際に動くようになると、楽しい物であれこれ動かし方を変えながら、遊んでみたりしていた。

巨大なfixtureデータとの向き合いかた

ただ、自動生成と言っても上手くはいかなくて、元となるfixtureデータが巨大な場合に、そのまま利用しようとした場合には、結局、元々fixtureを使っていた場合と同様のスローテストになるというようなことがある。これについての対応方法として2種類位は簡単に思いつきそうではある。

  • 生成するfixture(xml,jsonなど)の対象を絞る
  • テストデータの利用上手く行う方法を考える

生成するfixture(xml,jsonなど)の対象を絞る

前者については例えば以下のようなことが考えられる。

with dump_data("foo.xml") as r:
    obs = Model.objects.filter(id__in([1,2,3])).all()
    for ob in obs:
        r.register(ob)

dump_dataのコンテキストのなかで、欲しいオブジェクトを取得し、登録しておく。with-contextの __exit__部分で以下のような処理を行う

  1. 登録したオブジェクトが依存しているオブジェクト再帰的に取り出して登録
  2. 登録したデータをfixtureとして吐き出す

このようにすると、現在DBのなかにあるデータセットの内のある特定の系だけを取り出す事ができる。これはこれでありのような気がする。

テストデータの利用上手く行う方法を考える

後者について考えてみたのが今回の本題。以下のような構造でデータを扱えば幾分かは利用するデータが少なくて済むのではないかと思う。 利用するデータが少ないということは、モデルの生成が少ないというわけで、おそらくdb負荷も軽くなるのではなかと思う。

class TestDataContext(object):
    @as_lazy_array
    def x(self):
        yield XFactory(id=1)
        yield XFactory(id=2)
        yield XFactory(id=3)

    @as_lazy_array
    def y(self):
        yield YFactory(id=1, x=self.x[0])
        yield YFactory(id=2, x=self.x[0])
        yield YFactory(id=3, x=self.x[1])

    @as_lazy_array
    def z(self):
        yield ZFactorz(id=1, y=self.y[0])
        yield ZFactorz(id=2, y=self.y[0])
        yield ZFactorz(id=3, y=self.y[1])
        yield ZFactorz(id=4, y=self.y[1])

データセットを生成するための補助オブジェクトとしてTestDataContextというようなオブジェクトを作る。このオブジェクトは必要となるモデル毎にモデル名と同様の属性(のようなもの)を持っているとする。この属性(のようなもの)の実体は遅延リスト(遅延配列)のようなもので、以下のような条件を満たす。

  • iterableオブジェクトを渡して利用
  • xs[i]とアクセスした時、iterateするのはi個目まで
  • i >= j かつxs[i] にアクセスした後はxs[j]へのアクセスはO(1)

このような遅延リストのようなものを返す属性を付加しておくと、テスト中では以下の様にして使える。

import unittest

class Tests(unittest.TestCase):
    def test_it(self):
        """xとyに依存したテスト"""
        self.context = TestDataContext()
        x = self.context.x[0]
        y = self.context.y[0]
        # do something

    def test_it2(self):
        """zの要素が2つ欲しい場合のテスト"""
        self.context = TestDataContext()
        zs = self.context.zs[:2]
        # do somthing

このようにすると、必要な部分までモデルは生成されるものの以降のモデルは生成されることが無い。 また、これにモデルを生成せずにfilter条件を掛けるというようなことができれば検索などのテストはし易いのではないかと思った。

もちろん、このような TestDataContext オブジェクトをどのようにして作るかという話は、fixtureから自動生成するということになる*1

*1:モデル定義の変更とそれへの追随の問題は残る。例えば、あるタイミングでfactory経由で生成したデータをまたfixtureに戻すというようなことをしないといけない。