Djangoのテストについて考えていたところ、以下の記事に出会いました。
後者の記事にもある通り、pytest
ではテストの失敗内容を細かく出せそうでした。
そのため、Django向けのpytestライブラリpytest-django
を使ってテストコードを書いてみました。
目次
- 環境
- 試したけど分からなかったこと
- 試してないこと
- テスト対象のアプリ
- セットアップとアプリ作成
- pytest.iniの作成
- Modelのテスト
- URL解決のテスト
- Viewのテスト
- Formのテスト
- 全体の流れのテスト
- pytest-djangoで、DeprecationWarningのチェック
- ソースコード
環境
試したけど分からなかったこと
Djangoのテストを書く上で、一般的にはどちらを使うのかが分からなかったものを残しておきます。
もし指針などがあれば教えていただけるとありがたいです。
Viewのテストでリクエストを送信する際、
django.test.Client
とRequestFactory
のどちらを使うのか- 今回は、ViewのテストはRequestFactory、それ以外のテストはミドルウェアも関係するのでClientを使ってみた
テスト中のURLは、ハードコーディングするのか、
django.core.urlresolvers.reverse()
を使うのか- 今回は、URL解決のテストはハードコーディング、それ以外のテストはreverse()を使ってみた
試してないこと
今回はさらっと触れただけなので、以下のテストコードは書きませんでした。
- モックなどのテストダブルを使ったテスト
- Seleniumを使ったテスト
テスト対象のアプリ
以下のような流れのテスト対象アプリを作成しました。
mysite/create
へアクセスすると、CreateView + ModelFormを使った登録フォームを表示- 登録フォームに入力しPOST
- POSTデータをDBへ保存
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
を作成します。
テストでは
- URLが存在する場合、対応するクラスが呼ばれているか
- URLが存在しない場合、
Resolver404
例外が発生するか
を確認します。
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
とした場合は、
- ステータスコードは、
302
- response.contextは、
None
となり、リダイレクト先の情報が少ないです。
そのため、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へは
object
- (出典忘れ)
- DetailViewの
context_object_name
の値か、DetailViewのModelのlowercase名- 今回はcontext_object_nameをセットしていないため、後者の値
item
となります - Single object mixins | Django documentation | Django
- 今回はcontext_object_nameをセットしていないため、後者の値
などでアクセスできるため、以下のようにしてテストします。
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()と同じなので、より詳しい方へとリンクしておきます