• 1.9
  • dev
  • Documentation version: 1.10

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

このチュートリアルは チュートリアル 4 の続きです。Web 投票アプリケーションが完成したので、今度は自動テストを作ってみましょう。

自動テストの導入

自動テストとは何ですか?

テストとは、コードの動作を確認する単純なプログラムです。

テストは異なるレベルで実行されます。あるテストは、小さな機能に対して行われるもの (ある特定のモデルのメソッドは期待通りの値を返すか?) かもしれませんし、別のテストは、ソフトウェア全体の動作に対して行われるもの (サイト上でのユーザの一連の入力に対して、期待通りの結果が表示されるか?) かもしれません。こうしたテストは、前に チュートリアル その2shell を用いてメソッドの動作を確かめたことや、実際にアプリケーションを実行して値を入力して結果がどうなるのかを確かめるといったことと、何も違いはありません。

自動 テストが他と異なる点は、テスト作業がシステムによって実行されることです。一度テストセットを作成すると、それからはアプリに変更を加えるたびに、あなたの意図した通りにコードが動作するか確認できます。手動でテストする時間がかかることはありません。

なぜテストを作成せねばならないのか

どうしてテストを作るのか?また、なぜ今なのか?

これまで学んだ Python/Django の知識に満足し、さらに別のことを学ぶのは大変で不必要なことだと思われるかもしれません。だって投票アプリケーションはきちんと動いているし、わざわざ自動テストを導入したところでアプリケーションがより良くなるわけではないのだから。もし Django プログラミングを学ぶことの全ての目的がこの投票アプリケーションを作ることであるのならば確かに自動テストの導入は必要ないと思います。しかし、そうではないのならば自動テストについて学ぶことは役に立つことでしょう。

テストはあなたの時間を節約します

ある一定の基準まで、’動くであろうことを確認すること’が十分なテストでしょう。高機能なアプリケーションでは、コンポーネント間で複雑な連携が数多くあるかもしれません。

プログラムの変更によって予想だにしない箇所の挙動が変わってしまう可能性があります。それを確かめるためには、様々なテストデータを用いてプログラムを走らせて ‘正しく動いていそう’ であることを確認する必要があります - これは効率がよくありません。

自動テストを導入することによってプログラムが正しく動くことの確認を一瞬で終わらせることができ、またテストはプログラムのどこで予期せぬ動作が起きたかを見極めるのに役立つことでしょう。

テストを書くという行為は、特にプログラムが適切に動くと分かっているときには、生産的でも創造的でもないつまらないことのように思われるかもしれません。

しかしテストを書くことは、何時間もかけてアプリケーションの動作を確認したり、新しく発生した問題の原因を探したりすることよりもずっとやりがいのあることなのです。

またテストは問題点を検出するのみならず、問題が発生するのを防ぐこともできます。

テストを単に開発の負な面と考えることは誤りです。

テストなくしては、アプリケーションの目的や意図した動作というものが曖昧になってしまうことがあります。自分自身で書いたコードであっても、時にはそのコードがすることを正確に理解するのに時間がかかってしまうことがあります。

テストはこの状況を大きく変え、いわばコードを内側から照らし出してくれます。そして、何か間違ったことをしてしまった時には、自分自身では間違っていると気づかなかった場合でさえ、間違いが起きた場所にスポットライトを当ててくれるのです。

テストは、コードをより魅力的にします

たとえあなたが輝かしいソフトウェアを作ったとしても、テストがないというそれだけの理由で、多くの開発者は見ることさえしてくれないでしょう。テストのないソフトは信用されないのです。Django を開発した Jacob Kaplan-Moss は次の言葉を残しています。「テストのないコードは、デザインとして壊れている。」

あなたのソフトウェアを他の開発者が真剣に見てもらうというのも、テストを書くべきもう一つの理由です。

テストを書くことはチームで共同作業を行う上で役に立ちます。

これまでの点は、1人の開発者でアプリケーションをメンテナンスしているという観点から書きました。しかし、複雑なアプリケーションはチームでメンテナンスされるようになるものです。テストは、あなたが書いたコードを他人がうっかり壊してしまうことから守ってくれます (そして、他の人が書いたコードをあなたが壊してしまうことからも)。Django のプログラマとして行きてゆくつもりなら、良いテストを絶対に書かなければなりません!

基本的なテスト方針

テストを書くためのアプローチには、さまざまなものがあります。

