※弊社記事はたぶんにPRが含まれますので
話半分で受け取ってください。

Django公式サイトのチュートリアルを咀嚼しながらやってみる

6. はじめての Django アプリ作成、その 5

出典:はじめての Django アプリ作成、その 5 | Django ドキュメント | Django
https://docs.djangoproject.com/ja/3.2/intro/tutorial05/

 投票アプリが正しく動作するかチェックするため、自動テストを作成します。

6.1. 自動テストの導入

 テストは大事というお話。

6.2. 基本的なテスト方針

 テストの方針として考えられるのは以下の2つ。

  • コードを書いてからテストを書く
  • テストを書いてからコードを書く

6.3. 初めてのテスト作成

 まずは「コードを書いてからテストを書く」パターン。

6.3.1. バグを見つけたとき

 polls アプリの Question.was_published_recently() は pub_date が未来の日付でも True を返します。

# 質問
class Question(models.Model):

    #(省略)

    # 最近公開されたものか判別するメソッド (boolean)
    def was_published_recently(self):
        return self.pub_date >= timezone.now() - datetime.timedelta(days=1)
        # 公開日時が1日前よりあとかどうかだけを見ているので、
        # 未来の日付を設定した場合も True が返される。
        # 本来なら未来の公開日時になっているものは非表示でなければならない。

#(省略)

 シェルを使って実際にバグを確認してみます。

% python manage.py shell
>>> import datetime
>>> from django.utils import timezone
>>> from polls.models import Question

>>> # create a Question instance with pub_date 30 days in the future
>>> # pub_date が30日分未来になっている Question インスタンスを作成します。
>>> future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30))

>>> # was it published recently?
>>> # 最近公開されましたか?
>>> future_question.was_published_recently()
True

 未来の日付でも True を返すのでこれはバグです。

6.3.2. バグをあぶり出すためにテストを作成する

 バグの確認のためにいちいちシェルを入力しなくてもいいように polls/test.py に自動テストを書きます。

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() returns False for questions whose pub_date
        # is in the future.
        # was_published_recently() は pub_date が未来の場合は False を返します。

        time = timezone.now() + datetime.timedelta(days=30)
        future_question = Question(pub_date=time)
        self.assertIs(future_question.was_published_recently(), False)
        # unittest --- ユニットテストフレームワーク — Python 3.9.4 ドキュメント
        # https://docs.python.org/ja/3/library/unittest.html#unittest.TestCase.debug

6.3.3. テストの実行

 テストを実行してみます。

% python manage.py test poll

 すると以下のような結果が表示されます。

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 "/testsite/mysite/polls/tests.py", line 17, 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'...

 polles/test.py 17行目の self.asertIs() で False であるべきなのに True が返ってきたため、エラーが出力されました。

6.3.4. バグを修正する

 was_published_recently() を以下のように修正してバグを潰します。

#(省略)

    # 最近公開されたものか判別するメソッド (boolean)
    def was_published_recently(self):
        now = timezone.now()
        # return self.pub_date >= timezone.now() - datetime.timedelta(days=1)
        return now - datetime.timedelta(days=1) <= self.pub_date <= now
        # 公開日時 pub_date が1日前より未来、かつ、現在より過去であるかどうかを判定する。
        # 1日以内だったら true、そうでなければ false を返す。

#(省略)

 再度、テストを実行します。バグが修正されていれば以下のように表示されます。

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'...

6.3.5. より包括的なテスト

 ひとつバグを潰したら新たに別のバグが現れたなんてことはままあるので、先にテストを書いて歯止めをかけておきます。

 ここまでで未来の日時に対してのテストを作成したので、同様に過去と現在についてのテストも追加します。今後開発を続けていく中でコードが複雑化したり、ほかの部分と相互作用したりして挙動がおかしくなってしまった場合でも自動テストを実行することですぐに不具合が起きていることがわかるようになります。

#(省略)

    def test_was_published_recently_with_old_question(self):
        # was_published_recently() returns False for questions whose pub_date
        # is older than 1 day.
        # was_published_recently() は pub_date が1日以上前だった場合に 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):
        # was_published_recently() returns True for questions whose pub_date
        # is within the last day.
        # was_published_recently() は pub_date が
        # 最終日中(過去24時間未満)だった場合に 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)

#(省略)

6.4. ビューをテストする

6.4.1. ビューに対するテスト

 今度は「テストを書いてからコードを書く」パターン。

6.4.2. Django テストクライアント

 Client を使ってビューのテストをします。

% python manage.py shell
>>> # setup_test_environment でテスト環境を作成。
>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()

>>> from django.test import Client
>>> # create an instance of the client for our use
>>> # 使用するクライアントのインスタンスを作成します
>>> client = Client()

>>> # get a response from '/'
>>> # '/' からレスポンスを取得します
>>> response = client.get('/')
Not Found: /

