Djangoアプリについて、pytest-djangoを使ってテストしてみた

Djangoのテストについて考えていたところ、以下の記事に出会いました。

後者の記事にもある通り、pytestではテストの失敗内容を細かく出せそうでした。

そのため、Django向けのpytestライブラリpytest-djangoを使ってテストコードを書いてみました。

目次

 

環境

 

試したけど分からなかったこと

Djangoのテストを書く上で、一般的にはどちらを使うのかが分からなかったものを残しておきます。

もし指針などがあれば教えていただけるとありがたいです。

  • Viewのテストでリクエストを送信する際、django.test.ClientRequestFactoryのどちらを使うのか

    • 今回は、ViewのテストはRequestFactory、それ以外のテストはミドルウェアも関係するのでClientを使ってみた
  • テスト中のURLは、ハードコーディングするのか、django.core.urlresolvers.reverse()を使うのか

    • 今回は、URL解決のテストはハードコーディング、それ以外のテストはreverse()を使ってみた

 

試してないこと

今回はさらっと触れただけなので、以下のテストコードは書きませんでした。

  • モックなどのテストダブルを使ったテスト
  • Seleniumを使ったテスト

 

テスト対象のアプリ

以下のような流れのテスト対象アプリを作成しました。

  1. mysite/createへアクセスすると、CreateView + ModelFormを使った登録フォームを表示
  2. 登録フォームに入力しPOST
  3. POSTデータをDBへ保存
  4. mysite/item/<レコードのid>へリダイレクトし、DetailViewを使った詳細画面を表示

 

セットアップとアプリ作成

virtualenv環境作成からアプリ作成までを行います。

d:\Sandbox\Django_pytest_sample>virtualenv -p c:\python35-32\python.exe env
d:\Sandbox\Django_pytest_sample>env\Scripts\activate
(env) d:\Sandbox\Django_pytest_sample>pip install django pytest-django
(env) d:\Sandbox\Django_pytest_sample>django-admin startproject myproject .

# 以下を参考に、アプリのディレクトリを先に作成しておく
# http://stackoverflow.com/questions/33243661/startapp-with-manage-py-to-create-app-in-another-directory
(env) d:\Sandbox\Django_pytest_sample>mkdir apps\myapp

(env) d:\Sandbox\Django_pytest_sample>python manage.py startapp myapp apps/myapp

# アプリ作成 (省略)

# マイグレーション
(env) d:\Sandbox\Django_pytest_sample>python manage.py makemigrations
(env) d:\Sandbox\Django_pytest_sample>python manage.py migrate

# テストコード用のディレクトリ作成
(env) d:\Sandbox\Django_pytest_sample>mkdir apps\myapp\tests

この時点のアプリのコードは、以下の通りです。
thinkAmi-sandbox/Django_pytest_sample at b989361d37e24a069167386611be3ceb6165c757

 

pytest.iniの作成

pytest-djangoを使ってテストするために、設定ファイルpytest.iniを作成し、DJANGO_SETTINGS_MODULEを設定します。
Getting started with pytest and pytest-django — pytest-django documentation

 
なお、今回プロジェクトルート下にvirtualenv環境を作ったため、このままではインストールしたライブラリもテスト対象となってしまいます。

(env) d:\Sandbox\Django_pytest_sample>py.test
...
collected 31 items
env\Lib\site-packages\django\contrib\admindocs\tests\test_fields.py ...

 
そのため、norecursedirsを設定し、envディレクトリ以下のライブラリはテスト対象外とします。
Basic test configuration - norecursedirs

 
最終的なpytest.iniは以下の通りとなります。

[pytest]
DJANGO_SETTINGS_MODULE=myproject.settings
norecursedirs=env

 
これでテストの準備ができたため、テストコードを書いていきます。

 

Modelのテスト

models.pyに対するテストコードとして、test_models.pyを作成します。

Itemモデルの各フィールドには

  • name: 3文字以上255文字以下であること
  • unit_price: 0以外の10桁までの数字であること

という制限を持たせているため、バリデーションエラーとなった場合、ValidationError例外を送出します

