Djangoでテスト駆動開発 その3
これはDjangoでテスト駆動開発(Test Driven Development, 以下:TDD)を理解するための学習用メモです。
参考文献はTest-Driven Development with Python: Obey the Testing Goat: Using Django, Selenium, and JavaScript (English Edition) 2nd Editionを元に学習を進めていきます。
本書ではDjango1.1系とFireFoxを使って機能テスト等を実施していますが、今回はDjagno3系とGoogle Chromeで機能テストを実施していきます。また一部、個人的な改造を行っていますが(Project名をConfigに変えるなど、、)、大きな変更はありません。
⇒⇒その1 - Chapter1はこちら
⇒⇒その2 - Chapter2はこちら
Part1. The Basics of TDD and Django
Capter3. Testing as Simple Home Page with Unit Tests
Chapter2では機能テストをunittest.TestCase
を使って記述し、ページタイトルに”To-Do”があるかどうかをテストしました。
今回は実際にアプリケーションを開始してTDDしていきます。
Our First Django App, and Our First Unit Test
Djangoは1つのプロジェクト下に複数のアプリケーションを構築させる形と取っています。
さっそくDjangoのアプリケーションを作成していきましょう。
ここではlistsという名前のアプリケーションを作成します。
$ python manage.py startapp lists
Unit Tests, and How They Differ from Functional Tests
機能テスト(Functional Tests)はアプリケーションを外側から(ユーザー視点で)見て正確に機能しているのかどうかをテストしているのに対して、
単体テスト(Unit Tests)はアプリケーションを内側から(開発者視点で)機能しているのかをテストしています。
TDDは機能テスト、単体テストをカバーすることが求められており、開発手順は下記のようになります。
step1. 機能テストを書く(ユーザー視点から新しい機能を説明しながら)。
step2. 機能テストが失敗したらテストをパスするにはどうコードを書いたら良いのかを考える(いきなり書かない)。自分の書いたコードがどう振舞って欲しいのかを単体テストを追加して定義する。
step3. 単体テストが失敗したら、単体テストがパスする最小のアプリケーションコードを書く。
step4. step2とstep3を繰り返して最後に機能テストがパスかどうかを確認する。
Unit Testing in Django
ホームページのviewのテストをlists/tests.pyに書いていきます。まずはこちらを確認してみましょう。
# lists/tests.py
from django.test import TestCase
# Create your tests here.
これを見るとDjangoが提供するTestCaseクラスを使って単体テストを書くことができることがわかりました。djagno.test.TestCase
は機能テストで使った標準モジュールであるunittest.TestCase
を拡張したものです。
試しに単体テストを書いてみます。
# lists/tests.py
from django.test import TestCase
class SmokeTest(TestCase):
def test_bad_maths(self):
self.assertEqual(1 + 1, 3)
Djangoには各アプリケーションのテストを探してテストを実行するテストランナー機能があります。
Djangoのテストランナーを起動してみましょう。
$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_bad_maths (lists.tests.SmokeTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "C:\--your_pass--\django-TDD\lists\tests.py", line 9, in test_bad_maths
self.assertEqual(1 + 1, 3)
AssertionError: 2 != 3
----------------------------------------------------------------------
Ran 1 test in 0.005s
FAILED (failures=1)
Destroying test database for alias 'default'...
lists/tests.pyが実行されてFailedしているのが確認できました。ここでコミットしておきます。
$ git status
$ git add lists
$ git commit -m "Add app for lists, with deliberately failing unit test"
Django's MVC, URLs, and View Functions
Djangoは特定のURLに対して何をするべきなのかを定義しておく必要があります。
Djangoのワークフローは次のようになっています。
-
HTTP/requestが特定のURLにくる
-
HTTP/requestに対してどのViewを実行するべきなのかルールが決められているのでルールにしたがってviewを実行する。
-
Viewはrequestを処理してHTTP/responseを返す。
したがって、我々が行うことは次の2点です。
-
URLとViewの紐づけ(resolve the URL)ができるか
-
Viewは機能テストをパスすることができるHTMLを変えることができるのか
それではlists/tests.pyを開いて小さなテストを書いてみましょう。
# lists/tests.py
from django.urls import resolve # 追加
from django.test import TestCase
from lists.views import home_page # 追加
class HomePageTest(TestCase):
def test_root_url_resolve_to_home_page_view(self):
found = resolve('/')
self.assertEqual(found.func, home_page)
django.urls.resolve
はdjangoが内部で使用しているURLを解決するためモジュールです。
from lists.views import home_page
は次に記述する予定のviewです。これを次に記述することがわかります。
それではテストしてみます。
$ python manage.py test
System check identified no issues (0 silenced).
E
======================================================================
ERROR: lists.tests (unittest.loader._FailedTest)
----------------------------------------------------------------------
ImportError: Failed to import test module: lists.tests
Traceback (most recent call last):
File "C:\--your_user_name--\AppData\Local\Programs\Python\Python37\lib\unittest\loader.py", line 436, in _find_test_path
module = self._get_module_from_name(name)
File "C:\--your_user_name--\AppData\Local\Programs\Python\Python37\lib\unittest\loader.py", line 377, in _get_module_from_name
__import__(name)
File "C:\--your_path--\django-TDD\lists\tests.py", line 5, in <module>
from lists.views import home_page
ImportError: cannot import name 'home_page' from 'lists.views' (C:\Users\--your_path--\django-TDD\lists\views.py)
----------------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (errors=1)
ImportError
が出現しました。内容をみるとlists.views
からhome_page
がimportできないと教えてくれています。
それではlists.views
にhome_page
を記述してみましょう。
# lists/views.py
from django.shortcuts import render
home_page = None
なにかの冗談みたいですがこれでImportError
は解決できるはずです。TDDはエラーを解決する最小のコードを書いていくお気持ちを思い出しましょう。
もう一度テストしてみます。
$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
E
======================================================================
ERROR: test_root_url_resolve_to_home_page_view (lists.tests.SmokeTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "C:\--your_path--\django-TDD\lists\tests.py", line 10, in test_root_url_resolve_to_home_page_view
found = resolve('/')
File "C:\--your_path--\django-TDD\venv-tdd\lib\site-packages\django\urls\base.py", line 25, in resolve
return get_resolver(urlconf).resolve(path)
File "C:\--your_path--\django-TDD\venv-tdd\lib\site-packages\django\urls\resolvers.py", line 575, in resolve
raise Resolver404({'tried': tried, 'path': new_path})
django.urls.exceptions.Resolver404: {'tried': [[<URLResolver <URLPattern list> (admin:admin) 'admin/'>]], 'path': ''}
----------------------------------------------------------------------
Ran 1 test in 0.005s
FAILED (errors=1)
Destroying test database for alias 'default'...
確かにImportError
は解決しましたが、またしてもテストが失敗しました。Tracebackを確認すると'/'
をresolve
が解決してもDjangoが404エラーを返すことが分かります。つまり、Djangoが'/'
を解決できていないという意味になります。
urls.py
DjangoにはURLとViewをマッピングするurls.pyが存在します。config/urls.pyがメインのurls.pyになります。こちらを確認してみましょう。
# config/urls.py
"""config URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/3.0/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
urlpatterns = [
path('admin/', admin.site.urls),
]
中身を確認すると"config URL Configuration"
というURLマッピングの書き方が書かれているので参考にします。
今回はFunciton viewsの書き方で"/"
とhome_page
のマッピングをurlpatterns
に追加してみましょう。
また、admin/
はまだ使用しないのでコメントアウトしておきます。
# config/urls.py
from django.contrib import admin
from django.urls import path
from lists import views
urlpatterns = [
# path('admin/', admin.site.urls), # コメントアウト
path('', views.home_page, name='home')
]
マッピングができました。テストをします。
$ python manage.py test
[...]
TypeError: view must be a callable or a list/tuple in the case of include().
URLのマッピングを追加したので404エラー
は解決されましたが、TypeError
が発生しました。
これはlists.view
からhome_page
を呼んでもhome_page = None
で何も返ってこなかったためだと思われます。
lists/views.pyを編集してこれを解決しましょう。
# lists/views.py
from django.shortcuts import render
def home_page():
pass
テストしてみます。
$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.003s
OK
Destroying test database for alias 'default'...
単体テストをパスできました。ここでコミットしておきます。
$ git add .
$ git status
$ git commit -m "First unit test and url mapping, dummy view"
現在のlists/views.pyが実際にHTMLを返しているのかどうかをテストできるようにlists/tests.pyを書き換えていきます。
# lists/tests.py
from django.urls import resolve
from django.test import TestCase
from django.http import HttpRequest
from lists.views import home_page
class HomePageTest(TestCase):
def test_root_url_resolve_to_home_page_view(self):
found = resolve('/')
self.assertEqual(found.func, home_page)
def test_home_page_returns_current_html(self): # 追加
request = HttpRequest()
response = home_page(request)
html = response.content.decode('utf8')
self.assertTrue(html.startswith.('<html>'))
self.assertIn('<title>To-Do lists</title>', html)
self.assertTrue(html.endwith('</html>'))
test_root_url_resolve_to_home_page_view
はURLマッピングが正確にできていのかどうかを確認していますが、
test_home_page_returns_current_html
で正確なHTMLが返せているのかどうかを確認しています。
新たに単体テストを追加したので早速テストしてみましょう。
$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
E.
======================================================================
ERROR: test_home_page_returns_current_html (lists.tests.SmokeTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "C:\--your_path--\django-TDD\lists\tests.py", line 18, in test_home_page_returns_current_html
response = home_page(request)
TypeError: home_page() takes 0 positional arguments but 1 was given
----------------------------------------------------------------------
Ran 2 tests in 0.005s
FAILED (errors=1)
Destroying test database for alias 'default'...
TypeError
が出ました。内容を確認するとhome_page() takes 0 positional arguments but 1 was given
とあるので
home_page()
の定義では引数が指定されていない(0 positional arguments
)が、引数が与えられてて(1 was given
)おかしいということがわかります。
ということでlists/views.pyを書き換えたいと思います。
# lists/views.py
from django.shortcuts import render
def home_page(request): # 変更
pass
home_page()
関数に引数request
を追加しました。これでテストをしてみます。
$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
E.
======================================================================
ERROR: test_home_page_returns_current_html (lists.tests.SmokeTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "C:\--your_path--\django-TDD\lists\tests.py", line 19, in test_home_page_returns_current_html
html = response.content.decode('utf8')
AttributeError: 'NoneType' object has no attribute 'content'
----------------------------------------------------------------------
Ran 2 tests in 0.005s
FAILED (errors=1)
Destroying test database for alias 'default'...
TypeError
は解決しましたが、次はAttributeError
が発生しました。
'NoneType' object has no attribute 'content'
とあるのでhome_page(request)
の戻り値がNone
となっているのが原因のようです。
lists/views.pyを修正します。
# lists/views.py
from django.shortcuts import render
from django.http import HttpResponse # 追加
def home_page(request):
return HttpResponse()
django.http.HttpResponse
を返すように修正しました。テストしてみましょう。
$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F.
======================================================================
FAIL: test_home_page_returns_current_html (lists.tests.SmokeTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "C:\Users\--your_path--\django-TDD\lists\tests.py", line 20, in test_home_page_returns_current_html
self.assertTrue(html.startswith('<html>'))
AssertionError: False is not true
----------------------------------------------------------------------
Ran 2 tests in 0.005s
FAILED (failures=1)
Destroying test database for alias 'default'...
AttributeError
は解決してAssertionError
が発生しました。html.startwith('<html>')
がFalseであるためにFalse is not ture
というメッセージがでているのがわかります。
lists/views.pyを修正します。
# lists/views.py
from django.shortcuts import render
from django.http import HttpResponse
def home_page(request):
return HttpResponse('<html>') # 変更
$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F.
======================================================================
FAIL: test_home_page_returns_current_html (lists.tests.SmokeTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "C:\--your_path--\django-TDD\lists\tests.py", line 21, in test_home_page_returns_current_html
self.assertIn('<title>To-Do lists</title>', html)
AssertionError: '<title>To-Do lists</title>' not found in '<html>'
----------------------------------------------------------------------
Ran 2 tests in 0.005s
FAILED (failures=1)
Destroying test database for alias 'default'...
同じくAssertionError
です。'<title>To-Do lists</title>'
が見つからないとのことです。
lists/views.pyを修正します。
# lists/views.py
from django.shortcuts import render
from django.http import HttpResponse
def home_page(request):
return HttpResponse('<html><title>To-Do lists</title>') # 変更
$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F.
======================================================================
FAIL: test_home_page_returns_current_html (lists.tests.SmokeTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "C:\--your_path--\django-TDD\lists\tests.py", line 22, in test_home_page_returns_current_html
self.assertTrue(html.endswith('</html>'))
AssertionError: False is not true
----------------------------------------------------------------------
Ran 2 tests in 0.013s
FAILED (failures=1)
Destroying test database for alias 'default'...
同じくAssertionError
です。'</html>'
が見つからないとのことです。
lists/views.pyを修正します。
# lists/views.py
from django.shortcuts import render
from django.http import HttpResponse
def home_page(request):
return HttpResponse('<html><title>To-Do lists</title></html>') # 変更
これでようやくうまくいくはずです。
$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.004s
OK
Destroying test database for alias 'default'...
うまくいきました。単体テストがパスできたので開発サーバーを立ち上げてから機能テストを実行してみましょう。
# 開発サーバーの立ち上げ
$ python manage.py runserver
# 別のcmdを立ち上げて機能テストを実行
$ python functional_tests.py
DevTools listening on ws://127.0.0.1:51108/devtools/browser/9d1c6c55-8391-491b-9b14-6130c3314bba
F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "functional_tests.py", line 21, in test_can_start_a_list_and_retrieve_it_later
self.fail('Finish the test!')
AssertionError: Finish the test!
----------------------------------------------------------------------
Ran 1 test in 7.244s
FAILED (failures=1)
機能テストはFAILEDとなっていますが、これはunittest.TestCase
の.fail
を使ってテストをパスしても必ずエラーを発生させるようにしているためでした。
したがって、機能テストがうまくいったことがわかります!
コミットしておきましょう。
$ git add .
$ git commit -m "Basic view now return minimal HTML"
Chapter3まとめ
ここまででカバーしたことを確認しておきます。
-
Djagnoのアプリケーションをスタートしました。
-
Djangoのユニットテストランナーを使いました。
-
機能テストと単体テストの違いを理解しました。
-
Djangoのrequestとresponseオブジェクトを使ってviewを作成しました
-
基本的なHTMLを返しました。
単体テストとコードの追加修正サイクルを回して機能を作っていく過程を確認できました。