Skip to content

5. テスト#

自動テストの導入#

自動テストとは#

テストは、コードの動作をチェックするルーチン。 テストは「ある特定のモデルのメソッドは期待通りの値を返すか?」、また「ソフトウェア全体の動作に対して」など異なるレベルで実行される。 自動テストが他と異なる点は、テスト作業がシステムによって実行されること。 一度テストセットを作成すると、それからはアプリに変更を加えるたびに、意図した通りにコードが動作するか確認できる

なぜテストを作成するのか?#

Python や Django を学ぶのに手一杯で、さらに別のことを学ぶのは大変で不必要なことと思うが、それを上回るメリットは次のとおり。

テストは時間の節約#

高機能なアプリケーションでは、コンポーネント間の複雑な相互作用が数多くあるかもしれない。 それらのコンポーネントのどれかを変更した場合、予想外の振る舞いをアプリケーションがする可能性がある。 '正しく動いているらしい' メソッドを使う場合、プログラムを壊していないことを確かめるためには、様々なテストデータを用いてプログラムを走らせる必要があるが、これは、良い方法ではない。 自動テストを導入することによってプログラムが正しく動くことの確認を一瞬で終わらせることができ、またテストはプログラムのどこで予期せぬ動作が起きたかを見極めるのに役立つ。

問題点の検出・防止#

テストを単に開発の負の面と考えることは誤り。 テストなくしては、アプリケーションの目的や意図した動作というものが曖昧になってしまうことがある。 何か間違ったことをしてしまった時には、自分自身では間違っていると気づかなかった場合でさえ、間違いが起きた場所にスポットライトを当てる。

信用#

テストがないというそれだけの理由で、多くの開発者は見ることさえしてくれない。テストのないソフトは信用されない。

共同作業を行う上で役に立つ#

複雑なアプリケーションはチームでメンテナンスされるようになるもの テストは、自分が書いたコードを他人がうっかり壊してしまうことから守ってくれる (その逆も然り)。 プログラマとして生きてゆくつもりなら、良いテストはマスト。

基本的なテスト方針#

テストを書くためのアプローチには、さまざまなものがある。 その1つに、「テスト駆動開発」の原則がある。これは、実際にコードを書く前にテストを書く、という原則。 この原則は直感に反するように感じるが、問題をきちんと言葉にしてから、その問題を解決するためのコードを書くという考え。 テストの初心者の多くは、先にコードを書いてから、その後でテストが必要だと考えるもの。 もっと早くからいくつかテストを書いておいた方が良いが、テストを始めるのに遅すぎるということはない。

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

初めてのテスト作成#

バグを見つけたとき#

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

未来の日付の質問のメソッドをチェックするには、 shell を使用してバグを確認してみる。

(venv) C:\Users\omron\Documents\20_Python\dev\Django\mysite> python manage.py shell
Python 3.9.0 (tags/v3.9.0:9cf6752, Oct  5 2020, 15:34:40) [MSC v.1927 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> import datetime
>>> from django.utils import timezone
>>> from polls.models import Question
>>> future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30))
>>> future_question.was_published_recently()
True
>>>

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

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

問題をテストするために先ほど shell で実行した内容を、自動テストでしたいところ。 そこで、今やったことを自動テストに変換してみる。

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

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

polls/tests.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.
        """
        time = timezone.now() + datetime.timedelta(days=30)
        future_question = Question(pub_date=time)
        self.assertIs(future_question.was_published_recently(), False)

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

テストの実行#

ターミナルから、python manage.py test pollsでテストが実行できる。

(venv) C:\Users\omron\Documents\20_Python\dev\Django\mysite>  python manage.py test polls
Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_was_published_recently_with_future_question (polls.test.QuestionModelTests)
was_published_recently() returns False for questions whose pub_date
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\Users\omron\Documents\20_Python\dev\Django\mysite\polls\test.py", line 18, 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.007s

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

上記の動作は以下の通り。

  1. manage.py test polls は、polls アプリケーション内にあるテストを探す。
  2. django.test.TestCase クラスのサブクラスを発見す
  3. テストのための特別なデータベースを作成
  4. テスト用のメソッドとして、test で始まるメソッドを探す。
  5. test_was_published_recently_with_future_question の中で、pub_date フィールドに今日から30日後の日付を持つ Question インスタンスが作成される
  6. 最後に、 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

もう一度テストを実行してみる。

(venv) C:\Users\omron\Documents\20_Python\dev\Django\mysite>  python manage.py test polls
Found 1 test(s).
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'...
(venv) C:\Users\omron\Documents\20_Python\dev\Django\mysite> 

コード内のバグを直したので、テストは無事にパスされたことを確認できる。

より包括的なテスト#

この段階で、was_published_recently() メソッドをさらにピン留めしておける。(一つのバグを直したことでほかのバグを作り出すことを防ぐため) このメソッドの振る舞いをより包括的にテストするために、同じクラスにさらに2つのテストを追加する。

polls/tests.py
def test_was_published_recently_with_old_question(self):
    """
    was_published_recently() returns False for questions whose pub_date
    is older than 1 day.
    """
    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.
    """
    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)

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

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

ビューをテスト#

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

ビューに対するテスト#

上でバグを修正した時には、初めにテストを書いてからコードを修正した。 実は、テスト駆動開発の簡単な例であった。しかし、テストとコードを書く順番はどちらでも構わない。 初めのテストでは、コード内部の細かい動作に焦点を当てたが、このテストでは、ユーザが Web ブラウザを通して経験する動作をチェックする。 まずは何かを修正する前に、使用できるツールについて見ていく

Django テストクライアント#

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

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

venv) C:\Users\omron\Documents\20_Python\dev\Django\mysite> python manage.py shell
Python 3.9.0 (tags/v3.9.0:9cf6752, Oct  5 2020, 15:34:40) [MSC v.1927 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()

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

>>> from django.test import Client
>>> client = Client()

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

>>> response = client.get('/')
Not Found: /
>>> 
>>> response.status_code
404
>>>
>>> from django.urls import reverse
>>> response = client.get(reverse('polls:index'))
>>> response.status_code
200
>>>
>>> response.content
b'\n    <ul>\n    \n        \n        <li><a href="/polls/1/">What&#x27;s up?</a></li>\n    \n    </ul>\n'
>>>
>>> response.context['latest_question_list']
<QuerySet [<Question: What's up?>]>

ビューを改良#

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

Tutorial4 では、以下のような 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_date が timezone.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):
    """
    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).
    """
    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.
        """
        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.
        """
        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],
        )

これらのコードを詳しく見ていく。

create_questionはquestion のショートカット関数。この関数によって、 question 作成処理のコード重複をなくしている。

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

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

以下のテストメソッドも同様。

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

追加したテストを実行した結果、PASSしていることが分かる

(venv) C:\Users\omron\Documents\20_Python\dev\Django\mysite>  python manage.py test polls
Found 3 test(s).
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'...

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())

そして、 pub_date が過去のものである Question は表示でき、未来のものである pub_date は表示できないことを確認するために、いくつかのテストを追加しなければならない:

polls/views.py
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.
        """
        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.
        """
        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 におけるテスト - テストを書いて実行する - テストツール - Advanced testing topics