c-bata web

日本語の技術ブログ by @c-bata . 英語の記事はMediumに書いています。

Djangoライブラリのテストを setup.py testで実行するためのTestCommand

Djangoのアプリケーションをライブラリとして公開する際の、ファイル構成はいろいろな選択肢があります。 通常のプロジェクトの始め方と同じように django-admin startproject でプロジェクトを作成しその中に作成したアプリケーションを公開する場合や、プロジェクトは用意せずに django-admin startapp your-awesome-library でアプリケーションだけを作成する場合などがあります。

基本的には前者のやり方が悩みどころが少なく簡単ですが、パッケージ構成は後者のほうがスッキリするかもしれません。このあたりは正解がなくOSSのプロジェクトによっても様々なので、好きな方を選ぶ必要があるかと思います。最近 django-httpbench というライブラリを公開したのですが、そこでは後者を選びました。

ただテストの実行方法に関して悩みどころがあります。 DJANGO_SETTINGS_MODULE=test_settings python3 -m django test のように実行していましたが、Pythonパッケージとしては python3 setup.py test で統一的にテストを実行できるほうが嬉しいでしょう。jazzbandにあるリポジトリを眺めているとその多くが setup.py test には対応していませんでいたが、対応しているものの中にはテストファイル内で django.setup などを呼び出しているものがありました。あまりそういうやり方も取りたくないので、Pytest のやり方を参考にDjango用のクラスを用意しました。

settings.py は --settings オプションで manage.py コマンドと同じように指定できるようにしました。 test_suites に指定されたパッケージを TestRunnerのtest_labelsに渡して実行しています。

import os
import sys

from setuptools import setup, find_packages
from setuptools.command.test import test as TestCommand

class DjangoTestCommand(TestCommand):
    user_options = TestCommand.user_options + [
        ('settings=', None, "The Python path to a settings module"),
    ]

    def initialize_options(self):
        TestCommand.initialize_options(self)
        self.settings = ''

    def run_tests(self):
        # import here, cause outside the eggs aren't loaded
        import django
        from django.conf import settings
        from django.test.utils import get_runner

        if self.settings:
            os.environ['DJANGO_SETTINGS_MODULE'] = self.settings
        django.setup()
        TestRunner = get_runner(settings, test_runner_class=self.test_runner)
        test_runner = TestRunner()
        test_labels = [self.test_suite]
        failures = test_runner.run_tests(test_labels)
        if failures:
            sys.exit(1)


setup(
    name='djangohttpbench',
    ...,
    install_requires=[
        'Django',
        'requests',
    ],
    test_suite='httpbench.tests',
    tests_require=[],
    cmdclass={'test': DjangoTestCommand}
)

全体: django-httpbench/setup.py at master · c-bata/django-httpbench · GitHub

実行する際はこんな感じです。

$ python setup.py test --settings=test_settings
...
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....
----------------------------------------------------------------------
Ran 4 tests in 0.002s

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

または DJANGO_SETTINGS_MODULE=test_settings python setup.py test ともできます。 また setup.py test の --test-runner オプションとかはそのまま付けることができるので次のように明示的に指定することも可能です。

$ python setup.py test --settings=test_settings --test-runner=django.test.runner.DiscoverRunner
...
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....
----------------------------------------------------------------------
Ran 4 tests in 0.002s

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

メモ: djangojaでのやりとり

hirokiky [9:00 AM]
テスト用のSettings指定するかなぁ

tokibito [12:09 PM]
pytest-djangoでテスト用プロジェクトのsettings指定に落ち着いたよ
テスト実行はCIと、手元ではtox使っているから、setup.pyにこだわらなくなった
https://github.com/tokibito/django-ftpserver
GitHub
tokibito/django-ftpserver
FTP server application that used user authentication of Django. - tokibito/django-ftpserver
こういう感じ

tokibito [1:13 PM]
Django向けのライブラリを作るとき、アプリやプロジェクトがあることを前提とすること多いし、独自の仕組にしてDjangoバージョン上がったときに動かなくなること結構あった

c-bata [1:51 PM]
kyさんもsetup.pyにはこだわらずに実行してる感じですかね
> テスト実行はCIと、手元ではtox使っているから、setup.pyにこだわらなくなった

なるほど

hirokiky [1:59 PM]
ですねー。toxで書いちゃう。
でも手元で1環境でテスト実行したいときに、 DJANGO_SETTINGS… って書いてだりーなぁって思う
--pdb つけたいときとか。

c-bata [2:10 PM]
なるほどー
setup.py test対応するのって統一的なインターフェイスがあるといいよねって話かと思ってますが、たしかにtoxとか使ってるプロジェクトなら `tox` とだけ打てばテスト実行できますしね

hirokiky [2:15 PM]
そうねぇ。あんまりsetup.py test使わないのも何か悲しいけどね。

tokibito [2:38 PM]
testコマンドでtox叩こうぜ

toxでやるならこんな感じでもいいかもですね

import os
import subprocess
import sys

from setuptools import setup, find_packages
from setuptools.command.test import test as TestCommand

class ToxTestCommand(TestCommand):
    user_options = [
        ('env=', 'e', "work against specified environments (ALL selects all)"),
    ]

    def initialize_options(self):
        TestCommand.initialize_options(self)
        self.env = None

    def run_tests(self):
        popenargs = ['tox']
        if self.env:
            popenargs += ['-e', self.env]
        sys.exit(subprocess.call(popenargs))


setup(
    ...
    test_suite='',
    tests_require=['tox'],
    cmdclass={'test': ToxTestCommand}
)

tox使う以上 test_suite は使われないのですが、とりあえず動きます

$ python setup.py test -e py37
....
----------------------------------------------------------------------
Ran 4 tests in 0.004s

OK
Destroying test database for alias 'default'...
___________________________________________________________________________________________ summary ____________________________________________________________________________________________
  py37: commands succeeded
  congratulations :)