- Python 3.11.4 / Django 4.2.4
目次
チュートリアル⑤
web投票アプリケーションが完成したので、今度は自動テストを作ってみる。
- 自動テストの導入
- 自動テストとは コードの動作をチェックするルーティーン! テストは異なるレベルで実行され、あるテストは小さな機能に対して行われるもの(ある特定のモデルのメソッドは期待通りの値を返すか?)かもしれないし、別のテストはソフトウェア全体の動作に対して行われるもの(サイト上でのユーザーの一連の入力に対し希望通りの結果が表示されるか?)かもしれない。 こうしたテストは、前にチュートリアル②でshellを用いてメソッドの動作を確かめたことや、実際にアプリケーションを実行して値を入力して結果がどうなるかを確かめることと何も違いはない。 自動テストが他と異なる点は、テスト作業がシステムによって実行されること。一度テストセットを作成すると、それからはアプリに変更を加えるたび、意図した通りにコードが動作するか確認できる。
- なぜテストを作成する必要があるのか ある程度のところまでは動かしてみて正しく動いていそうなことを確認するだけでもテストとして十分だが、高機能なアプリでは、コンポーネント間の複雑な相互作用が数多くあるかもしれず、それらのコンポーネントのどれかを変更したときに、アプリが予想外の振る舞いをする可能性がある。プログラムを壊していないことを確認するには、様々なテストデータを用いてプログラムを走らせないといけない。 自動テストを導入すると、プログラムが正しく動くことの確認を一瞬で終わらせることができ、また、プログラムのどこで予期せぬ動作が起きたかを見極めるのに役立つ。
- 共同作業を行う上でも役立つ テストは、あなたが書いたコードを他人がうっかり壊してしまうことから守ってくれる(そして、他の人が書いたコードをあなたが壊してしまうことからも)。
- 基本的なテスト方針 「テスト駆動開発」の原則に従い、コードを書く前にテストを書くプログラマーもいる! 問題をきちんと言葉にしてから、その問題を解決するためのコードを書くということ。テスト駆動開発は、ここで言う問題を単に Python のテストケースとして形式化しただけのこと。
- 初めてのテスト作成
- バグを見つけたとき pollsアプリケーションにはすぐに修正可能な小さなバグがある。 Question.was_published_recently() のメソッドは Question が昨日以降に作成された場合に True を返すが、 Question の pub_date が未来の日付になっている場合にも True を返してしまう。 未来の日付の質問のメソッドをチェックするには、shellを使用してバグを確認する。
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 >>>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() 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_piblished_resently()の出力をチェックしている。これは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 16,in test_was_published_recently_with_future_question self.assertIs(future_question.was_published_recently(), False) AssertionError: True is not False ---------------------------------------------------------------------- Ran 1 testin 0.001s FAILED (failures=1) Destroying test databasefor alias 'default'...
- manage.py test pollsが、pollsアプリケーション内にあるテストを探す。
- django.test.TestCaseクラスのサブクラスを発見する。
- テストのための特別なデータベースを作成する。
- テスト用のメソッドとして、testでd始まるメソッドを探す。
- test_was_published_resently_with_future_quesitonの中で、pub_dateフィールドの今日から30日後の日付を持つQuestionインスタンスが作成される。
- 最後に、assertIs()メソッドを使うことで、本当に返してほしいのはFalseだったにもかかわらず、was_published_resently()がTrueを返していることを発見する。
- バグを修正する 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_resently()メソッドをさらにピン留めしておける。一つのバグを直したことで他のバグを作り出すなんてしたくないので、このメソッドの振る舞いをより包括的にテストするために、同じクラスにさらに2つのテストを追加する。
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_resently()が過去、現在、未来の質問に対して意味のある値を返すことを確認する3つのテストがそろった。
- バグを見つけたとき pollsアプリケーションにはすぐに修正可能な小さなバグがある。 Question.was_published_recently() のメソッドは Question が昨日以降に作成された場合に True を返すが、 Question の pub_date が未来の日付になっている場合にも True を返してしまう。 未来の日付の質問のメソッドをチェックするには、shellを使用してバグを確認する。
- ビューをテストする この投票アプリケーションは、まだ質問をちゃんと見分けることができない。pub_dateフィールドが未来の日付になっている質問を含め、どんな質問でも公開してしまうので、この点を改善するべき。pub_dateを未来に設定するということは、その Question がその日付になった時に公開され、それまでは表示されないことを意味するはず。
- ビューに対するテスト 上でバグを修正したときには、初めにテストを書いてからコードを修正した。実は、テスト駆動開発の簡単な例だったわけだが、テストとコードを書く順番はどちらでも構わない。 上のテストではコード内部の細かい動作に焦点を当てたが、このテストではユーザーがwebブラウザを通して経験する動作をチェックする。 はじめに何かを修正する前に使用できるツールについてみておく。
- Djangoテストクライアント Djangoは、ビューレベルでのユーザーとのインタラクションをシミュレートすることができるClientを用意している。これはtests.pyの中でもshellでも使うことができる。 shellから始める!ここでテストクライアントを使う場合には、tests.pyでは必要がない二つの準備が必要になる。まず最初にしなければならないことは、shellの上でテスト環境をセットアップすること。
python manage.py shell
>>>from django.test.utils import setup_test_environment >>>setup_test_environment()
setup_test_environment()は、テンプレートのレンダラー(テンプレートファイルに変数や制御構造を埋め込んで、実際のHTML出力を生成する)をインストールする。これによって、今までは調査できなかった、レスポンス上のいくつかの属性(たとえばresponse.context)を調査できるようになる。 注意点として、このメソッドはテスト用データベースを作成しないので、これに続く命令は既存のデータベースに対して実行される。作成した question によってはアウトプットが多少異なるかもしれない。またsettings.pyのTIME_ZONEが正しくない場合は予期しない結果になる。設定が正しいか自信がなければ、次に進む前にTIME_ZONEを確認して! 次に、テストクライアントのクラスをインポートする必要がある(後で取り上げるtests.pyの中では、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("/") 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. >>>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's up?</a></li>\\n \\n </ul>\\n\\n' >>>response.context["latest_question_list"] <QuerySet [<Question: What's up?>]>
- ビューを改良する 現在の投票のリストは、まだ公開されていない (つまりpub_dateの日付が未来になっている) 投票が表示される状態になっているので、これを直す。 チュートリアル④では、以下のような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()と比較してチェックする必要がある。まず、インポート文を追加する: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_dateがtimezone.now以前のQuestionを含んだクエリセットを返す。 - 新しいビューをテストする これで期待通りの満足のいく動作をしてくれるかどうか確かめる! まず、runserverを実行してブラウザでサイトを読み込む。過去と未来、それぞれの日付を持つQuestionを作成し、すでに公開されている質問だけがリストに表示されるかどうかを確認する。 上のshellのセッションに基づいてテストを作る。polls/tests.pyに次の行を追加する。
from django.urls import reverse
そしてquestionを簡単に作れるようにするショートカット関数と、新しいテストクラスを作る。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], )
- questionのショートカット関数create_question。この関数によってquestionの作成処理コードの重複をなくしている。
- test_index_view_with_no_questionsは、質問が1つも作成されていない状況をテストする。このテストは、”No polls are available.” というメッセージが表示されているかどうかと、latest_question_listが空であることを確認する。 DjangoのTestCaseクラスは、様々な便利なテストのアサーションメソッドを提供しており、このケースでは、特に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())
そして、pub_dateが過去のものであるQuestionは表示でき、未来の葉表示できないことを確認するためにいくつかのテストを追加する。# polls/tests.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)が1つもない質問(Questions)が公開されているのは不適切。このような質問をフィルタリングするビューのロジックを追加することが考えられる。選択肢がない質問が公開されないこと、そして選択肢がある質問が公開されること、双方をテストすれば良い! また、管理者としてログインしているユーザーは、通常の訪問者とは異なり、まだ公開前の質問を確認できるようにすると良いかもしれない。どんなソフトウェアの変更を考える場合も、その変更にはテストが必要。テスト先行で開発するか、コードのロジックを先に試してからテストを書くか、どちらの方法でも構わない。 しかし、テストが増えてくると、その量が圧倒的になり、コードの管理が難しくなることがある。このような状況になった場合、どうすれば良いかという問題が生じる。
- テストにおいて、多いことは良いことだ テストはどれだけ多くても大丈夫だし、たいていの場合、テストを一回書いたらそのことを忘れて大丈夫!プログラムを開発し続ける限りずっと、そのテストは便利に機能し続ける。 時には、テストのアップデートが必要になることがある。たとえば、Choicesを持つQuestionsだけを公開するようにビューを修正したとすると、既存のテストの多くは失敗する。この失敗によって、 最新の状態に対応するためにどのテストを修正する必要があるのかが正確にわかる。そのためある程度、テストはテスト自身をチェックする助けになる。 きちんと考えてテストを整理していれば、テストが手に負えなくなることはない。経験上、良いルールとして次のようなものが挙げられます。
- モデルやビューごとにTestClassを分割する
- テストしたい条件の集まりのそれぞれに対して、異なるテストメソッドを作る
- テストメソッドの名前はその機能を説明するようなものにする
- さらなるテスト このチュートリアルではテストの基本に少し触れたが、まだやれることはたくさんあるし、色んな便利なツールもある。 例えば、今回のテストでモデルのロジックやビューの情報の公開方法を見たが、ブラウザがHTMLをどう表示するかをチェックするためのツール、例えばSeleniumのような”in-browser”フレームワークも使える。これで、Djangoが作ったコードだけじゃなく、JavaScriptの動きもテストできる。Djangoには、Seleniumなどのツールと一緒に使えるLiveServerTestCaseもあるので便利。 大きなアプリを作る時は、コミットするたびに自動でテストが走るように設定するのがいい(これを継続的インテグレーションと呼ぶ)。そうすれば、品質を自動でチェックできるようになる。 テストしてないコードの部分を見つける時や、使ってないコードを探す時は、コードカバレッジを使うといい。テストができないコードがあれば、そのコードは直すか消すべき。カバレッジで使われてないコードもわかる。
チュートリアル⑥
web投票アプリケーションのテストが完成したので、今度はスタイルシートや画像を追加する。
サーバで生成するHTML以外に、Webアプリケーションは一般的に完全なWebページをレンダリングするために、画像、JavaScript、CSSなど必要なファイルを提供する必要がある。Djangoでは、これらのファイルを “静的 (static) ファイル” と呼ぶ。
d.jango.contrib.staticfilesは、静的ファイルを各アプリケーションから一つの場所に集め、運用環境で公開しやすくするもの。
- アプリの構造をカスタマイズする 最初にpollsディレクトリの中にstaticディレクトリを作成する。Djangoはそこから静的ファイルを探す。 DjangoのSTATICFILES_FINDERSは、静的ファイルを探すための方法を知っているファインダの集まりで、その中のAppDirectoriesFinderは、INSTALLED_APPSにリストされたアプリごとにstaticディレクトリを検索する。これは管理サイトの静的ファイルでも同じ方法で動作する。 そこで、先に作ったstaticディレクトリの中に、pollsディレクトリを作る。次に、そのpollsディレクトリの中にstyle.cssというファイルを追加する。つまり、ファイルのフルパスはpolls/static/polls/style.cssとなる。しかし、DjangoのAppDirectoriesFinderを使うと、このファイルはシンプルにpolls/style.cssとして参照できる。 スタイルシートに次のコードを配置する。
li a { color:green; }
次に、polls/templates/polls/index.htmlの上部に追加する。{% load static %} <link rel="stylesheet" href="{% static 'polls/style.css' %}">
{% static %}テンプレートタグを使うことで、指定した静的ファイルへの正確なURLパスを自動的に取得できる。 次のコマンドを実行してサーバーを起動する。python manage.py runserver
http://localhost:8000/polls/ をリロードすると、質問のリンクが緑色になり、スタイルシートが適切に読み込まれたことが確認できる! - 背景画像を追加する 次に画像用にサブディレクトリを作成する。polls/static/polls ディレクトリの中にimagesサブディレクトリを作る。ディレクトリの中に背景と使用したい画像ファイルを追加する。このチュートリアルではbackground.pngという名前のファイルを使用し、フルパスはpolls/static/polls/images/background.png となるようにする。 さらに、スタイルシートに画像への参照を追加する。
body { background:white url("images/background.png")no-repeat; }
http://localhost:8000/polls/ をリロードすると、読み込んだ画像がスクリーンの左上に確認できる。
コメント