プログラマの中には、「テスト駆動開発」の原則に従っている人がいます。これは、実際にコードを書く前にテストを書く、という原則です。この原則は直感に反するように感じるかもしれませんが、実際には多くの人がどんなことでも普通にしていることに似ています。つまり、問題をきちんと言葉にしてから、その問題を解決するためのコードを書く、ということです。テスト駆動開発は、ここで言う問題を単に Python のテストケースとして形式化しただけのことです。

テストの初心者の多くは、先にコードを書いてから、その後でテストが必要だと考えるものです。おそらく、おそらく早くからいくつかテストを書いておいた方が良いですが、テストを始めるのに遅すぎるということはありません。

どこからテストを始めるべき場所を見つけるのが難しいこともあります。もしすでに数千行の Python コードがあったとしたら、テストすべき場所を選ぶのは簡単ではないかもしれません。そのような場合には、次に新しい機能やバグの修正を行う時に、最初のテストを書いてみると役に立つでしょう。

それでは早速始めてみましょう。

初めてのテスト作成

バグを見つけたとき

運よく、 polls のアプリケーションにはすぐに修正可能な小さなバグがありました。Question.was_published_recently() のメソッドは Question が昨日以降に作成された場合に True を返すのですが(適切な動作)、 Questionpub_date が未来の日付になっている場合にも``True``を返してしまいます(不適切な動作)。

このバグが本当に存在するのかを確かめるために、 shell:: から未来の日付の Question を作成し、メソッドの結果を見てみましょう。

>>> import datetime
>>> from django.utils import timezone
>>> from polls.models import Question
>>> # create a Question instance with pub_date 30 days in the future
>>> future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30))
>>> # was it published recently?
>>> future_question.was_published_recently()
True

未来の日付は ‘最近’ ではないため、この結果は明らかに間違っています。

バクをあらわにするためテストを作成します。

問題をテストするために shell でたった今したことこそ、自動テストでしたいことです。そこで、それを自動テストの中に取り込みましょう。

アプリケーションのテストを書く場所は、慣習として、アプリケーションの tests.py ファイル内ということになっています。テストシステムが test で始まる名前のファイルの中から、自動的にテストを見つけてくれます。

polls アプリケーションの tests.py ファイルに次のコードを書きます。

polls/tests.py
import datetime

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

from .models import Question


class QuestionMethodTests(TestCase):

    def test_was_published_recently_with_future_question(self):
        """
        was_published_recently() should return False for questions whose
        pub_date is in the future.
        """
        time = timezone.now() + datetime.timedelta(days=30)
        future_question = Question(pub_date=time)
        self.assertIs(future_question.was_published_recently(), False)

ここではまず、django.test.TestCase を継承したサブクラスを作り、未来の日付の pub_date を持つ Question のインスタンスを作っています。それから、was_published_recently() の出力をチェックしています。これは False になるはずです。

テストの実行

ターミナルから、次のコマンドでテストが実行できます。

$ python manage.py test polls

すると、次のような結果を得るでしょう。