そこで、これらのvalidatorの動作をModel.full_clean()を使ってテストします。

 
バリデーションエラーとなるテストケースについては、

def test_nameが長すぎる場合_エラー(self):
    with pytest.raises(ValidationError):
        model = Item(name='1'*256, unit_price=100)
        model.full_clean()

のようにして例外が送出されるかをテストします。
Assertions about expected exceptions - The writing and reporting of assertions in tests

 
また、例外に含まれるメッセージを確認する場合は

def test_unit_priceがゼロの場合_エラー(self):
    with pytest.raises(ValidationError) as excinfo:
        model = Item(name='abc', unit_price=0)
        model.full_clean()
    assert 'unit_priceにはゼロを設定できません' in excinfo.value.messages

のように、ValidationError.value.messagesの値を使います。
django/exceptions.py - GitHub

 
一方、例外が発生しないことを確認するため、

def test_unit_priceが正常な場合_エラーとならない(self):
    model = Item(name='abc', unit_price=10**9)
    try:
        model.full_clean()
    except:
        pytest.fail()

と、例外が発生した場合にはpytest.fail()を使ってテストが失敗するようにします。
Pytest API and builtin fixtures

 
他に、pytest.mark.parametrizeデコレータを使ったパラメタライズドテストも試してみたかったため、正常系のテストケースで

@pytest.mark.parametrize(['input',],[('a'*3), ('a'*255),])
# parametrizeを使う場合、selfは引数として受け取らない模様
def test_nameが正常な場合_エラーとならない(input):
    model = Item(name=input, unit_price=100)
    try:
        model.full_clean()
    except:
        pytest.fail()

と書いてみました。
@pytest.mark.parametrize: parametrizing test functions - Parametrizing fixtures and test functions

 

URL解決のテスト

urls.pyに対するテストコードとして、test_urls.pyを作成します。

テストでは

を確認します。

def test_存在しないURLの場_エラー(self):
    with pytest.raises(Resolver404):
        resolve('/mysite/not-exist')
    
def test_商品登録のURLの場合_URL解決される(self):
    found = resolve('/mysite/create')
    assert found.func.__name__ == ItemCreateView.__name__
    
def test_商品詳細のURLの場合_URL解決される(self):
    # DBにデータがないので404テンプレートが使われるけど、resolveされている
    found = resolve('/mysite/item/1/')
    assert found.func.__name__ == ItemDetailView.__name__

 

Viewのテスト

views.pyに対するテストコードとして、test_views.pyを作成します。

テストでは

を確認します。

 
なお、Viewへのリクエスト方法として、

の2種類があるため、それぞれを使って書いてみました。

 

Clientを使う場合
def test_登録画面Viewが使われている_Client版(self):
    response = self.client.get(reverse('my:item-creation'))
    
    self.assertTemplateUsed(response, 'myapp/item_form.html')
    assert response.status_code == 200


def test_詳細画面Viewが使われている_Client版(self):
    # データを登録しないと表示されないので、事前に登録しておく
    Item(name='abc', unit_price=100).save()
    response = self.client.get(reverse('my:item-detail', args=(1,)))
    
    self.assertTemplateUsed(response, 'myapp/item_detail.html')
    assert response.status_code == 200

 

RequestFactoryを使う場合
def test_登録画面Viewが使われている_RequestFactory版(self):
    request = RequestFactory().get(reverse('my:item-creation'))
    response = ItemCreateView.as_view()(request)
    
    # テンプレート名はlistで入ってるのでinを使って確認する
    # https://docs.djangoproject.com/ja/1.9/ref/template-response/#django.template.response.SimpleTemplateResponse
    assert 'myapp/item_form.html' in response.template_name
    assert response.status_code == 200 


def test_詳細画面Viewが使われている_RequestFactory版(self):
    Item(name='abc', unit_price=100).save()
    request = RequestFactory().get(reverse('my:item-detail', args=(1,)))
    response = ItemCreateView.as_view()(request)
    
    assert 'myapp/item_form.html' in response.template_name
    assert response.status_code == 200

 

 

Formのテスト

