Djangoチュートリアルを適当に進める~その5~

2019年2月23日

Djangoのチュートリアルの備忘録。
さて、続き。アプリのテストを作っていく。
公式チュートリアルだと以下の部分。
https://docs.djangoproject.com/en/2.1/intro/tutorial05/

自働化テスト入門

チュートリアルでは、すごくくどくテストの必要性について書いてある。テストの必要性について充分分かっているなら、前半部分は飛ばすことをオススメしたい。

自働化テストっておいしいの?

テストとはコードの挙動をチェックするシンプルな作業で時短が出来たり問題発生を防いだり、共同でアプリをつくるのに役立つらしい。

基本のテスト戦略

テストを書く方法はいくつもあって、コードを書く前にテストを書く方法もある。

最初のテストを書こうず

バグを知る

投票アプリには、早いとこ直した方がいいバグがある。Question.was_published_recently() のmethodは結構適当。

shellを使って検証してみる。

$ python manage.py shell

>>> import datetime
>>> from django.utils import timezone
>>> from polls.models import Question
>>> # 30日先のpub_dateのデータを持ったQuestionのインスタンスを作る
>>> future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30))
>>> # 最近作られたデータかどうかを確認するメソッドを実行
>>> future_question.was_published_recently()
True

未来のものをTrueと返してはだめ。

バグをさらけ出すテストを作る

作法通りにやると、アプリのテストはtests.pyファイルの中に書くのが良い。Djangoのテストシステムが自動的にテストを見つけ出して実行してくれるから。
test.pyというファイルがpollsのフォルダの中にあるハズなので、その中に次のコードを書く。

import datetime

from django.test import TestCase
from django.utils import timezone

from .models import Question

class QuestionModelTests(TestCase):

    def test_was_published_recently_with_future_question(self):
        """
        was_published_recently() はpub_dateが未来だった時にFalseを返す。
        """
	# timeに現在から30日先の日時を入れる
        time = timezone.now() + datetime.timedelta(days=30)
        # pub_dateに上でつくったtimeの値を入れて、Questionのインスタンスを作る
	future_question = Question(pub_date=time)
	# was_published_recently()メソッドを実行して、Falseが返ってくるか確認
        self.assertIs(future_question.was_published_recently(), False)

さてテストを走らせる。

テストを走らせる