Creating test database for alias 'default'...
F
======================================================================
FAIL: test_was_published_recently_with_future_question (polls.tests.QuestionMethodTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/mysite/polls/tests.py", line 16, 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.001s

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

ここでは以下のようなことが起こりました。

  • python manage.py test pollspolls アプリケーションからテストを探します

  • django.test.TestCase クラスのサブクラスを発見します

  • テストのための特別なデータベースを作成します

  • テスト用のメソッドとして、test で始まるメソッドを探します

  • test_was_published_recently_with_future_question の中で、pub_date フィールドに今日から30日後の日付を持つ Question インスタンスが作成されます

  • そして最後に、 assertIs() メソッドを使うことで、本当に返してほしいのは False だったにもかかわらず、 was_published_recently()True を返していることを発見します

テストは私たちにテストの失敗を教えてくれるだけでなく、失敗が起こったコードの行数まで教えてくれています。

バグを修正する

私たちはすでに問題の原因を知っています。それは、Question.was_published_recently()pub_date が未来の日付だった場合には False を返さなければならない、ということです。models.py にあるメソッドを修正して、日付が過去だった場合にのみ True を返すようにしましょう。

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

そして、もう一度テストを実行します。

Creating test database for alias 'default'...
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

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

バグを発見した後、私たちはそのバグをあぶり出してくれるようなテストを書いて、コード内のバグを直したので、テストは無事にパスされました。

このアプリケーションでは将来、たくさんの他のバグが生じるかもしれませんが、このバグがうっかり入ってしまうことは二度とありません。単にテストを実行するだけで、すぐに警告を受けられるからです。このアプリケーションの小さな部分をピンで留める、永遠に安全にすることができたと考えられます。

より包括的なテスト

私たちがここにいる間、was_published_recently() メソッドはさらにピンで留めることができます。実際、it would be positively embarrassing if in fixing one bug we had introduced another.

このメソッドの振る舞いをより包括的にテストするために、同じクラスにさらに2つのテストを追加しましょう。

polls/tests.py
def test_was_published_recently_with_old_question(self):
    """
    was_published_recently() should return False for questions whose
    pub_date is older than 1 day.
    """
    time = timezone.now() - datetime.timedelta(days=30)
    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() should return True for questions whose
    pub_date is within the last day.
    """
    time = timezone.now() - datetime.timedelta(hours=1)
    recent_question = Question(pub_date=time)
    self.assertIs(recent_question.was_published_recently(), True)

これで、Question.was_published_recently() が過去、現在、そして未来の質問に対して意味のある値を返すことを確認する3つのテストが揃いました。

繰り返しになりますが、polls は簡単なアプリケーションであっても、将来このアプリケーションが他のどんなコードと関係するようになっても、複雑さは増していきます。メソッドに対してテストを書いたおかげで、今ではそのメソッドが期待したとおりに動作することを、ある程度保証でききるようになったのです。

ビューをテストする

この投票アプリケーションは、まだ質問をちゃんと見分けることができません。pub_date フィールドが未来の日付になっている質問を含め、どんな質問でも公開してしまいます。この点を改善するべきでしょう。pub_date を未来に設定するということは、その Question がその日付になった時に公開され、それまでは表示されないことを意味するはずです。

ビューに対するテスト

上でバグを修正した時には、初めにテストを書いてからコードを修正しました。実は、テスト駆動開発の簡単な例だったわけです。しかし、テストとコードを書く順番はどちらでも構いません。

初めのテストでは、コード内部の細かい動作に焦点を当てましたが、このテストでは、ユーザが Web ブラウザを通して経験する動作をチェックしましょう。

初めに何かを修正する前に、使用できるツールについて見ておきましょう。

Django テストクライアント

Django は、ビューレベルでのユーザとのインタラクションをシミュレートすることができる Client を用意しています。これを tests.py の中や shell でも使うことができます。

もう一度 shell からはじめましょう。ここでテストクライアントを使う場合には、tests.py では必要がない2つの準備が必要になります。まず最初にしなければならないのは、shell の上でテスト環境をセットアップすることです。

>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()

setup_test_environment() installs a template renderer which will allow us to examine some additional attributes on responses such as response.context that otherwise wouldn’t be available. Note that this method does not setup a test database, so the following will be run against the existing database and the output may differ slightly depending on what questions you already created. You might get unexpected results if your TIME_ZONE in settings.py isn’t correct. If you don’t remember setting it earlier, check it before continuing.

つぎに、テストクライアントのクラスをインポートする必要があります (後ほどの tests.py の中では、”class”django.test.TestCase クラス自体がクライアントを持っているため、インポートは不要です)。

>>> from django.test import Client
>>> # create an instance of the client for our use
>>> client = Client()

さて、これでクライアントに仕事を頼む準備ができました。

>>> # get a response from '/'
>>> response = client.get('/')
>>> # we should expect a 404 from that address
>>> 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
>>> 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&#39;s up?</a></li>\n    \n    </ul>\n\n'
>>> # If the following doesn't work, you probably omitted the call to
>>> # setup_test_environment() described above
>>> response.context['latest_question_list']
<QuerySet [<Question: What's up?>]>

ビューを改良する

現在の投票のリストには、まだ公開されていない (つまり``pub_date`` の日付が未来になっている) 投票が表示される状態になっています。これを直しましょう。

Tutorial 4 では、以下のような ListView: をベースにしたクラスベースビューを導入しました。

polls/views.py
class IndexView(generic.ListView):
    template_name = 'polls/index.html'
    context_object_name = 'latest_question_list'

    def get_queryset(self):
        """Return the last five published questions."""
        return Question.objects.order_by('-pub_date')[:5]

get_queryset() メソッドを修正して、日付を``timezone.now()`` と比較して確認できるようにする必要があります。まず、インポート文を追加します。

polls/views.py
from django.utils import timezone

そして、次のように get_queryset メソッドを修正します。

polls/views.py
def get_queryset(self):
    """
    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()
    ).order_by('-pub_date')[:5]

