自動テストをいくつか作成する

DjangoDjangoBeginner
オンラインで実践に進む

💡 このチュートリアルは英語版からAIによって翻訳されています。原文を確認するには、 ここをクリックしてください

はじめに

このチュートリアルは、「フォーム処理とコードの削減」が終わるところから始まります。私たちはウェブ投票アプリケーションを構築しました。そして今、それに対する自動テストを作成します。

自動テストの紹介

自動テストとは?

テストは、コードの動作を確認するためのルーチンです。

テストはさまざまなレベルで行われます。一部のテストは非常に細かい部分に適用されます(特定のモデルメソッドが期待通りの値を返すか?)一方、他のテストはソフトウェアの全体的な動作を調べます(サイト上の一連のユーザー入力が期待される結果を生成するか?)。これは、「データベースのセットアップ」で行ったテストと同じではありません。そこでは、shell を使ってメソッドの動作を調べたり、アプリケーションを実行してデータを入力してその動作を確認したりしていました。

自動テストの違いは、テスト作業がシステムによって行われるところにあります。一度テストセットを作成すると、アプリケーションを変更するたびに、コードが元の意図通りに動作しているかどうかを確認できます。手作業での時間がかかるテストを行う必要はありません。

なぜテストを作成する必要があるのか

では、なぜテストを作成する必要があり、なぜ今すぐか?

Python / Django を学ぶだけでも十分忙しいと感じるかもしれません。さらに学ぶことやすることがあると、圧倒的で不必要に感じるかもしれません。結局のところ、私たちの投票アプリケーションは今十分に機能しています。自動テストを作成する手間をかけても、それがもっと良く機能することはありません。もし投票アプリケーションの作成があなたが最後に行う Django プログラミングであるなら、確かに、自動テストを作成する方法を知る必要はありません。しかし、そうでない場合、今が学ぶのに最適な時期です。

テストは時間を節約します

ある程度までは、「動作しているように見えることを確認する」ことが十分なテストになります。より洗練されたアプリケーションでは、コンポーネント間に数十の複雑な相互作用があるかもしれません。

それらのコンポーネントのいずれかが変更されると、アプリケーションの動作に予期しない結果が生じる可能性があります。「まだ動作しているように見える」ことを確認するには、テストデータの 20 通りの異なるバリエーションでコードの機能を実行して、何かが壊れていないことを確認する必要があります。これは時間の無駄です。

自動テストがこれを数秒で行ってくれる場合、特にそうです。何かが間違っている場合、テストは予期しない動作の原因となっているコードの特定にも役立ちます。

時々、生産的で創造的なプログラミング作業から離れて、テストを書く退屈で面白くない作業に直面するのは苦労に感じるかもしれません。特にコードが正常に動作していることを知っているときです。

しかし、テストを書く作業は、手動でアプリケーションをテストしたり、新しく導入された問題の原因を特定したりするのに何時間も費やすよりもはるかに充実感があります。

テストは問題を特定するだけでなく、防止します

テストを単に開発の否定的な側面と考えるのは間違いです。

テストがない場合、アプリケーションの目的や意図された動作はかなり不明瞭になります。自分自身のコードであっても、時々、それが何をしているのかを調べるために中を探り回ることになります。

テストはそれを変えます。それは内部からコードを照らしてくれます。何かが間違っているとき、それは間違っている部分に光を当てます。それが間違っていることに気づいていなくてもです。

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

素晴らしいソフトウェアを作成したかもしれませんが、多くの開発者がテストがないために見てもらえないことに気づくでしょう。テストがなければ、彼らはそれを信頼しません。Django の元の開発者の 1 人である Jacob Kaplan-Moss は、「テストのないコードは、設計上、破損している」と言っています。

他の開発者があなたのソフトウェアを真剣に受け取る前にテストを見たいと望むのは、あなたがテストを書き始めるもう 1 つの理由です。

テストはチームが一緒に働くのを助けます