$ python manage.py test polls

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_was_published_recently_with_future_question (polls.tests.QuestionModelTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/mysite/polls/tests.py", line 19, in test_was_published_recently_with_future_question
    self.assertIs(future_question.was_published_recently(), False)
AssertionError: True is not False

----------------------------------------------------------------------
Ran 1 test in 0.002s

FAILED (failures=1)
Destroying test database for alias 'default'...

テストの挙動は以下の通り。

・manage.py test polls でpollsアプリの中のテストを探しに行く。
・んでもって、django.test.TestCaseのサブクラスを見つける
・テストのためのデータベースを作る
・テストのメソッドを探しに行く。testから名前が始まるメソッドを探す。
・test_was_published_recently_with_future_questionの中で未来のpub_dateが設定されたQuestionのインスタンスを作る
・assertIs()メソッドでwas_published_recently()がTrueを返すのを発見する。本当はFalseを返さなきゃいけないのに。

テストはテスト実行が失敗したことに加えて、エラーが発生した場所も教えてくれる。

バグを直す

pub_dateが未来の時はQuestion.was_published_recently()はFalseを返してほしい。
models.pyのメソッドを過ぎた日時のときだけTrueが出るように修正する。

def was_published_recently(self):
    now = timezone.now()
    return now - datetime.timedelta(days=1) <= self.pub_date <= now

そしてもう一度テストを走らせてみる。

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

OK
Destroying test database for alias 'default'...

このようにテストを作っておけば同じバグの再発を防ぐことができる。

もっと網羅的なテスト

test.pyにもっと足して一般的に行われるようなテストを実装していく。

def test_was_published_recently_with_old_question(self):
    # 一日と一秒古いpub_dateの時にFalseが返ってくるか確認する
    time = timezone.now() - datetime.timedelta(days=1, seconds=1)
    old_question = Question(pub_date=time)
    self.assertIs(old_question.was_published_recently(), False)

def test_was_published_recently_with_recent_question(self):
    # 一日ぎりぎりでもTrueを返すか確認する。
    time = timezone.now() - datetime.timedelta(hours=23, minutes=59, seconds=59)
    recent_question = Question(pub_date=time)
    self.assertIs(recent_question.was_published_recently(), True)

そしてもう一度テストを走らせてみる。

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...
----------------------------------------------------------------------
Ran 3 tests in 0.002s

OK
Destroying test database for alias 'default'...

Viewをテストする

本当はpub_dateで設定した日時にはじめて「質問」が表示されないといけない。けど、今は全部質問が表示されてしまう。
これを改善するためには、まずテストを書く。
ここではコードの内部のテストではなく、ブラウザーの表示についてテストをしてみる。

$ python manage.py shell

In [1]: from django.test.utils import setup_test_environment
In [2]: setup_test_environment()

つづいて・・・

In [3]: from django.test import Client

In [4]: client = Client()

In [5]: response = client.get('/')
Not Found: /

In [6]: response.status_code
Out[6]: 404

In [7]: from django.urls import reverse

In [8]: response = client.get(reverse('p
   ...: olls:index'))

In [9]: response.status_code
Out[9]: 200

In [10]: response.content
Out[10]: b'\n    \n'

In [11]: response.context['latest_question_list']
Out[11]: ]>

viewを改善する

pub_dateが未来の日時であれば表示されないように修正する。
get_queryset()メソッドを変更し、現在の時間と比較して値を返すようにする。

polls/views.pyでまずはライブラリのインポートをする。

from django.utils import timezone

timezone・・・・これはジャニーズのグループ、男闘呼組が発売した3枚目のシングルと同名。
ジャニーズらしからぬ非常にカッコいい曲であるが、トム・クルーズ主演の名作「トップガン」主題歌、「デンジャーゾーン」にちょっと似ている。

get_querysetメソッドを以下の通り変更する。

 return Question.objects.filter(
        pub_date__lte=timezone.now()
    ).order_by('-pub_date')[:5]

filterの部分は「フィールド名」+「比較演算子」といったイメージで記述する。
上記のコードで言えば、「pub_date」がフィールド名、「__lte」が比較演算子。less than equal。「○○以下」を示す。timezone.now()で現在時間を取得しているので、今の時間よりも昔のモノをリストアップするといった意味になる。

新しいviewを試す

コードが正しく動くかどうか、runserverでわざわざチェックしてもいいけど、めんどい。
テストを書いて実行したほうが速いし、最初の方で述べた色々な利点がある。
polls/tests.pyに以下を足す

from django.urls import reverse

....

def create_question(question_text, days):
    """
    与えられたquestion_textと時間でquestionを作る。
    """
    time = timezone.now() + datetime.timedelta(days=days)
    return Question.objects.create(question_text=question_text, pub_date=time)


class QuestionIndexViewTests(TestCase):
    def test_no_questions(self):
        """
        quesutionが無いとき羽賀研二のような誠実な謝罪のメッセージが表示されるかテストする。
        """
        response = self.client.get(reverse('polls:index'))
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "No polls are available.")
        self.assertQuerysetEqual(response.context['latest_question_list'], [])

    def test_past_question(self):
        """
        pub_date(発行日)が過ぎたquestionが表示されるか。
        """
        create_question(question_text="Past question.", days=-30)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['']
        )

    def test_future_question(self):
        """
        未来の発行日のquestionは表示されないか。
        """
        create_question(question_text="Future question.", days=30)
        response = self.client.get(reverse('polls:index'))
        self.assertContains(response, "No polls are available.")
        self.assertQuerysetEqual(response.context['latest_question_list'], [])

    def test_future_question_and_past_question(self):
        """
        発行日を過ぎたquestionと未来のquesitonがあるとき、発行日を過ぎたものだけ表示されるか。
        """
        create_question(question_text="Past question.", days=-30)
        create_question(question_text="Future question.", days=30)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['']
        )

    def test_two_past_questions(self):
        """
        インデックスページはいくつものquestionを表示するかどうか。
        """
        create_question(question_text="Past question 1.", days=-30)
        create_question(question_text="Past question 2.", days=-5)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['', '']
        )

さて、上記のテストを実行してみる。