Question.objects.filter(pub_date__lte=timezone.now()) は、pub_datetimezone.now 以前の Question を含んだクエリセットを返します。

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

それでは、これで期待通りの満足のいく動作をしてくれるかどうか確かめましょう。まず、runserver を実行して、ブラウザでサイトを読み込みます。過去と未来、それぞれの日付を持つ Question を作成し、すでに公開されている質問だけがリストに表示されるかどうかを確認します。あなたはまさか、この通りにちゃんと動作しているか、プロジェクトにわずかでも変更を加えるたびに毎回手動で 確認したいなどとは思わないですよね? それなら、今回も上の shell のセッションに基づいてテストを作りましょう。

まず、polls/tests.py に次の行を追加します。

polls/tests.py
from django.urls import reverse

そして、question を簡単に作れるようにするショートカット関数と、新しいテストクラスを作ります。

polls/tests.py
def create_question(question_text, days):
    """
    Creates 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).
    """
    time = timezone.now() + datetime.timedelta(days=days)
    return Question.objects.create(question_text=question_text, pub_date=time)


class QuestionViewTests(TestCase):
    def test_index_view_with_no_questions(self):
        """
        If no questions exist, an appropriate message should be 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_index_view_with_a_past_question(self):
        """
        Questions with a pub_date in the past should be displayed on the
        index page.
        """
        create_question(question_text="Past question.", days=-30)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['<Question: Past question.>']
        )

    def test_index_view_with_a_future_question(self):
        """
        Questions with a pub_date in the future should not be displayed on
        the index page.
        """
        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_index_view_with_future_question_and_past_question(self):
        """
        Even if both past and future questions exist, only past questions
        should be displayed.
        """
        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: Past question.>']
        )

    def test_index_view_with_two_past_questions(self):
        """
        The questions index page may display multiple questions.
        """
        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'],
            ['<Question: Past question 2.>', '<Question: Past question 1.>']
        )

これらのコードを詳しく見ていきましょう。

まず、question のショートカット関数 create_question です。この関数は、処理の中の question を作成する繰り返しを取り除いてくれています。

test_index_view_with_no_questions は question を1つも作りませんが、”No polls are available.” というメッセージが表示されていることをチェックし、latest_question_list が空になっているか確認しています。django.test.TestCase クラスが追加のアサーション (assertion) メソッドを提供していることに注意してください。このチュートリアルでは、assertContains()assertQuerysetEqual() を使用します。

test_index_view_with_a_past_question では、question を作成し、その question がリストに現れるかどうかを検証しています。

test_index_view_with_a_future_question では、pub_date が未来の日付の質問を作っています。データベースは各テストメソッドごとにリセットされるので、この時にはデータベースには最初の質問は残っていません。そのため、index ページにはquestion は1つもありません。

以下のテストメソッドも同様です。実際のところ、私たちはテストを用いて、管理者の入力とサイトでのユーザの体験についてのストーリを語り、システムの各状態とそこでの新しい変化のそれぞれに対して、期待通りの結果が公開されているかどうかをチェックしているのです。

```DetailView``のテスト

とても上手くいっていますね。しかし、未来の質問は index に表示されないものの、正しいURL を知っていたり推測したりしたユーザは、まだページに到達できてしまいます。そのため、同じような制約を DetailView にも追加する必要があります。

polls/views.py
class DetailView(generic.DetailView):
    ...
    def get_queryset(self):
        """
        Excludes any questions that aren't published yet.
        """
        return Question.objects.filter(pub_date__lte=timezone.now())

そしてもちろん、2つのテストを追加します。pub_date が過去の Question が表示されることを確認するテストと、pub_date が未来の Question が表示されないことを確認するテストです。

polls/tests.py
class QuestionIndexDetailTests(TestCase):
    def test_detail_view_with_a_future_question(self):
        """
        The detail view of a question with a pub_date in the future should
        return a 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_detail_view_with_a_past_question(self):
        """
        The detail view of a question with a pub_date in the past should
        display the question's text.
        """
        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)

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

ResultsView にも同じように get_queryset メソッドを追加して、新しいテストクラスも作らなければならないようです。しかしこれは、今作ったばかりのものとそっくりになるでしょう。実際、テストは重複だらけになるはずです。