今回はModelFormを使っているため、model_forms.pyに対するテストコードとして、test_model_forms.pyを作成します。

通常、ModelFormはModelと紐付いている部分のテストは不要です。
unit testing - How to test approriately ModelForms and views in Django - Stack Overflow

ただ今回、ModelFormだけの項目form_onlyとそれに対するvalidatorを実装しているため、ModelForm.is_valid()を使ったテストを書きます。

def test_form_onlyに値がない場合_エラー(self):
    form = ItemModelForm({'form_only': '',
                          'name': 'test',
                          'unit_price': 100})
    assert form.is_valid() == False
# 以下略

 

全体の流れのテスト

全体の流れのテストコードとして、

  • 正常系
    • POST後リダイレクトしているか
    • リダイレクト後は正しいViewを使用しているか
    • テンプレートに表示されている値は正しいか
    • データベースに保存されているか
  • 異常系
    • フォームでエラーが発生しているか
    • データベースに保存されていないか

を確認するため、test_myapp.pyを作成します。

 

正常系
リダイレクトの確認

リダイレクトをしているかの確認には、self.assertRedirects()を使います。
python - Django : Testing if the page has redirected to the desired url - Stack Overflow

今回のようにCreateViewでリダイレクトする場合、リダイレクト時のレスポンスではHttpResponseRedirectを返すため*1ステータスコード302となります。
Request and response objects - HttpResponseRedirect | Django documentation | Django

また、今回は1件だけの登録なので、urls.pyにて実装したpkに1を渡しています(kwargs={'pk': 1}))。

self.assertRedirects(response, reverse('my:item-detail', kwargs={'pk': 1}), status_code=302)

 
なお、POST後にリダイレクトを伴う場合、django.test.Client.post()の引数にfollow = Trueを設定しておくと、リダイレクト先の情報がcontextなどに格納されます。
Testing tools - django.test.Client.get | Django documentation | Django*2

response = self.client.post(reverse('my:item-creation'), 
                            {
                                'form_only': 'alphabet',
                                'name': 'post_test',
                                'unit_price': 100,
                            }, 
                            follow=True)

 
一方、デフォルトのfollow=Falseとした場合は、

となり、リダイレクト先の情報が少ないです。

そのため、follow=Falseでもリダイレクト情報を扱いたい場合は、Djangoソースコードに従い、self.client._handle_redirects(response)を使えば良さそうです。
django/client.py at 21dd98a38660747c781802afca7ca02407964383 · django/django

 

テンプレートに渡すcontextの値の確認

Response.contextに設定されている値を確認します。
Testing tools - django.test.Response.context | Django documentation | Django

contextへは

などでアクセスできるため、以下のようにしてテストします。

assert response.context['object'].unit_price == 100
assert response.context['item'].unit_price == 100

 

Viewの確認

response.resolver_match.func.__name__を使います。
Testing tools - resolver_match | Django documentation | Django

assert response.resolver_match.func.__name__ == ItemDetailView.as_view().__name__

 

データベースに登録されているか

登録された件数と内容を確認します。

actual = Item.objects.all()
actual_item = actual[0]

assert actual.count() == 1
assert actual_item.name == 'post_test'
assert actual_item.unit_price == 100

 

異常系
フォームでエラーが発生しているか

フォームでエラーが発生しているかは、assertFormError()を使います。
Testing tools - assertFormError | Django documentation | Django

なお、第二引数には、フォームオブジェクトではなくformのcontext名を設定します(通常はform)。

self.assertFormError(response, 'form', 'form_only', 'form_onlyの値 1 にアルファベット以外が含まれています')

 

ステータスコードの確認

Djangoでは、バリデーションエラーの場合でも、デフォルトのステータスコード200です。

assert response.status_code == 200

 

Viewの確認

Viewが変更されていないことを確認します。

assert response.resolver_match.func.__name__ == ItemCreateView.as_view().__name__

 

データベースに保存されていないか

1件も登録されていないことを確認します。

assert Item.objects.all().count() == 0

 

pytest-djangoで、DeprecationWarningのチェック

