Djangoでテストを実行するには、以下のコマンドを実行するだけです。

$ python manage.py test

これでmanage.pyの下にあるtest*.pyのファイルに入っているTestCaseを継承したクラスがテストに追加され、実行されます。

$ python manage.py test
Creating test database for alias 'default'...
Creating test database for alias 'shard0'...
Creating test database for alias 'shard1'...
......................................................................................
----------------------------------------------------------------------
Ran 86 tests in 51.495s

OK
Destroying test database for alias 'default'...
Destroying test database for alias 'shard0'...
Destroying test database for alias 'shard1'...

詳しくはDjangoのドキュメントを参照して下さい。

自動的にテストが走るようにする

Djangoのテストは便利なのですが、開発をしていると、毎回テストを手で実行するのが面倒になってきます。 なのでファイルが書き換わったら自動的にテストが走るようにしたいところです。

こんなのきっと誰か開発してるだろうとPyPIを探したところ、tdaemonというパッケージが見つかりました。

これは以下のように書いておくと、そのフォルダ以下にあるどれかのファイルの変更があった際に自動的にテストを実行してくれます。

$ tdaemon -t django

これを別ウインドウでずっと実行したままにしておくことで、とりあえず自動的にテストが走るようになりました。

しかしこのままだと、Vimでファイルを開くと、無駄にテストが走るという状況になります。 これはVimが生成する.swpファイルをtdaemonが検知して、そのせいでテストが走るためです。

コードを見てみると、IGNORE_EXTENSIONSに拡張子を追加すれば.swpファイルの検知を無視してくれそうです。

ということで以下のようなコードを書きました。

import os
import sys

import tdaemon


def main():
    tdaemon.IGNORE_EXTENSIONS += ('swp',)
    tdaemon.main([sys.argv[0], '-t', 'django'])

if __name__ == '__main__':
    main()

これで無駄にテストが走ることが無くなりました。

しかしまだ問題があります。 tdaemonはなぜか、標準エラーは即座に出力するのに、標準出力は全てのテストが終わってから一気に出力します。 これはコードを見れば実際そのように書かれていて、大変残念な感じです(gitにある、リリースされていない最新バージョンなら直っている)。

かなりイケてないので、これも適当にモンキーパッチを当てて直します。


def run_tests(self):
    print(datetime.datetime.now())
    result = subprocess.call(self.cmd, shell=True)
    print('SUCCESS' if result == 0 else 'FAILED')

def main():
    tdaemon.Watcher.run_tests = run_tests
    ...

これでようやく見やすい出力になりました。

Djangoのテストを高速化する

最初のテスト結果を見れば分かりますが、1回のテストに51秒掛かっています。

ファイルを保存して、テストが通るかどうかを確認するために51秒も待つのは、さすがに辛いものがあります。

開発者は通常、開発しようとしている機能のテストを通そうとしているはずです。 そのため、通常ははその機能のテストだけをやってくれていれば良くて、それ以外の機能のテストは、開発しようとしている機能のテストが終わって、最後にやってくれればいいのです。

しかし、手動で開発中の機能を指定するのは面倒なので、「前回失敗したテストを優先的にテストし、次回もそのテストが失敗したらその時点で中断する」という形にしました。 つまり失敗するようなテストは開発中の機能に違いないので、それらのテストが通るまでは、ひたすらそのテストを回し続けよう、という方針です。

テストの動きを変えるのは結構手間かなと思っていたのですが、Djangoのテストランナーを見ながら拡張すれば思ったより簡単にできました。以下のようになります。