前述のポイントは、単独の開発者がアプリケーションを保守するという観点から書かれています。複雑なアプリケーションはチームによって保守されます。テストは、同僚が不注意にあなたのコードを破壊しないこと(そしてあなたが彼らのコードを知らずに破壊しないこと)を保証します。Django プログラマーとして生きていくためには、テストを書くことが上手でなければなりません!

基本的なテスト戦略

テストを書く方法はたくさんあります。

一部のプログラマーは、「テスト駆動開発(Test-driven development)」と呼ばれる規律に従っています。彼らは実際、コードを書く前にテストを書きます。これは直感的ではないように見えるかもしれませんが、実際にはほとんどの人がよくすることと似ています。彼らは問題を説明し、その後、それを解決するためのコードを作成します。テスト駆動開発は、Python のテストケースで問題を形式化します。

より一般的には、テストの初心者はいくつかのコードを作成し、後でそれにいくつかのテストが必要だと判断します。もしも早めにいくつかのテストを書いておけば良かったかもしれませんが、始めるのは遅すぎることはありません。

時々、テストを書き始める場所がわからないことがあります。数千行の Python を書いた後は、テストするものを選ぶのが簡単ではないかもしれません。そのような場合、新機能を追加したりバグを修正したりする際に、次に変更を加えるときに最初のテストを書くのが効果的です。

では、すぐにそれを行いましょう。

最初のテストを書く

バグを特定する

幸いなことに、polls アプリケーションにはすぐに修正する必要のある小さなバグがあります。Question.was_published_recently() メソッドは、Question が過去 1 日以内に公開された場合(これは正しい)に True を返しますが、Questionpub_date フィールドが未来の場合も True を返します(これは明らかに正しくありません)。

未来の日付を持つ質問に対するメソッドを shell を使って確認することで、このバグを確認しましょう:

cd ~/project/mysite
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

未来のものは「最近」ではないので、これは明らかに間違っています。

バグを明らかにするためのテストを作成する

問題をテストするために shell で先ほど行ったことは、自動テストでもまったく同じことができます。では、それを自動テストに変換しましょう。

アプリケーションのテストの一般的な場所は、アプリケーションの tests.py ファイルです。テストシステムは、test で始まる名前の任意のファイル内のテストを自動的に見つけます。

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() は、pub_date が未来の質問に対して False を返します。
        """
        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

すると、以下のような出力が表示されます:

[object Object]

異なるエラーが表示されますか?

ここで NameError が表示される場合は、Part 2 <tutorial02-import-timezone> の手順を抜けている可能性があります。そこでは、polls/models.pydatetimetimezone のインポートを追加しました。そのセクションのインポートをコピーして、再度テストを実行してみてください。

以下のようになります:

  • manage.py test pollspolls アプリケーション内のテストを探します。
  • django.test.TestCase クラスのサブクラスを見つけます。
  • テスト用に特別なデータベースを作成します。
  • テストメソッド(test で始まる名前のもの)を探します。
  • test_was_published_recently_with_future_question では、pub_date フィールドが未来 30 日後の Question インスタンスを作成します。
  • assertIs() メソッドを使って、was_published_recently()True を返すことを発見しました。ただし、私たちは False を返すことを望んでいました。

テストは、どのテストが失敗したか、さらには失敗が発生した行までを通知します。

バグを修正する

既に問題が何であるかはわかっています。Question.was_published_recently() は、pub_date が未来の場合には 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'...

バグを特定した後、それを明らかにするテストを書き、コード内のバグを修正してテストが通過するようにしました。

これから、アプリケーションで他の多くのことがうまくいかなくなるかもしれませんが、このバグを無注意に再現することはないことが確信できます。なぜなら、テストを実行するとすぐに警告が表示されるからです。このアプリケーションのこの小さな部分は、永遠に安全に固定された状態になっていると考えることができます。

より包括的なテスト

ここで、was_published_recently() メソッドをさらに固定することができます。実際、1 つのバグを修正した際に別のバグを導入してしまったら恥ずかしいことになります。

同じクラスにさらに 2 つのテストメソッドを追加して、メソッドの動作をより包括的にテストします:

def test_was_published_recently_with_old_question(self):
    """
    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() は、pub_date が過去 1 日以内の質問に対して 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)