ここまでのテストコードでDjangoアプリに問題がないかをテストできました。

ただ、Djangoのバージョンを上げるとDeprecationWarningが発生することも考えられます。
DjangoのDeprecationWarningを確認する - 偏った言語信者の垂れ流し

そこで、テストの実行と同時にDeprecationWarningも確認してみます。

 

コマンドラインオプションを使ったチェック

コマンドラインオプションで、テストの実行とDeprecationWarningのチェックを行います。
django-admin and manage.py - check | Django documentation | Django

(env) >python -Wd manage.py check & py.test
...
path\to\myproject\urls.py:22: RemovedInDjango20Warning: Specifying a namespace in django.conf.urls.include() without providing an app_name is deprecated. Set the app_name attribute in the included module, or pass a 2-tuple containing the list of patterns and app_name instead.
  url(r'mysite/', include('apps.myapp.urls', namespace="my")),

今までのソースコード中に、DeprecationWarningが1つ存在していることが確認できました。

 

pytestのフックを使って、テストと同時にチェック

コマンドラインオプションの場合、毎回python -Wd manage.py check & py.testとする必要があるため、うっかり忘れそうな気もしました。

そこで、pytestの設定で対応できないか調べてみたところ、py.testにフックがありました。
Writing plugins - pytest hook reference

フックはいくつかありますが、今回のDeprecationWarningのチェックはpy.testの実行ごとに一度だけ動けばよいので、pytest_unconfigure(called before test process is exited)を使うことにします。

プロジェクトのルートにconftest.pyファイルを作成し、DeprecationWarningをチェックするコードを書きます。

# d:\Sandbox\Django_pytest_sample\conftest.py
import subprocess

def pytest_unconfigure(config):
    print('subprocess')
    subprocess.run(['python', '-Wd', 'manage.py', 'check'], shell=True)

 
再度py.testを実行してみたところ、

(env) d:\Sandbox\Django_pytest_sample>py.test
...
========================== 19 passed in 1.11 seconds ==========================
d:\Sandbox\Django_pytest_sample\myproject\urls.py:21: RemovedInDjango20Warning: Specifying a namespace in django.conf.urls.include() without providing an app_name is deprecated. Set the app_name attribute in the included module, or pass a 2-tuple containing the list of patterns and app_name instead.
  url(r'mysite/', include('apps.myapp.urls', namespace='my')),
System check identified no issues (0 silenced).

テストコードの他にDeprecationWarningのチェックも実行されました。

 

DeprecationWarningへの対応

DeprecationWarningが1件出ていたため、対応してみます。

ドキュメントを読むと、includeでのnamespaceの指定方法が変更となるようでした。
django.conf.urls utility functions | Django documentation | Django URL dispatcher | Django documentation | Django

 
そのため、プロジェクトのurls.pyを以下のように修正します。

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    
    # DeprecationWarning
    # url(r'mysite/', include('apps.myapp.urls', namespace="my")),
    # OK
    url(r'mysite/', include(('apps.myapp.urls', 'my'),)),
]

 
再実行してみます。

(env) d:\Sandbox\Django_pytest_sample>py.test
============================= test session starts =============================
platform win32 -- Python 3.5.1, pytest-2.9.1, py-1.4.31, pluggy-0.3.1
django settings: myproject.settings (from ini file)
rootdir: d:\Sandbox\Django_pytest_sample, inifile: pytest.ini
plugins: django-2.9.1
collected 19 items

apps\myapp\tests\test_model_forms.py ...
apps\myapp\tests\test_models.py .......
apps\myapp\tests\test_myapp.py ..
apps\myapp\tests\test_urls.py ...
apps\myapp\tests\test_views.py ....

========================== 19 passed in 1.05 seconds ==========================
System check identified no issues (0 silenced).

DeprecationWarningが消えていました。

 

ソースコード

GitHubに上げました。
thinkAmi-sandbox/Django_pytest_sample

*1:django/edit.py at e429c5186ceed81c4627165518e0c70c58e69595 · django/django

*2:リンク先はget()の説明ですが、中身はpost()と同じなので、より詳しい方へとリンクしておきます