>>> # we should expect a 404 from that address; if you instead see an
>>> # "Invalid HTTP_HOST header" error and a 400 response, you probably
>>> # omitted the setup_test_environment() call described earlier.
>>> # そのアドレスからは404が予期されます。代わりに "Invalid HTTP_HOST header" エラー
>>> # と400レスポンスが表示される場合は、前述のsetup_test_environment()の呼び出しを
>>> # 省略した可能性があります。
>>> response.status_code
404

>>> # on the other hand we should expect to find something at '/polls/'
>>> # we'll use 'reverse()' rather than a hardcoded URL
>>> # 一方、'/polls/'ではなにか表示されることが予想されます。
>>> # ハードコードされたURLではなく 'reverse()' を使用します。
>>> from django.urls import reverse
>>> response = client.get(reverse('polls:index'))
>>> response.status_code
200
>>> response.content
b'\n    <ul>\n    \n        <li><a href="/polls/1/">What's up?</a></li>\n    \n    </ul>\n'
>>> response.context['latest_question_list']
<QuerySet [<Question: What's up?>]>

6.4.3. ビューを改良する

 現在の投票のリストは未公開(未来の日付)の投票も表示される状態になっているので、これを修正します。

from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.views import generic
from django.utils import timezone #追加

from .models import Choice, Question

# インデックス ページ
class IndexView(generic.ListView):

    # (省略)

    # 表示する queryset を設定
    def get_queryset(self):
        # """Return the last five published questions."""
        # return Question.objects.order_by('-pub_date')[:5]

        """
        Return the last five published questions (not including those set to be
        published in the future).
        """
        return Question.objects.filter(
            pub_date__lte = timezone.now()
            # SQLでいうと SELECT ... WHERE pub_date <= timezone.now();
            # QuerySet API reference | Django ドキュメント | Django - Field lookups
            # https://docs.djangoproject.com/ja/3.2/ref/models/querysets/#field-lookups
        ).order_by('-pub_date')[:5]

6.4.4. 新しいビューをテストする

 ちゃんと修正されているか確認するテストを作成。

import datetime

from django.test import TestCase
from django.utils import timezone
from django.urls import reverse #追加

#(省略)

def create_question(question_text, days):
    """
    Create a question with the given `question_text` and published the
    given number of `days` offset to now (negative for questions published
    in the past, positive for questions that have yet to be published).
    与えられた `question_text` で質問を作成し、与えられた `days` のぶんだけ
    現在までオフセットして公開します(過去に公開された質問はマイナス、
    まだ公開されていない質問はプラス)。
    """
    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):
        """
        If no questions exist, an appropriate message is displayed.
        質問が存在しない場合は、適切なメッセージが表示されます。
        """
        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):
        """
        Questions with a pub_date in the past are displayed on the
        index page.
        過去の 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'],
            [question],
        )

    def test_future_question(self):
        """
        Questions with a pub_date in the future aren't displayed on
        the index page.
        未来の pub_date をもつ質問はインデックスページに表示されません。
        """
        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):
        """
        Even if both past and future questions exist, only past questions
        are displayed.
        過去と未来両方の質問が存在する場合でも、過去の質問のみが表示されます。
        """
        question = 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'],
            [question],
        )

    def test_two_past_questions(self):
        """
        The questions index page may display multiple questions.
        質問のインデックスページには複数の質問が表示されるはずです。
        """
        question1 = create_question(question_text="Past question 1.", days=-30)
        question2 = create_question(question_text="Past question 2.", days=-5)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            [question2, question1],
        )

6.4.5. DetailView のテスト

 詳細ページは質問IDを決め打ちすることでまだ非公開の質問が表示されるようになっています。これを修正するため DetailView に get_queryset() を追加します。

#(省略)

# 詳細 ページ
class DetailView(generic.DetailView):
    model = Question
    template_name = 'polls/detail.html'

    def get_queryset(self):
        """
        Excludes any questions that aren't published yet.
        まだ公開されていない質問を除外します
        """
        return Question.objects.filter(pub_date__lte=timezone.now())

#(省略)

 動作を確認するためのテストを追加します。

#(省略)

class QuestionDetailViewTests(TestCase):
    def test_future_question(self):
        """
        The detail view of a question with a pub_date in the future
        returns a 404 not found.
        未来のpub_dateを持つ質問の詳細ビューは 404 not found を返します。
        """
        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):
        """
        The detail view of a question with a pub_date in the past
        displays the question's text.
        過去の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)

6.4.6. さらなるテストについて考える

 現在の投稿アプリには以下のような修正点が存在します。

  • 結果ビューも(修正前の詳細ビューと同様)質問IDを決め打ちすると未来の日付の質問が表示されるようになっている。
  • choices を持たない Questions が公開可能になっている。
  • 管理者は未公開状態の質問を確認できるようにしたほうがいいかも。

6.5. テストにおいて、多いことはいいことだ

 コード書いてくとそれに伴ってテストもどんどん増えてくけど気にすんな的な。

6.6. さらなるテスト

 テストのためのツールはほかにもいろいろあるよ的な。

ページ: 1 2 3 4 5 6 7 8

関連する記事