勉強会用資料です.
今回はTestについての色々と説明していきます.
本家チュートリアル(本家ではチュートリアル5として説明してます)
https://docs.djangoproject.com/ja/1.9/intro/tutorial05/
テストを実行させてみる
ソース→ 0b1bcccc1da95d274cd92253fa44af7a84c51404
趣味でちょこっと書くようなプログラムではあまり縁のない話かもしれませんが,
長期間に渡って少しづつ改修していくプログラムの場合,しっかりテストを書いておけば幸せになれます.
テストの目的やどうして必要なのか,どう便利なのかという話は本家チュートリアルやQiitaの記事などで調べてください.
pythonでは標準ライブラリとしてunittestが用意されており,
djangoではそれを拡張したTestCaseクラスおよび実行コマンドを提供しています.
test用のコードはアプリケーションを作成した時に作成された tests.py
の中に記述していきます.
tutorialでは $ python manage.py startapp polls
というコマンドでpollsアプリを作成しました.
その際に,views.py
や models.py
と共に tests.py
というファイルが作成されているはずです.
./manage.py
├ tutorial/ # 設定ファイル等の置き場所
│ ├ ...
│ └ settings.py
└ polls/ # ← "manage.py startapp polls" で作ったアプリ用ディレクトリ
├ models.py
├ views.py
├ ...
└ tests.py # ← これも自動作成されているはず. ここにテストを書いていく
tests.pyを開くとデフォルトで以下のような状態になっていると思います.
from django.test import TestCase
# Create your tests here.
ここにTestCaseを継承したクラスに test
で始まるメソッドを追加していくと,django側で自動的にメソッドを掻き集め実行してくれるようになります.
下記3つの条件を満たさないと実行されないので注意してください.
- アプリケーションの下に
test
から始まるファイルを作る - django.test.TestCaseを継承したクラスを作る
- メソッド名を
test
から始める
from django.test import TestCase
class PollsTest(TestCase):
def test_hoge(self):
print('test!')
$ ./manage.py test
Creating test database for alias 'default'...
test!
.
----------------------------------------------------------------------
Ran 1 test in 0.001s
OK
Destroying test database for alias 'default'...
テストらしいテストにする
テストは大抵の場合,ある関数やメソッドの戻り値が期待通りの結果になるかどうかを確認していく作業です.
pythonのTestCaseでは比較用のassertメソッドをいくつか提供しており,期待通りの結果にならなかった時にTest失敗となります.
代表的な4つを表を示します.
メソッド | 確認事項 |
---|---|
assertEqual(a, b) | a == b |
assertNotEqual(a, b) | a != b |
assertTrue(x) | bool(x) is True |
assertFalse(x) | bool(x) is False |
上記4つを覚えておけばほぼ全てのケースをカバーできますが,ショートカットとして使うと便利なassertメソッドがいくつかあるので
公式サイトに一度目を通しておくといいかもしれません.
http://docs.python.jp/3/library/unittest.html#assert-methods
例えば関数を実行した際に例外が発生することをテストするコードは assertTrue
を使うよりも assertRaises
を使うほうが楽に(意図がわかりやすく)書けます.
class PollsTest(TestCase):
def test_exception(self):
try:
func()
except Exception:
pass
else:
self.assertTrue(False) # 例外が発生しないと必ず失敗
def test_exception2(self):
self.assertRaises(Exception, func)
値の比較
ソース → 4301169d6eb1a06e01d93282fbaab0f1fc2c367e
早速 assertEqual
と assertNotEqual
を使ってテストを書いてみましょう.
表に書いた通り,上記2つは ==
,!=
での比較なので 1とTrue や 0とFalse は同じものになります.
型まで明確に比べたい場合は assertIs
や assertIsNone
などを使うか
assertTrue(bool(1))
などのように工夫して書く必要があります.
(assertIs
と assertIsNone
は python2系にはないらしいです)
from django.test import TestCase
class PollsTest(TestCase):
def test_success(self):
self.assertEqual(1, 1)
self.assertEqual(1, True)
def test_failed(self):
self.assertNotEqual(0, False)
$ ./manage.py test
Creating test database for alias 'default'...
F.
======================================================================
FAIL: test_failed (polls.tests.PollsTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/shimomura/pbox/work/tutorial/tutorial/polls/tests.py", line 10, in test_failed
self.assertNotEqual(0, False)
AssertionError: 0 == False
----------------------------------------------------------------------
Ran 2 tests in 0.001s
FAILED (failures=1)
Destroying test database for alias 'default'...
テストに成功すると .
, 失敗すると F
と共にどこで失敗したかが表示されます.
テスト数=メソッド数なので,同じメソッド内で何回assertを呼び出してもテスト回数としては1回としてカウントされます.
細かくテストを書いていったほうがどのテストで失敗したかわかりやすくなりますが,初期化等の関係でテストの時間が増えます.
modelのテスト
ソース→c2c65afcb0d26913f683cb7b64f388925d7896eb
値の比較方法がわかったところで実際にmodelをimportしてメソッドが正しい動作をするかテストしてみましょう.
と言っても,今のところメソッドらしいメソッドはQuestionモデルに一個しかないですが...
テスト対象はQuestionモデルのメソッドなのでtestコード内でQuestionモデルのインスタンスを作ってあげる必要があります.
class Question(models.Model):
...
def was_published_recently(self):
return self.pub_date >= timezone.now() - datetime.timedelta(days=1)
from django.test import TestCase
from django.utils import timezone
from .models import Question
class PollsTest(TestCase):
def test_was_published_recently(self):
obj = Question(pub_date=timezone.now())
self.assertTrue(obj.was_published_recently())
$ ./manage.py test
Creating test database for alias 'default'...
.
----------------------------------------------------------------------
Ran 1 test in 0.001s
OK
Destroying test database for alias 'default'...
こんな感じです.
テストの追加
ソース→1062f1b4362ef2bf4e9ea483b5e178e7ca82c0c6
1メソッドのテスト実行回数が1回というのも寂しいのでもう少しパターンを増やしてみましょう.
さて,テストとはそもそもメソッドが意図通りに動いているかどうかチェックするものです.
was_published_recently
の意図した動作とは,ある 質問 が最近掲載されたかどうかを判定するものであり,
最近 とはここでは現在から1日以内としています.
テストの手法はいくつかありますが,このようなメソッドの場合,条件の前後の値を与え,どう判定されるかを確認する手法がよく取られます.
(境界値分析やら境界値テストやらそんな感じで呼ばれます)
図にするとこんな感じです.
というわけで,この条件をチェックするためにテストを追加してみます.
本家チュートリアルでは別メソッドとしてテストを追加していますが,今回はとりあえず test_was_published_recently
を拡張します.
①と②,③と④はなるべく近くする必要がありますが,今回は時間なので少しルーズにします.
from datetime import timedelta
from django.test import TestCase
from django.utils import timezone
from .models import Question
class PollsTest(TestCase):
def test_was_published_recently(self):
# 1日よりも少しだけ古い
obj = Question(pub_date=timezone.now() - timedelta(days=1, minutes=1))
self.assertFalse(obj.was_published_recently())
# 1日よりも少しだけ新しい
obj = Question(pub_date=timezone.now() - timedelta(days=1) + timedelta(minutes=1))
self.assertTrue(obj.was_published_recently())
# つい最近公開
obj = Question(pub_date=timezone.now() - timedelta(minutes=1))
self.assertTrue(obj.was_published_recently())
# もうちょっとしたら公開
obj = Question(pub_date=timezone.now() + timedelta(minutes=1))
self.assertFalse(obj.was_published_recently())
$ ./manage.py test
Creating test database for alias 'default'...
F
======================================================================
FAIL: test_was_published_recently (polls.tests.PollsTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/shimomura/pbox/work/tutorial/tutorial/polls/tests.py", line 25, in test_was_published_recently
self.assertFalse(obj.was_published_recently())
AssertionError: True is not false
----------------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (failures=1)
Destroying test database for alias 'default'...
今のところ,was_published_recently
メソッドには未来の判定がないので④のテストで失敗します.
このように,メソッドを分けずに記述するとテストに失敗したこと(=FAIL: test_was_published_recently (polls.tests.PollsTest)
)はわかっても
①,②,③,④のどの理由で失敗したかは行番号から判断するしかなくなります.
ただ,assert
にはメッセージを付けられるのでメッセージを設定しておくともう少しわかりやすくなります.
...
def test_was_published_recently(self):
# 1日よりも少しだけ古い
obj = Question(pub_date=timezone.now() - timedelta(days=1, minutes=1))
self.assertFalse(obj.was_published_recently(), '1日と1分前に公開')
# 1日よりも少しだけ新しい
obj = Question(pub_date=timezone.now() - timedelta(days=1) + timedelta(minutes=1))
self.assertTrue(obj.was_published_recently(), '1日と1分後に公開')
# つい最近公開
obj = Question(pub_date=timezone.now() - timedelta(minutes=1))
self.assertTrue(obj.was_published_recently(), '1分前に公開')
# もうちょっとしたら公開
obj = Question(pub_date=timezone.now() + timedelta(minutes=1))
self.assertFalse(obj.was_published_recently(), '1分後公開')
$ ./manage.py test
Creating test database for alias 'default'...
F
======================================================================
FAIL: test_was_published_recently (polls.tests.PollsTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/shimomura/pbox/work/tutorial/tutorial/polls/tests.py", line 25, in test_was_published_recently
self.assertFalse(obj.was_published_recently(), '1分後公開')
AssertionError: True is not false : 1分後公開
----------------------------------------------------------------------
メッセージを付けると少しわかりやすくなりました.
この方法も完璧ではなく,例えば①の判定に失敗するとそこでテストが終了するため,②〜④が正常に動かくどうかの確認はできません.
もっとも,1つでもテストが失敗した時点で他がどう動作しようが修正の必要があるので②〜④のチェックをする必要すらない,ともいえますが...
いずれにしても,未来の判定がないということがテストで判明したので早速修正しましょう.
...
class Question(models.Model):
...
def was_published_recently(self):
return timezone.now() >= self.pub_date >= timezone.now() - datetime.timedelta(days=1)
$ ./manage.py test
Creating test database for alias 'default'...
.
----------------------------------------------------------------------
Ran 1 test in 0.001s
OK
Destroying test database for alias 'default'...
これでメソッドを意図通りに動作に修正できました.
viewのテスト
modelのテストでは定義したメソッドのロジックについて動作をチェックしました.
viewのテストではユーザが実際にページを表示させた時,
− 正常にページが表示できるか?
− 表示される項目が正しいか?
についてチェックしていくことになります.
djangoではテスト用の Client
クラスを提供しており,ブラウザでのアクセスをシミュレートしてくれます.
まずはpollsのindex画面に対してのテストを書いてみましょう.
TestCase
内では self.client
でClient
クラスにアクセスできます.
ステータスコードの確認
ソース→852b862423f9908480e60c5657ee028782530933
正常にページが表示できるか?
を確かめるにはstatus_codeを確認すればいいです.
djangoを触っててよく目にするステータスコードは以下のような感じかと思います.
→ ステータスコード(wikipedia)
コード | 意味 |
---|---|
200 | 正常終了 |
400 | パラメータエラー.Formに渡された値がおかしい場合など |
401 | 認証が必要なページにログインせずにアクセスした |
404 | ページがない |
500 | 内部エラー.pythonのコードがおかしい場合はこれになる |
200番台なら正常動作,400番台ならクライアント側の操作ミスによるエラー,
500番台はプログラムやサーバが理由のエラーと覚えておいてください.
今回は正常にページが表示できることの確認なのでresponse.status_codeが200であることを確認します.
...
from django.shortcuts import resolve_url
...
class ViewTest(TestCase):
def test_index(self):
response = self.client.get(resolve_url('polls:index'))
self.assertEqual(200, response.status_code)
今回はページ表示(GETメソッドでのアクセス)なので
self.client.get
を使いましたが,
FormなどのSubmitのテストをするときはPOSTメソッドなのでself.client.post
を使います.
第1引数にはurlを渡す必要があります.
内容の確認
ソース→d1ad02228ca2cb2370b323fd98cd27a0cf02d7a9
status_codeが 200 であればとりあえずブラウザで見た時にページは表示されているということですので,
次は表示されている内容が意図した通りのものかどうかを確認しましょう.
本家チュートリアルでは assertContains
を使って実際に表示されているhtmlの中身確認しています.
例えば,
self.assertContains(response, "No polls are available.")
のように書けば,表示されるhtmlのソースコードの中にNo polls are available.
が含まれていることを確認します.
self.client.get
の戻り値であるresponse
にはhtmlのレンダリングに使用するcontextを持っているので,
このcontextの中身をチェックすることで,動的に生成される部分が意図通りの値になっているか確認できます.
...
def index(request):
return render(request, 'polls/index.html', {
'questions': Question.objects.all(),
})
...
上記がテスト対象のview関数ですが,render関数の第3引数の辞書({'questions': Question.objects.all()}
)
がtemplateに渡されるcontextになります.
なお,テスト実行時にはテスト用のDBが生成され,テストメソッド毎にDBが作り直されます.
そのため,DBを操作するテストを記述しても本番DBや他のテストの影響を受けることはありません.
views.py内のindex関数ではQuestionオブジェクトの中身を全て取得していますが,
前述の通りDBの中身は空のはずですので実際に空かどうかをチェックするテストを書いてみます.
context['questions']の中身はQuestionモデルに対するQuerySetです.
QuerySetからcount()
を呼び出すことでQuestionテーブルの件数を取得しています.
...
class ViewTest(TestCase):
def test_index(self):
response = self.client.get(resolve_url('polls:index'))
self.assertEqual(200, response.status_code)
self.assertEqual(0, response.context['questions'].count())
テストが無事通ったら,今度はデータを登録し,件数の変化や内容を確認してみましょう.
class ViewTest(TestCase):
def test_index(self):
response = self.client.get(resolve_url('polls:index'))
self.assertEqual(200, response.status_code)
self.assertEqual(0, response.context['questions'].count())
Question.objects.create(
question_text='aaa',
pub_date=timezone.now(),
)
response = self.client.get(resolve_url('polls:index'))
self.assertEqual(1, response.context['questions'].count())
self.assertEqual('aaa', response.context['questions'].first().question_text)
mock
→ python公式
今のところありませんが,アプリによってはAPIを使って外部と連携するようなプログラムも当然出てきます.
そのような外部との通信が必要なプログラムのテストをするために,pythonではmockライブラリが提供されています.
外部との通信が発生する部分をmockに置き換えることで,APIの戻り値を好きな値に置き換えることができます.
試しにapi通信を行う関数と,ダミー用の関数を置き換えてみましょう.
置き換えには unittest.mock.patch
を使います.
呼び出しがある箇所を with
で囲んでもいいですし,テストメソッド自体に decorator
を設定してもいいです.
from unittest import mock
def dummy_api_func():
return 'dummy api response'
def api_func():
return 'api response'
class PollsTest(TestCase):
def test_mocked_api(self):
ret = api_func()
print('ret:', ret)
with mock.patch('polls.tests.api_func', dummy_api_func):
ret = api_func() # ←←←←←←←← この呼出がdummyになる
print('mocked_ret:', ret)
@mock.patch('polls.tests.api_func', dummy_api_func)
def test_mocked_api_with_decorator(self):
ret = api_func() # decoratorを使った場合はここもdummyになる
print('decorator:', ret)
$ ./manage.py testCreating test database for alias 'default'...
ret: api response
mocked_ret: dummy api response # ← withによるpatch適用
.decorator: dummy api response # ← decoratorによるpatch適用
次のチュートリアルではModelに関する補足説明と,shellからの操作について説明します.
次のチュートリアルへ