そして今、Question.was_published_recently() が過去、最近、未来の質問に対して適切な値を返すことを確認する 3 つのテストがあります。

再び申しますが、polls は最小限のアプリケーションですが、これからどんなに複雑になっても、他のどのコードと相互作用しても、テストを書いたメソッドが期待通りに動作することが保証されています。

ビューをテストする

polls アプリケーションは比較的制限が少なく、未来の pub_date フィールドを持つ質問を含む、任意の質問を公開します。これを改善する必要があります。未来の日付を設定することは、その時点で質問が公開されることを意味しますが、それまでは非表示になります。

ビューのテスト

上記のバグを修正する際、私たちはまずテストを書き、その後修正するコードを書きました。実際、これはテスト駆動開発の例でしたが、作業の順序は実際にはあまり重要ではありません。

最初のテストでは、コードの内部動作に焦点を当てました。このテストでは、ユーザーがウェブブラウザを通じて経験するような動作を確認したいと思います。

何かを修正しようとする前に、利用可能なツールを見てみましょう。

Django テストクライアント

Django は、ユーザーがビューレベルでコードと対話することをシミュレートするためのテスト用の ~django.test.Client を提供しています。これを tests.py で、あるいは shell でも使用できます。

ここでは再び shell から始めます。ここでは、tests.py では必要ないいくつかのことを行う必要があります。最初のことは、shell 内でテスト環境を設定することです:

python manage.py shell
>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()

~django.test.utils.setup_test_environment は、レスポンスの一部の追加属性(例えば response.context)を調べることができるようにするためのテンプレートレンダラーをインストールします。これらの属性は、そうでなければ利用できません。このメソッドはテストデータベースをセットアップしませんので、以下の操作は既存のデータベースに対して実行され、出力は既に作成した質問によって多少異なる場合があります。settings.py 内の TIME_ZONE が正しく設定されていない場合、予期しない結果が得られるかもしれません。もし以前に設定したことを忘れている場合は、続ける前に確認してください。

次に、テストクライアントクラスをインポートする必要があります(後で tests.py では、独自のクライアントを持つ django.test.TestCase クラスを使用するため、これは必要ありません):

>>> from django.test import Client
>>> ## 私たちが使用するためのクライアントのインスタンスを作成
>>> client = Client()

これが準備できたら、クライアントにいくつかの作業を依頼できます:

