dackdive's blog

新米webエンジニアによる技術ブログ。JavaScript(React), Salesforce, Python など

[Django]URLconfのユニットテストの書き方

メモ。

Django の urls.py で urlpattern に正規表現とか使っていると、正しく書けているかどうか確認したいことがある。
たとえば myproject というプロジェクトに myapp というアプリケーションを作成した場合、
myproject および myapp の urls.py は以下のようになる。

myproject/urls.py
from django.conf.urls import include, url
from django.contrib import admin

urlpatterns = [
    url(r'^myapp/', include('myapp.urls')),
    url(r'^admin/', admin.site.urls),
]
myapp/urls.py
from django.conf.urls import patterns, url
from django.views.generic import TemplateView

from .views import HelloView

urlpatterns = patterns('',
    url(r'^hello/(?P<user_id>\d{4}-\d{4})', HelloView.as_view()),
    url(r'^$', TemplateView.as_view(template_name='myapp/index.html')),
)

特定の URL (/myapp/hello/0000-0000 とか) で期待通りビューが呼ばれているかどうかというユニットテストを書きたい。

基本的なテストの書き方

django.core.urlresolvers モジュールにある resolve() というメソッドを使う。

テストクラスは以下のようになる。(py.test で書いた)

# -*- coding: utf-8 -*-
import pytest

from django.core.urlresolvers import resolve, Resolver404


class TestUrl(object):
    app = '/myapp'

    @pytest.mark.parametrize(('url', 'expected'), [
        ('/', { 'func_name': 'TemplateView', 'kwargs': {}}),
        ('/hello/0000-0000', { 'func_name': 'HelloView', 'kwargs': {'user_id': '0000-0000'}}),
    ])
    def test_valid(self, url, expected):
        func, args, kwargs = resolve(self.app + url)
        assert func.__name__ == expected['func_name']
        for k, v in expected['kwargs'].items():
            assert kwargs[k] == v

    @pytest.mark.parametrize(('url',), [
        ('/hello/',),
        ('/hello/000-000',),
    ])
    def test_invalid(self, url):
        u"""404 になるケース"""
        with pytest.raises(Resolver404):
            func, args, kwargs = resolve(self.app + url)

resolve() は引数としてパスを受け取り、

  1. パスに対応する関数(Class-based View の場合、XXXView.as_view()
  2. 位置指定引数 positional arguments
  3. キーワード引数 keyword arguments

を返す。

URL の正規表現の中に P<XXX> とかを指定していて、正しくパラメータを渡せたかどうかは3つめの戻り値を検証すれば良い。

また、正しいビューが呼ばれたかどうか、ここではビュー名で検証しているが
これについてはもっと良い方法があるかもしれない。


ちょっとだけ改善

ユニットテストの書き方としてはだいたいOKだが、これだと myapp/urls.py のユニットテストが include している大元の myproject/urls.py の設定に依存している ことになる。
(たとえば、URL の /myapp という部分を変更するとテストが失敗する)

そのため、myapp/urls.py のテストを書くときはプロジェクトの urls.py との依存関係を極力取り除きたい。

具体的にどうすれば良いかというと、テストごとに ROOT_URLCONF がアプリケーションのルート URL (ここでは /myapp 以下) を指すように切り替えられると良い。
そのためには以下のようにする。まずは Django の TestCase を使用した場合。

# -*- coding: utf-8 -*-
from django.core.urlresolvers import resolve, Resolver404
from django.test import TestCase, override_settings


class TestUrl(TestCase):

    @override_settings(ROOT_URLCONF='myapp.urls')
    def test_valid(self):
        func, args, kwargs = resolve('/0000-0000')

    @override_settings(ROOT_URLCONF='myapp.urls')
    def test_invalid(self):
        with self.assertRaises(Resolver404):
            func, args, kwargs = resolve('/000-000/')

テストメソッドに @override_settings アノテーションをつけて ROOT_URLCONF を置き換えることができる。

実は、クラスに urls = 'myapp.urls' という変数を持たせると同じように ROOT_CONF を置き換えることができるんだけど
Django1.8 から deprecated みたいなので @override_settings を使うようにする。
参考:https://docs.djangoproject.com/en/1.9/topics/testing/tools/#django.test.SimpleTestCase.urls


続いて、py.test を使用した場合。
ちょっと試してみた感じだと @override_settings@pytest.mark.parametrize は併用できないっぽいので次のようにする。

# -*- coding: utf-8 -*-
import pytest
from django.conf import settings
from django.core.urlresolvers import resolve, clear_url_caches, Resolver404


class TestUrl(object):
    urls = 'myapp.urls'

    @pytest.mark.parametrize(('url', 'expected'), [
        ('/', { 'func_name': 'TemplateView', 'kwargs': {}}),
        ('/hello/0000-0000', { 'func_name': 'HelloView', 'kwargs': {'user_id': '0000-0000'}}),
    ])
    def test_valid(self, url, expected):
        func, args, kwargs = resolve(url)
        assert func.__name__ == expected['func_name']
        for k, v in expected['kwargs'].items():
            assert kwargs[k] == v

    @pytest.mark.parametrize(('url',), [
        ('/hello/',),
        ('/hello/000-000',),
    ])
    def test_invalid(self, url):
        u"""404 になるケース"""
        with pytest.raises(Resolver404):
            func, args, kwargs = resolve(url)


    # ref. django.test.testcase
    @classmethod
    def setup_class(self):
        if hasattr(self, 'urls'):
            self._old_root_urlconf = settings.ROOT_URLCONF
            settings.ROOT_URLCONF = self.urls
            clear_url_caches()

    @classmethod
    def teardown_class(self):
        if hasattr(self, '_old_root_urlconf'):
            settings.ROOT_URLCONF = self._old_root_urlconf
            clear_url_caches()

テストクラスに urls という変数があった場合は ROOT_URLCONF に置き換える、という処理を行っている。
setup_classteardown_class はクラス単位でテストの実行前後に処理を挟むための py.test のメソッドである。
参考:https://pytest.org/latest/xunit_setup.html#class-level-setup-teardown

この処理は(先ほど deprecated であると言った)Djangourls 変数による ROOT_URLCONF の置き換え処理を参考にした。

このあたり。
https://github.com/django/django/blob/1.9.1/django/test/testcases.py#L233-L243