テストを追加することによって、同じように他の方法でアプリを改善できるでしょう。例えば、Choices を一つも持たない馬鹿げた Questions が公開可能になっています。このような Questions を排除するようビューでチェックできます。 Choices がない Question を作成し、それが公開されないことをテストし、同じようにして、Choices がある Question を作成し、それが公開 される ことをテストすることになるでしょう。

もしかすると管理者としてログインしているユーザーは、一般の訪問者と違い、 まだ公開されていない Questions を見ることができるようにした方がいいかもしれません。また繰り返しになりますが、この問題を解決するためにソフトウェアにどんなコードが追加されべきであったとしても、そのコードにはテストが伴うべきです。テストを先に書いてからそのテストを通るコードを書くのか、あるいはコードの中で先にロジックを試してからテストを書いてそれを検証するのか、いずれにしてもです。

ある時点で、書いたテストが限界に達しているように見え、テストが膨らみすぎてコードが苦しくなってしまうのではないかという疑問が浮かんでくるでしょう。こうなって場合にはどうすれば良いのでしょうか?

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

私たちのテストは、手がつけられないほど成長してしまっているように見えるかもしれません。この割合で行けば、テストコードがアプリケーションのコードよりもすぐに大きくなってしまうでしょう。そして繰り返しは、残りの私たちのコードのエレガントな簡潔さに比べて、美しくありません。

構いません。 テストコードを大きくしましょう。たいていあなたはテストを一回書いてそのことを忘れます。プログラムを開発し終えるまで便利な関数を使いそれを続けましょう。

時には、テストにアップデートが必要になることがあります。Choices を持つ Questions だけを公開するよう、ビューを修正したときのことを思い出してください。この後、既存のテストの多くは失敗します。この失敗は、どのテストが、最新の状態に対応するために修正する必要があるのか をわたしたちに教えてくれているのです。テストはこの意味でも、テスト自身をチェックするのに役に立っています。

最悪の場合、開発を続けていくにつれて、あるテストが今では冗長なものになっていることに気づいてしまうかもしれません。これも問題ではありません。テストにおいては、冗長であることは 良い ことなのです。

テストを意味のあるものとなるように整えている限り、手に負えないものになることはありません。経験上、次のルールを守るようにすれば問題ありません。

  • モデルやビューごとに TestClass を分割する

  • テストしたい条件の集まりのそれぞれに対して、異なるテストメソッドを作る

  • テストメソッドの名前は、その機能を説明するようなものにする

さらなるテスト

このチュートリアルでは、テストの基本の一部を紹介しました。この他にもあなたにできることはまだまだたくさんありますし、いろいろと賢いことを実現するに使えるとても便利なツールが数多く用意されています。

たとえば、ここでのテストでは、モデルの内部ロジックと、ビューの情報の公開の仕方をカバーしましたが、ブラウザが HTML を実際にどのようにレンダリングのするのかをテストする Selenium のような “in-browser” のフレームワークを使うこともできます。これらのツールは、Django が生成したコードの振る舞いだけでなく、たとえば、 JavaScript の振る舞いも確認できます。テストがブラウザを起動してサイトとインタラクションしているのを見るのはとても面白いですよ。まるで本物の人間がブラウザを操作しているかのように見えるんです! Django には、Selenium のようなツールとの連携を容易にしてくれる LiveServerTestCase が用意されています。

複雑なアプリケーションを開発する時には、継続的インテグレーション (continuous integration) のために、コミットの度に自動的にテストを実行したいくなるかもしれませんね。継続的インテグレーションを行れば、品質管理それ自体が、少なくとも部分的には自動化できます。

アプリケーションのテストされていない部分を発見するには、コードカバレッジをチェックするのが良いやり方です。これはまた、脆弱なコードや使用されていないデッドコードの発見にも役に立ちます。テストできないコード片がある場合、ふつうは、そのコードはリファクタリングするか削除する必要があることを意味します。カバレッジはデッドコードの識別に役に立つでしょう。詳細は Integration with coverage.py を参照してください。

Django におけるテスト には、テストに関する包括的な情報がまとめられています。

次は何をしましょうか?

テストの詳細は、Djagnoにおけるテスト を参照してください。

テストやビューを使いこなせるようになったら、 チュートリアルその6 に進んで、静的ファイルの管理について学びましょう。

Back to Top