読者です 読者をやめる 読者になる 読者になる

c-bata web

@c_bata_ のメモ。python多め

Djangoのテストの書き方について勉強したのでまとめる

Python Django テスト

はじめに

この記事はPython Advent Calendar 2014の12日目の記事です. 昨日は「SushiYasukawa」さんによる(Pythonによる簡単なLispインタープリタ実装方法(四則演算編)) - Python, web, Algorithm 技術的なメモでした.


最近Djangoで何か作ったという記事をよく見かけます. 次のQiitaの記事を参考にDjangoの勉強を始められた方が多いようなので、僕も始めてみました.

上記チュートリアルはとても分かりやすくDjangoが少しわかってきたので、さらに理解を深めるためにテストの書き方について勉強し、上記チュートリアルで作成する書籍管理サイトのユニットテストを書いてみました.

具体的にはユニットテストによって以下を検証してみます.

  • モデル(Bookクラス, Impressionsクラス)
  • URIに対して呼び出されるメソッドが正しいか
  • メソッドに対して返されるHTMLが正しいかどうか
  • フォーム(BookFormクラス, ImpressionFormクラス)のテスト
  • POSTリクエストで投げたデータが正しく保存されているか

なお、ソースコードc-bata/TDD-with-Django · GitHubに公開しています.


準備

まずは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アプリケーションをテストする上で便利な独自のアサーションメソッドを提供しています.詳しくは↓.

Testing tools | Django documentation | Django

テストコードを分割

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.pyINSTALLED_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勉強してみたいとか考えてた方はこの機会にどうぞ!


明日のAdvent Calendar

明日は217さんがDjangoについて書いてくださるようです. Django関連の記事が出てくるのは嬉しいですね.

参考資料

Test-Driven Development with Python

Test-Driven Development with Python

この本を読みながらテストの書き方について勉強しました。僕もまだ半分ぐらいしか読めていませんがかなりオススメです。

以下は最近はてぶのホットエントリー等でみかけたDjangoで何かつくっている記事。とてもいい刺激を受けました。ありがとうございます。Djangoユーザがさらに増えるといいですね。