追記: この記事の内容はかなり古くなっています。翔泳社さんからDjangoの書籍を出版するので、ぜひ読んでみてください。
はじめに
この記事はPython Advent Calendar 2014の12日目の記事です. 昨日は「SushiYasukawa」さんによる(Pythonによる簡単なLispインタープリタ実装方法(四則演算編)) - Python, web, Algorithm 技術的なメモでした.
最近Djangoで何か作ったという記事をよく見かけます. 次のQiitaの記事を参考にDjangoの勉強を始められた方が多いようなので、僕も始めてみました.
- Python Django入門 (1) - Qiita
- Python Django入門 (2) Mac編 - Qiita
- Python Django入門 (3) - Qiita
- Python Django入門 (4) - Qiita
- Python Django入門 (5) - Qiita
- Python Django入門 (6) - Qiita
上記チュートリアルはとても分かりやすくDjangoが少しわかってきたので、さらに理解を深めるためにテストの書き方について勉強し、上記チュートリアルで作成する書籍管理サイトのユニットテストを書いてみました.
具体的にはユニットテストによって以下を検証してみます.
- モデル(Bookクラス, Impressionsクラス)
- URIに対して呼び出されるメソッドが正しいか
- メソッドに対して返されるHTMLが正しいかどうか
- フォーム(BookFormクラス, ImpressionFormクラス)のテスト
- POSTリクエストで投げたデータが正しく保存されているか
なお、ソースコードはGitHub - c-bata/TDD-with-Django: QiitaのDjango入門をTDDで書いてみるに公開しています.
準備
まずはPython Django入門 (3) - Qiitaに沿ってプロジェクトやアプリケーションを作成する
$ django-admin.py startproject mybook $ python manage.py migrate $ python manage.py createsuperuser $ python manage.py runserver ブラウザで http://127.0.0.1:8000/ にアクセスして動作確認 $ python manage.py startapp cms
Djangoのユニットテストの書き方
cms/tests.pyに以下を記述して、python manage.py testを実行してみてください.もちろん以下のテストは失敗します.
from django.test import TestCase
class SmokeTest(TestCase):
def test_bad_maths(self):
self.assertEqual(1+1, 3) # 失敗
Failure
Traceback (most recent call last):
File "/Users/masashi/PycharmProjects/mybook/cms/tests.py", line 8, in test_bad_maths
self.assertEqual(1+1, 3)
AssertionError: 2 != 3
簡単に解説しておくと、DjangoではPython標準のTestCaseクラス(unittest.TestCase)を拡張したDjango独自のTestCaseクラス(django.test.TestCase)を使うようです.このクラスはWebアプリケーションをテストする上で便利な独自のアサーションメソッドを提供しています.詳しくは↓.
https://docs.djangoproject.com/en/1.7/topics/testing/tools/#assertions
テストコードを分割
cms/tests.pyにこのままテストコードを増やしていくと読みにくくなってしまうので分割した方が良さそうです.
cms/tests/ディレクトリを作成して、その中にテストコードを格納していきます.
Bookクラスのテスト
それでは実際にTDDの手順を踏みながらBookクラスのテストを書いてみます.
Bookクラスのテストケースはcms/tests/test_book_model.pyを作成してその中に記述しました.
Bookクラスに対して以下のテストを書いてみます.
- 何も登録しなければレコードの数は0個
- 1つデータを登録すればレコードの数は1個
名前(name),ページ数(page),出版社(publisher)を属性として持つ
テストを書く
何も登録しなければ保存されたレコードの数は0個
from django.test import TestCase
from cms.models import Book
class BookModelTests(TestCase):
def test_is_empty(self):
saved_books = Book.objects.all()
self.assertEqual(saved_books.count(), 0)
テスト実行
$ python manage.py test : ImportError: cannot import name 'Book'
まだ実装していないのでもちろんエラー
Bookクラスを用意
from django.db import models
class Book(models.Model):
pass
テスト実行
$ python manage.py test : django.db.utils.OperationalError: no such table: cms_book
エラーメッセージが変化
モデルを有効にするためにmybook/settings.pyのINSTALLED_APPに'cms',を追記
INSTALLED_APPS = (
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'cms', # 追記
)
マイグレートファイルを作成してデータベースに反映
$ python manage.py makemigrations cms
Migrations for 'cms':
0001_initial.py:
- Create model Book
$ python manage.py migrate
Operations to perform:
Apply all migrations: auth, contenttypes, cms, sessions, admin
Running migrations:
Applying cms.0001_initial... OK
テスト実行
$ python manage.py test Creating test database for alias 'default'... . ---------------------------------------------------------------------- Ran 1 test in 0.001s OK Destroying test database for alias 'default'...
テスト通過!
テストケース追加
1つ登録すれば保存されたレコードの数は1個
def test_is_not_empty(self):
book = Book()
book.save()
saved_books = Book.objects.all()
self.assertEqual(saved_books.count(), 1)
テスト実行
$ python manage.py test Creating test database for alias 'default'... .. ---------------------------------------------------------------------- Ran 2 tests in 0.001s OK Destroying test database for alias 'default'...
うーん... よくよく考えるとここはTDDの手順を踏んでいない気がする. このテストケースは先に書いとくべきだったのかな?
テストケース追加
def test_saving_and_retrieving_book(self):
first_book = Book()
name, page, publisher = 'name', 10, 'publisher'
first_book.name = name
first_book.page = page
first_book.publisher = publisher
first_book.save()
saved_books = Book.objects.all()
actual_book = saved_books[0]
self.assertEqual(actual_book.name, name)
self.assertEqual(actual_book.page, page)
self.assertEqual(actual_book.publisher, publisher)
TDDのステップを忠実に踏むなら、ここではまだ書籍名の属性(name)だけを調べるべきだったかも。ただ明白な気もするので一気に実装します.
テスト実行
$ python manage.py test
Creating test database for alias 'default'...
..E
======================================================================
ERROR: test_saving_and_retrieving_book (cms.tests.test_book_model.BookModelTests)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/masashi/PycharmProjects/mybook/cms/tests/test_book_model.py", line 30, in test_saving_and_retrieving_book
self.assertEqual(actual_book.name, book_name)
AttributeError: 'Book' object has no attribute 'name'
----------------------------------------------------------------------
Ran 3 tests in 0.003s
FAILED (errors=1)
Destroying test database for alias 'default'...
予定通りのエラー
- Bookクラスに属性を追加
class Book(models.Model):
"""書籍"""
name = models.CharField('書籍名', max_length=255)
publisher = models.CharField('出版社', max_length=255, default=True)
page = models.IntegerField('ページ数', blank=True, default=0)
マイグレートファイルの作成
$ python manage.py makemigrations
You are trying to add a non-nullable field 'name' to book without a default; we can't do that (the database needs something to populate existing rows).
Please select a fix:
1) Provide a one-off default now (will be set on all existing rows)
2) Quit, and let me add a default in models.py
Select an option: 1
Please enter the default value now, as valid Python
The datetime and django.utils.timezone modules are available, so you can do e.g. timezone.now()
>>> 'aaa'
Migrations for 'cms':
0002_auto_20141211_0327.py:
- Add field name to book
- Add field page to book
- Add field publisher to book
Bookクラスに新たな属性を追加しようとしましたが、書籍名(name)はnullを許さないのでデータベースのマイグレーションを行うには、既存のレコードに対するデフォルト値の入力が必要だと言っているみたいです.今回はまだ何もデータベースに登録していないのでデフォルト値はなんでもいいと思います(ここでは'aaa'としました).
$ python manage.py migrate Operations to perform: Apply all migrations: contenttypes, admin, cms, auth, sessions Running migrations: Applying cms.0002_auto_20141211_0327... OK
テスト実行
$ python manage.py test Creating test database for alias 'default'... ... ---------------------------------------------------------------------- Ran 3 tests in 0.002s OK Destroying test database for alias 'default'...
通過!
リファクタリング
テストコードを見ると、以下の点からリファクタリングの余地がありそうです.
test_saving_and_retrieving()が11行、少し読みづらい気がするtest_saving_and_retrieving()の中にassertion methodが3つある- Bookクラスのcreation methodを作るともうちょっと本質的で読みやすそう
リファクタリングしてみると、cms/tests/test_book_model.pyは以下のようになりました.
from django.test import TestCase
from cms.models import Book
class BookAssertion(TestCase):
def assertBookModel(self, actual_book, name, page, publisher):
self.assertEqual(actual_book.name, name)
self.assertEqual(actual_book.page, page)
self.assertEqual(actual_book.publisher, publisher)
class BookModelTests(BookAssertion):
def creating_a_book_and_saving(self, name=None, page=None, publisher=None):
book = Book()
if name is not None:
book.name = name
if page is not None:
book.page = page
if publisher is not None:
book.publisher = publisher
book.save()
def test_is_empty(self):
saved_books = Book.objects.all()
self.assertEqual(saved_books.count(), 0)
def test_is_not_empty(self):
self.creating_a_book_and_saving()
saved_books = Book.objects.all()
self.assertEqual(saved_books.count(), 1)
def test_saving_and_retrieving_book(self):
name, page, publisher = 'name', 10, 'publisher'
self.creating_a_book_and_saving(name, page, publisher)
saved_books = Book.objects.all()
actual_book = saved_books[0]
self.assertBookModel(actual_book, name, page, publisher)
Impressionクラスのテスト
出来ればTDDの手順を踏みながら最後まで書きたかったんですがあまりにも長くなってしまうので、
ここからは解説なしで最終的に出来上がったテストコードのみ載せていきます.
cms/tests/test_impression_model.py
from django.test import TestCase
from cms.models import Book, Impression
class ImpressionModelTests(TestCase):
def create_impression(self, comment=None):
book = Book()
book.save()
impression = Impression()
impression.book = book
if comment is not None:
impression.comment = comment
impression.save()
def test_is_empty(self):
saved_books = Impression.objects.all()
self.assertEqual(saved_books.count(), 0)
def test_is_not_empty(self):
self.create_impression()
saved_books = Impression.objects.all()
self.assertEqual(saved_books.count(), 1)
def test_impression_size_equals_book_size(self):
self.create_impression()
saved_books = Book.objects.all()
saved_impressions = Impression.objects.all()
self.assertEqual(saved_books.count(),
saved_impressions.count())
def test_saving_and_retrieving_impression(self):
comment = 'impression comment'
self.create_impression(comment)
saved_impressions = Impression.objects.all()
impression = saved_impressions[0]
self.assertEqual(impression.comment, comment)
URL解決のテスト
cms/tests/test_urls.py
from django.core.urlresolvers import resolve
from django.test import TestCase
from cms.views import book_list, book_edit, book_del
class UrlResolveTests(TestCase):
def test_url_resolves_to_book_list_view(self):
"""/cms/book/では、book_listが呼び出される事を検証"""
found = resolve('/cms/book/')
self.assertEqual(found.func, book_list)
def test_url_resolves_to_book_add_view(self):
"""/cms/book/add/では、book_editが呼び出される事を検証"""
found = resolve('/cms/book/add/')
self.assertEqual(found.func, book_edit)
def test_url_resolves_to_book_mod_view(self):
"""/cms/book/mod/では、book_editが呼び出される事を検証"""
found = resolve('/cms/book/mod/1/')
self.assertEqual(found.func, book_edit)
def test_url_resolves_to_book_del_view(self):
"""/cms/book/del/では、book_delが呼び出される事を検証"""
found = resolve('/cms/book/del/1/')
self.assertEqual(found.func, book_del)
django.core.urlresolvers.resolve()関数は、URLのパスとそれに付随しているビュー関数を呼び出すのに使われます。もしURLが見つからなければ、関数はhttp404という例外を発生させる.
正しいHTMLが返されているかテスト
cms/tests/test_return_correct_html.py
from django.http import HttpRequest
from django.template.loader import render_to_string
from django.test import TestCase
from cms.views import book_list
class HtmlTests(TestCase):
def test_book_list_page_returns_correct_html(self):
request = HttpRequest()
response = book_list(request)
expected_html = render_to_string('cms/book_list.html',
{'books': []})
self.assertEqual(response.content.decode(), expected_html)
BookFormクラスのテスト
cms/tests/test_book_form.py
from django.test import TestCase
from cms.forms import BookForm
from cms.models import Book
class BookFormTests(TestCase):
def test_valid(self):
"""正常な入力を行えばエラーにならないことを検証"""
params = dict(name='書籍タイトル', publisher='出版社', page=0)
book = Book() # book_idの指定なし(追加時)
form = BookForm(params, instance=book)
self.assertTrue(form.is_valid())
def test_either1(self):
"""何も入力しなければエラーになることを検証"""
params = dict()
book = Book() # book_idの指定なし(追加時)
form = BookForm(params, instance=book)
self.assertFalse(form.is_valid())
POSTリクエストでちゃんとデータを保存するかテスト
book_editメソッドにPOSTでデータを投げたら、保存してくれないといけないのでちゃんと保存してるか確認
cms/tests/test_can_save_a_post_request.py
from django.http import HttpRequest
from cms.views import book_edit
from django.test import TestCase
class CanSaveAPostRequestAssert(TestCase):
def assertFieldInResponse(self, response, name, page, publisher):
self.assertIn(name, response.content.decode())
self.assertIn(page, response.content.decode())
self.assertIn(publisher, response.content.decode())
class CanSaveAPostRequestTests(CanSaveAPostRequestAssert):
def post_request(self, name, page, publisher):
request = HttpRequest()
request.method = 'POST'
request.POST['name'] = name
request.POST['page'] = page
request.POST['publisher'] = publisher
return request
def test_book_edit_can_save_a_post_request(self):
name, page, publisher = 'name', 'page', 'publisher'
request = self.post_request(name, page, publisher)
response = book_edit(request)
self.assertFieldInResponse(response, name, page, publisher)
ImpressionFormクラスのテスト
from django.test import TestCase
from cms.forms import ImpressionForm
from cms.models import Impression
class ImpressionFormTests(TestCase):
def test_valid(self):
"""正常な入力を行えばエラーにならないことを検証"""
params = dict(comment='感想')
impression = Impression()
form = ImpressionForm(params, instance=impression)
self.assertTrue(form.is_valid())
def test_either(self):
"""何も入力しなければエラーになることを検証"""
params = dict()
impression = Impression()
form = ImpressionForm(params, instance=impression)
self.assertFalse(form.is_valid())
おわりに
APIのテスト等も書いてみたかったんですが、どうテストすればいいのかよく分からなかったのでここで終わります. これまでWebアプリケーションフレームワークはFlaskしか使えなかったんですが、Djangoでの開発は本当に速くて驚きばかりです.
Django1.7からデータベースのマイグレーション機能が標準でサポートされたり、Djangoの勉強を始めるにはちょうどいい時期かと思うので僕のようにDjango勉強してみたいとか考えてた方はこの機会にどうぞ!