>>> ## '/' からレスポンスを取得
>>> response = client.get("/")
Not Found: /
>>> ## そのアドレスから 404 が返ることを期待する必要があります。代わりに
>>> ## "Invalid HTTP_HOST header" エラーと 400 レスポンスが表示される場合、おそらく
>>> ## 前述の setup_test_environment() の呼び出しを省略しています。
>>> response.status_code
404
>>> ## 一方、'/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&#x27;s up?</a></li>\n    \n    </ul>\n\n'
>>> response.context["latest_question_list"]
<QuerySet [<Question: What's up?>]>

ビューの改善

投票の一覧にはまだ公開されていない投票(すなわち、未来の pub_date を持つもの)が表示されています。これを修正しましょう。

**Form Processing and Cutting Down Our Code** では、~django.views.generic.list.ListView に基づくクラスベースのビューを導入しました:

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() と比較することで日付をチェックするように変更する必要があります。まずインポートを追加する必要があります:

from django.utils import timezone

そして、get_queryset メソッドを以下のように修正する必要があります:

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 に以下を追加します:

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).
    """
    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 で、質問を作成するプロセスからいくつかの繰り返しを省きます。

test_no_questions は質問を作成せずに、メッセージ "No polls are available." をチェックし、latest_question_list が空であることを検証します。django.test.TestCase クラスはいくつかの追加のアサーションメソッドを提供しています。これらの例では、~django.test.SimpleTestCase.assertContains()~django.test.TransactionTestCase.assertQuerySetEqual() を使用しています。

test_past_question では、質問を作成してリストに表示されることを検証します。

test_future_question では、未来の pub_date を持つ質問を作成します。各テストメソッドでデータベースがリセットされるため、最初の質問はもう存在しなくなり、したがってインデックスにはもう質問が表示されないはずです。

その他も同様です。実際、私たちはテストを使って、サイト上の管理者の入力とユーザーの経験の物語を語り、システムの状態のあらゆる状態とその状態のあらゆる新しい変更に対して、期待される結果が表示されることを確認しています。

DetailView のテスト

これまでの作業でうまくいっています。しかし、未来の質問はインデックスには表示されませんが、ユーザーが正しい URL を知っているか、または推測すれば、まだ到達できてしまいます。ですから、DetailView にも同様の制約を追加する必要があります:

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 を持つものは表示されないことを確認するためのいくつかのテストを追加する必要があります:

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 を作成して、それが公開されないことをテストするだけでなく、同様の QuestionChoices とともに作成して、それが公開されることをテストします。

ログインした管理者ユーザーは未公開の Questions を見ることができるかもしれませんが、通常の訪問者は見ることができません。再び申しますが、これを達成するためにソフトウェアに追加する必要のあるものは、どれでもテストに付随する必要があります。テストを最初に書いてからコードをテストに合格させるか、または最初にコード内のロジックを考えてからそれを証明するためのテストを書くかは問いません。

ある時点で、あなたは必ずあなたのテストを見て、あなたのコードがテストの肥大化に苦しんでいないかと疑問に思うでしょう。これが私たちに導くものです:

テストを行う際は、多い方が良い

私たちのテストがコントロール不能になっているように見えるかもしれません。この速度では、すぐにテストの方がアプリケーションよりも多くのコードになり、繰り返しが多く、コードの他の部分のエレガントな簡潔さと比べて見苦しいものになってしまいます。

問題ありません。成長させてください。ほとんどの場合、テストは一度書けば忘れて構いません。プログラムを開発し続ける間、それは引き続き役に立つ機能を果たします。

時々、テストを更新する必要があります。例えば、ビューを修正して、Choices を持つ Questions のみを公開するようにした場合、既存の多くのテストが失敗します。それは、最新の状態にするためにどのテストを修正する必要があるかを正確に教えてくれます。その意味で、テストは自動的に更新されるのを助けます。

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

テストが適切に配置されていれば、管理不能になることはありません。良い目安としては、以下のことが挙げられます。

  • 各モデルまたはビューに対して別の TestClass を持つ
  • テストしたい各条件セットに対して別のテストメソッドを持つ
  • 機能を説明するテストメソッド名を持つ

さらなるテスト

このチュートリアルでは、テストの基本をいくつか紹介します。もっとたくさんのことができ、非常に便利なツールがいくつかあり、とても賢いことを達成するために利用できます。

たとえば、ここでのテストはモデルの内部ロジックの一部と、ビューが情報を公開する方法をカバーしていますが、Selenium のような「ブラウザ内」フレームワークを使って、HTML が実際にブラウザでどのようにレンダリングされるかをテストすることができます。これらのツールを使うと、Django コードだけでなく、たとえば JavaScript の動作も確認できます。テストがブラウザを起動し、人間が操作しているかのようにサイトとやり取りを始めるのを見るのは、なかなか面白いものです!Django には、Selenium のようなツールとの統合を容易にする ~django.test.LiveServerTestCase が含まれています。

複雑なアプリケーションの場合、コンティニュアスインテグレーション の目的で、コミットごとに自動的にテストを実行したい場合があります。そうすることで、品質管理自体が少なくとも部分的に自動化されます。

アプリケーションの未テスト部分を見つける良い方法は、コードカバレッジを確認することです。これはまた、脆弱なコードや死んだコードを特定するのに役立ちます。コードの一部をテストできない場合、それは通常、コードを再構築または削除する必要があることを意味します。カバレッジは死んだコードを特定するのに役立ちます。詳細については、topics-testing-code-coverage を参照してください。

Testing in Django </topics/testing/index> には、テストに関する包括的な情報があります。

まとめ

おめでとうございます!あなたは「自動テストをいくつか作成する」実験を完了しました。あなたの技術を向上させるために、LabEx でさらに多くの実験を練習することができます。