class FastRunner(object):
    def __init__(self, **kwargs):
        self.kwargs = kwargs

    def load_failtests(self):
        try:
            with open('.failtests') as f:
                return set(f.read().split(','))
        except Exception:
            return None

    def save_failtests(self, result):
        lines = []
        for testcase, traceback in result.errors:
            lines.append(testcase.id())
        for testcase, traceback in result.failures:
            lines.append(testcase.id())
        with open('.failtests', 'w') as f:
            f.write(','.join(lines))

    def teardown_with_keepdb(self, verbosity, old_config):
        old_names, mirrors = old_config
        for connection, old_name, destroy in old_names:
            if destroy:
                connection.creation.destroy_test_db(old_name, verbosity, True)

    def run_tests(self, test_labels, extra_tests=None, **kwargs):
        from django.test.runner import DiscoverRunner
        from django.test.runner import setup_databases

        # 全体テストじゃなければいつも通りの処理を行なう
        if test_labels:
            dr = DiscoverRunner(**self.kwargs)
            return dr.run_tests(test_labels, extra_tests, **kwargs)

        # 直前のテスト結果が成功でもいつも通りの処理を行なう
        failtests = self.load_failtests()
        if failtests is None:
            dr = DiscoverRunner(**self.kwargs)
            dr.setup_test_environment()
            suite = dr.build_suite(test_labels, extra_tests)
            old_config = dr.setup_databases()
            result = dr.run_suite(suite)
            if result.wasSuccessful():
                # テストが成功だったら普通に後処理
                dr.teardown_databases(old_config)
                dr.teardown_test_environment()
            else:
                # テストが失敗だったら失敗したテストを保存してDBをキープしておく
                self.save_failtests(result)
                self.teardown_with_keepdb(dr.verbosity, old_config)
                dr.teardown_test_environment()
            return dr.suite_result(suite, result)

        else:
            # 直前のテスト結果が失敗なら、キープしておいたDBを使って失敗したテストをもう一度実行
            #   - そこで失敗したら失敗したテストを保存してDBをキープして終了
            #   - 成功したら残りのテストを実行する
            #     - そこで失敗したら同様に失敗したテストを保存してDBをキープして終了
            #     - 成功したらDBを解体処理して終了
            dr = DiscoverRunner(**self.kwargs)
            dr.setup_test_environment()
            first_suite = dr.test_suite()
            second_suite = dr.test_suite()
            suite = dr.build_suite(test_labels, extra_tests)
            for testcase in suite:
                if testcase.id() in failtests:
                    first_suite.addTest(testcase)
                else:
                    second_suite.addTest(testcase)

            old_config = setup_databases(dr.verbosity, dr.interactive, True, dr.debug_sql)

            result = dr.run_suite(first_suite)
            if not result.wasSuccessful():
                self.save_failtests(result)
                self.teardown_with_keepdb(dr.verbosity, old_config)
                dr.teardown_test_environment()
                return dr.suite_result(first_suite, result)

            result = dr.run_suite(second_suite)
            if not result.wasSuccessful():
                self.save_failtests(result)
                self.teardown_with_keepdb(dr.verbosity, old_config)
                dr.teardown_test_environment()
                return dr.suite_result(second_suite, result)

            os.remove('.failtests')
            dr.teardown_databases(old_config)
            dr.teardown_test_environment()
            return dr.suite_result(second_suite, result)

あとはこのランナーをテストで使うDjangoの設定ファイルのTEST_RUNNERに設定すればOKです。

テストに失敗している間はDBを作り直したりもしないので、かなり高速(1秒未満)にテストできます。 ただし失敗している間にDBの構造が変わった場合には、.failtestsファイルを削除して再度全体テストを回す必要があるので注意しましょう。

あと嬉しい誤算として、テストに失敗しているとgit status時に.failtestsファイルが見えるため、このファイルがある間はコミットしてはいけないなというのがすぐに分かります。gitのコミットメッセージを書く時にはuntracked filesが見えるので、(ゴミファイルを常に置くようなことをしていなければ)結構気が付きます。

これでかなり快適にDjangoアプリケーションのテストができるようになりました。 テストを書くのはほんと面倒です。しかし変更の度にクライアントを使って手でやるよりははるかにマシだし、バグ発生数もかなり減るので、Djangoアプリケーションを書く場合には書ける部分は頑張ってテストを書きましょう。