はじめに
今回はアプリの機能からは離れてテストについて触れていきます。
テストについて
私は就活をするために以前 こういうもの を作ったのですが、それを実際に公開して面接で担当者の方に見て頂いた時や、触っていただいた方からフレームワークの理解が足りないという指摘を多く頂きました。
その中の1つがテストコードがないというものでした。
公式チュートリアルより
なぜテストを作成する必要があるのか
どうしてテストを作るのか?また、なぜ今なのか?
もしかしたら、 Python や Django を学ぶのに手一杯で、さらに別のことを学ぶのは大変で不必要なことだと思われるかもしれません。だって投票アプリケーションはきちんと動いているし、わざわざ自動テストを導入したところでアプリケーションがより良くなるわけではないのだから。もし Django プログラミングを学ぶ目的がこの投票アプリケーションを作ることだけならば、確かに自動テストの導入は必要ないと思います。しかし、そうではないなら、今こそ自動テストについて学ぶ絶好の機会です。
公式チュートリアルでもテストについてこのように紹介されています。
ではなぜテストを書くのかというと
-
アルゴリズムの検証を自動化して組み込んでおけば、システムを構成する要素(コンポーネント)のどこかに変化を加えた際にそれが正常に動作するかしないか、あるいはしない場合はどこにどういう問題があるかということがすぐに把握することができる。
-
アルゴリズムの検証をするコードを書けるということは逆に言えば、そのアルゴリズムに対する理解が十全であるということの証明になる
大きく分けてこの2点に尽きるのだと感じました。
前者はいわゆる完全性とか保守・メンテナンスの観点から重要だということですね。
特に我々のような初学者だと
公式チュートリアルより
テストを書くことはチームで共同作業を行う上で役に立ちます。
これまでの点は、1人の開発者でアプリケーションをメンテナンスしているという観点から書きました。しかし、複雑なアプリケーションはチームでメンテナンスされるようになるものです。テストは、あなたが書いたコードを他人がうっかり壊してしまうことから守ってくれます (そして、他の人が書いたコードをあなたが壊してしまうことからも)。Django のプログラマとして生きてゆくつもりなら、良いテストを絶対に書かなければなりません!
こういう考えが欠如しがちですが、自分1人でやっていても昨日の自分や数時間前の自分のタイプミスや変なコードで今の自分の書いたコードに支障が出るのですからチームでやるとなるとそのリスクがあるのは言われてみれば想像に難くないです。
後者に関してはつまりテストまでかけて初めてコーダーとして名乗れるということなのだなと感じました。
では、進めていきます。
テストはいつ作成するべきなのか?
-
テストを書いてからコードを書くという意見
→ テストが書ければコードは書けるという理論に則ったもの。実現したい処理に対する問題を明らかにしてからコードを書き始めるということ。 -
コードを書いてからテストを書くという意見
→ 自分が書いたコードが正常に動くかどうかを確かめるという理論に則って書く。 -
新しい機能の追加・バグチェック・既存のコンポーネントの変更を行ったときについでに書く
どれが正解なのかというのは私にはわからないですが、1ができる人はもう立派なプログラマーだと思います。
では、私達初学者が目指すのはどこなのかというと1と2の折衷のような3の段階にまず進むことを意識するということなのだと私は思います。
テストを書くことに時間を割くということは単に「動くものを作る」という目的から見れば回り道もいいところで、初学者にとって歯がゆく感じるところであります。
しかし、再三申し上げる通りテストが書けなければ、自分の書いたコードを理解していないという評価を周りから受けてしまっても致し方ないわけであり、ではそこから脱却するのにまずするべきなのはということが3に当たるのだと私は思っています。
バグチェックのテストコードを書いてみる。
では、ここからは3で書いたことを意識しながらDjangoのチュートリアルのテストの項目をやっていきます。
以前 においてmodels.py
においてバグがあったのを覚えているでしょうか。
いわゆる未来日付が表示されてしまうバグです。
shellコマンドを使って以下のコマンドを実行します。
import datetime
from django.utils import timezone
from polls.models import Question
future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30)) #1
future_question.was_published_recently() #2
1ではtimedelta
メソッドで未来の日付のインスタンスを作っています。
pub_date=timezone.now() + datetime.timedelta(days=30)
なので、現在から数えて30日後の日付情報を持ったインスタンスを発行します。
2ではそのインスタンスを用いて、was_published_recently()
メソッドを実行しています。
何を実行してるかというと
return self.pub_date >= timezone.now() - datetime.timedelta(days=1)
インスタンスの日付情報が過去日付でなければTrue
、そうでなければFalse
を返すというメソッドでした。
ところがこのままだと未来日付の場合でもTrue
になってしまうというバグがあるというお話でした。
なので、2の結果もTrue
となります。
ではこのバグを発見するためのテストを作成しましょう。
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):
time = timezone.now() + datetime.timedelta(days=30)
future_question = Question(pub_date=time)
self.assertIs(future_question.was_published_recently(), False)
Djangoでテストを記述するファイルはtests.pyです。
冒頭でTestCase
オブジェクトをインポートしておきましょう。
テストの書き方としてはdjango.test.TestCase
を継承したクラスを作りそこにメソッドを書いていくという形になります。
ちなみに、TestCase
を継承すると同時にTransactionTestCase
とSimpleTestCase
も継承します。
データベースを使わないアプリの場合はSimpleTestCase
だけを継承するのが良いそうです。
じゃあこのTestCase
がどういう働きをするのかというのを簡単に言うと、まずtests.pyが実行されるとTestCaseを継承したクラスがあるかどうかの検索が始まります。
そしてそれを発見すると
- テストのためのデータベースを作成してマイグレーションを適用する。
- testで始まるメソッドを検索して実行する。
- ロールバックする。
- 他にメソッドがあれば2と3を繰り返す。
- TestCaseクラス実行前までロールバックする。
- 1で作ったデータベースを削除する。
という処理が行われます。
つまり、テスト・及びテストにおける各メソッドはアプリケーションから見るとある種独立したものであるとも言えるわけです。
参考1
参考2
次にtest_was_published_recently_with_future_question
メソッドがなにをしているのかというと
-
time
変数に未来日付を代入。 -
future_question
変数にtime
変数の日付情報を持ったQuestion
インスタンスを代入する。 -
assertIs(a, b)
はa=b
かどうか検証するメソッド、つまりfuture_question.was_published_recently()
がFalse
であるかを検証します。
ということになります。
ではここまで理解したらテストを実行してみます。
py manage.py test polls
複数アプリケーションがある場合はpollsの部分を任意のアプリケーションフォルダ名に置き換えてください。
実行結果としては
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 test in 0.001s
FAILED (failures=1)
Destroying test database for alias 'default'...
こういう類のものが出るはずです。
AssertionError: True is not False
というエラーが出ていることからwas_published_recently()
が未来日付に対してTrueを返してしまっていることがここでわかります。
バグを発見したというわけですね。
バグの修正
ではバグを発見したということなのでこれを修正するコードを書いていきます。
# return self.pub_date >= timezone.now() - datetime.timedelta(days=1)を下記に修正
def was_published_recently(self):
now = timezone.now()
return now - datetime.timedelta(days=1) <= self.pub_date <= now
未来日付にTrue
を返してしまうというのであれば条件指定に現在の日付を加えて、それを最大値にしてしまえばいいということですね。
ちなみにより厳密なテストをするとなると
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)
tests.pyに以上2つのメソッドを新たに定義します。
test_was_published_recently_with_old_question()
メソッドはwas_published_recently()
が過去日付でTrueを返していないか検証するメソッドで、
test_was_published_recently_with_recent_question()
メソッドはwas_published_recently()
が現在の日付にTrue
を返しているか検証するメソッドになります。
テストコードを書いてみる(View編)
さてアルゴリズムに対するテストは行いましたがそれが実際にアプリケーションで動作をするのか気になると思います。
なので、今度はView
のテストを書いて行こうと思います。
まず、先程作ったクラスに以下のモジュールとテストクラスを追加します。
from django.urls import reverse
# 中略
def create_question(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):
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):
# 過去日付のQuestionインスタンスを作成
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_future_question(self):
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):
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_two_past_questions(self):
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.>']
)
create_question
メソッドはquestion_text
とdays
を引数とするQuestion
オブジェクトを作成するためのメソッドになります。
以後のテストクラスでQuestion
オブジェクトを作成するのでその都度そのコードを書かなくていいように冒頭で定義しておきます。
ではQuestionIndexViewTests
クラスを見ていきましよう。
全編を通して出てくるclient
クラスはテストで用いるクラスでブラウザでのアクセスのシミュレートをしてくれるクラスになります。
参考
例えばself.client.get
のように書くとself.objects.get
のような実際のリクエスト処理をテストしてくれるというわけです。
test_no_questions
はQuestion
オブジェクトが存在しない場合の動作を検証するメソッドです。
ではresponse = self.client.get(reverse('polls:index'))
でURLを叩いて見ます。
self.assertEqual(response.status_code, 200)
self.assertContains(response, "No polls are available.")
self.assertQuerysetEqual(response.context['latest_question_list'], [])
以上3つは先程も出てきたassertIs()
メソッドと同じように引数の検証をします。
例えば、self.assertEqual(response.status_code, 200)
だと先程URLを叩いた結果ブラウザにページが表示されているかどうかの検証になります。
200のアクセスコードは正常にページが表示されている状態の時に出るものなのでresponse.status_code == 200
を検証しています。
ちなみにformなどのPOSTの動作の確認の場合はself.client.post
になります。
self.assertContains()
は引数に第2引数で指定したものが含まれるかどうかの検証をします。
例えば今回の場合だとresponse
の中にはself.client.get(reverse('polls:index'))
の戻り値にcontext
が含まれているのでその中に指定した文字列があるかどうか検証をするということになります。
self.assertQuerysetEqual()
はクエリセットにデータがあるかどうかを確認しています。
ちなみにクエリセットはint
などと同様にデータの型を指し、もっとざっくばらんに言うとモデルから取り出した一連の情報と思ってください。
今回はpolls:index
のパスのURLを叩くのでIndexView
クラスが実行されることになります。
そうするとIndexView
クラスでは最後にget_queryset()
メソッドでデータベースからデータを取得するのでそこで取得されたそこでクエリセットが生まれるというわけです。
ちなみに
self.assertQuerysetEqual(response.context['latest_question_list'], [])
なのでこの場合はlatest_question_list
が空であるということ、つまり質問が何もないことを検証するということになります。
以後のテストメソッドも同様です。
定義したcreate_question()
メソッドに引数であるquestion_text
とdays
の値を指定してQuestion
オブジェクトを作成し、それを元にテストを行っていきます。
response.context()
の実行結果についてですがテンプレートにおいて
<li><a href="{% url 'polls:detail' question.id %}">{{ question.question_text }}</a></li>
こういう記述をしたので引数にquestion_text
を指定したので{{ question.question_text }}
にはそこで指定したものが入るのは覚えていますね。
つまり例えばtest_past_question()
メソッドだとPast question.
がここに入ることになります。
よって
self.assertQuerysetEqual(response.context['latest_question_list'],['<Question: Past question.>'])
このような記述は戻り値でresponse
の中のhtmlコンテキストの中にPast question.
が含まれているか検証するという意味になるということを理解しておきましょう。
ここまでわかればtest_future_question()
において
self.assertQuerysetEqual(response.context['latest_question_list'], [])
がTrueになるのもわかると思うのであとは難しいことはないと思います。
最後にテストの前にView
をを修正します。
modelのバグは修正しましたが、これまでのままだと未来日付を持つオブジェクトをしてしまうためです。
django.utils
モジュールからtimezone
オブジェクトをインポートして以下のようにIndexView
クラスを修正します。
from django.utils import timezone
class IndexView(generic.ListView):
template_name = 'polls/index.html'
context_object_name = 'latest_question_list'
def get_queryset(self):
return Question.objects.filter(
pub_date__lte = timezone.now()
).order_by('-pub_date')[:5]
pub_date__lte=timezone.now()
はpub_date <= timezone.now()
と同義と思ってください。
つまり、現在以前の日付情報を持つQuestion
オブジェクトを5件づつ取得するというクエリセットが返されるという処理になります。
DetailViewのテスト
Index
までは修正したはいいものの、このままではまだDetailView
で未来日付のデータが表示されてしまいますのでここも修正しておきましょう。
以下のように修正します。
class DetailView(generic.DetailView):
...
def get_queryset(self):
return Question.objects.filter(pub_date__lte=timezone.now())
次にtests.py
に下記のテストクラスを追加します。
class QuestionDetailViewTests(TestCase):
def test_future_question(self):
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):
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
基本的には先程までのテストと理屈は同じです。違うところはDetailViewのテストなのでurl = reverse('polls:detail', args=(future_question.id,))
といった形で例えば/polls/34
といったURLの34
の部分を予め用意してそれを個別のURLとして指定しないといけないということですね。
テストが多くなってしまったら?
さて、ここまでテストをいくつか書いてみて気づいた方もいると思いますが、例えば今回のチュートリアルで作っているアプリであるとresluts.html
、つまりResultsView
クラスに対するテストも書かないといけないことがわかります。
が、そのテストの内容は今書いたDetaiView
クラスに対するテストとほぼ同じになることはわかると思います。
違うのはpolls:detail
の部分をpolls:results
にするくらいです。
これに対して公式ドキュメントではテストに重複はつきものだとして、整理するためのルールとして以下の3点を提示しています。
- モデルやビューごとに
TestClass
を分割する - テストしたい条件の集まりのそれぞれに対して、異なるテストメソッドを作る
- テストメソッドの名前は、その機能を説明するようなものにする
補足
今回のテストチュートリアルを実行するにあたって必要になった知識を残しておきます。
Dockerでのコンテナ・ホスト間のファイルコピー
・コンテナからホストへ
docker cp コンテナ名orID:コピーしたいディレクトリorファイルへのパス コピー先のホストのディレクトリ
・ホストからコンテナへ
docker cp コピーしたいディレクトリorファイルへのパス コンテナ名orID:コンテナへの保存先のパス
*DjangoはDockerの内部、つまり仮想環境側に入っているのでそれらのファイルへのパスはターミナル形式で叩かないと探せません。
つまり
docker exec -it コンテナ名 /bin/bash
などでコンテナの中に入り、
py -c "import django; print(django.__path__)"
で、ソースファイルのパスを検索しないといけないというわけです。
ソースファイルのパスを検索すればDjangoフォルダの場所がわかるのであとはそれをホスト側にコピーすれば最悪あとはホスト側で探せます。
もちろんファイルまでのディレクトリがわかるならそのままそれを加えて直接そのファイルだけコピーすればOKです。
最後に
今回始めてテストコードについて勉強しましたが、流石にこれが書けてようやくコーダーと名乗れるというだけあってかなり奥が深いものを感じました。
クラスベースビューもややこしかったですが、この項目はこれだけではまだまだ足りないと思います。
ただ、こうやってテストコードを書いてみると今まで自分が書いてきたコードを再確認して、あやふやだったところを含めて改めて理解が深まったのでやはり大切なことなのだと感じました。
チュートリアルの補足としてテストだけを掘り下げた章があるのでこのあとの6・7章が終わったあとに目を通して行きたいと思います。
参考
[Docker]コンテナとホスト間でファイルをコピーする
[Django] 自動テストについてのまとめ
はじめての Django アプリ作成、その 5