$ python manage.py test polls

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
FF......
======================================================================
FAIL: test_future_question (polls.tests.QuestionIndexViewTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/mysite/polls/tests.py", line 44, in test_future_question
    self.assertContains(response, "No polls are available.")
  File "/home/user/.pyenv/versions/miniconda3-4.3.30/envs/py3/lib/python3.6
/site-packages/django/test/testcases.py", line 369, in assertContains
    self.assertTrue(real_count != 0, msg_prefix + "Couldn't find %s in response" % text_repr)
AssertionError: False is not true : Couldn't find 'No polls are available.' in
 response

======================================================================
FAIL: test_future_question_and_past_question (polls.tests.QuestionIndexViewTes
ts)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/mysite/polls/tests.py", line 56, in test_future_question_and_past_question
    ['']
  File "/home/user/.pyenv/versions/miniconda3-4.3.30/envs/py3/lib/python3.6
/site-packages/django/test/testcases.py", line 955, in assertQuerysetEqual
    return self.assertEqual(list(items), values, msg=msg)
AssertionError: Lists differ: ['', ''] != ['']

あらー。FAILになってしまった。
1番目は'No polls are available.'がFalseにならないよって結果。
つまり、Questionが表示されてしまってますよってこと。
2番目はだけじゃんくても表示されてますよとのこと。
コードをあらためてチェックすると、viewを改善するのところでdef get_queryset()のメソッドを修正するの忘れてた・・・
あらためて、def get_queryset()を修正。timezoneライブラリのimportをして、中身を変える。
timezone・・・・これはジャニーズのグループ、男闘呼組が発売した3枚目のシングルと同名。
ジャニーズらしからぬ非常にカッコいい曲であるが、トム・クルーズ主演の名作「トップガン」主題歌、「デンジャーゾーン」にちょっと似ている。(二回目)

再度ためす。

$ python manage.py test polls
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
........
----------------------------------------------------------------------
Ran 8 tests in 0.035s

OK
Destroying test database for alias 'default'...

よかった。いやーテストって重要。

DetailViewをテスト

未来の日付の質問はindexページで表示されなくなったけど、個別の質問ページはまだ表示されてしまう。
まあ、アドレスが分からなければアクセスできないけど。
つーわけで、以下のようにpolls/views.pyを変更す。

class DetailView(generic.DetailView):
    ...
    def get_queryset(self):
        """
        フィルターを掛けてpub_dateが未来のモノは表示しない。
        """
        return Question.objects.filter(pub_date__lte=timezone.now())

そしてまたテストを作る。

class QuestionDetailViewTests(TestCase):
    def test_future_question(self):
        """
        未来のpub_dateの質問の詳細ページを開いたときに404が返ってくるか。
        """
        future_question = create_question(question_text='Future question.', days=5)
        url = reverse('polls:detail', args=(future_question.id,))
        response = self.client.get(url)
        self.assertEqual(response.status_code, 404)

    def test_past_question(self):
        """
		過去のpub_dateの質問詳細ページはきちんと表示されるか。
        """
        past_question = create_question(question_text='Past Question.', days=-5)
        url = reverse('polls:detail', args=(past_question.id,))
        response = self.client.get(url)
        self.assertContains(response, past_question.question_text)

そしてテストを実行してみる。

$ python manage.py test polls
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..........
----------------------------------------------------------------------
Ran 10 tests in 0.057s

OK
Destroying test database for alias 'default'...

問題なく成功。

ResultsViewをテスト

まあ、DetailViewと同じ。

class ResultsView(generic.DetailView):
	...
    def get_queryset(self):
        """
        フィルターを掛けてpub_dateが未来のモノは表示しない。
        """
        return Question.objects.filter(pub_date__lte=timezone.now())

そしてまたテストを作る。

class QuestionResultsViewTests(TestCase):
    def test_future_question(self):
        """
        未来のpub_dateの質問の投票結果ページを開いたときに404が返ってくるか。
        """
        future_question = create_question(question_text='Future question.', days=5)
        url = reverse('polls:results', args=(future_question.id,))
        response = self.client.get(url)
        self.assertEqual(response.status_code, 404)

    def test_past_question(self):
        """
		過去のpub_dateの質問の投票結果ページはきちんと表示されるか。
        """
        past_question = create_question(question_text='Past Question.', days=-5)
        url = reverse('polls:results', args=(past_question.id,))
        response = self.client.get(url)
        self.assertContains(response, past_question.question_text)

他にも選択肢を持たない質問は表示しないようにするテストとか、管理者以外が未来のpub_dateの質問を見られないかテストすることが提案されているが、チュートリアルでは実装され無い様子。どうやらチュートリアルで作る投票アプリは、機能的にはここいらまでみたい。

チュートリアルではテストは多いほどいいし、テスト同士が重なっていてもさほど問題にはならないとしている。
チュートリアルの最後には、実際のブラウザーの挙動に沿ったテストにはselenium等のツールを使うことが紹介され、連携させるためにDjangoのLiveServerCaseを使うとしてる。