45
43

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Python Django チュートリアル(7)

Last updated at Posted at 2016-06-23

勉強会用資料です.
今回は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.pymodels.py と共に tests.py というファイルが作成されているはずです.

./manage.py
  ├ tutorial/  # 設定ファイル等の置き場所
  │  ├ ...
  │  └ settings.py
  └ polls/  # ← "manage.py startapp polls" で作ったアプリ用ディレクトリ
    ├ models.py
    ├ views.py
    ├ ...
    └ tests.py  # ← これも自動作成されているはず. ここにテストを書いていく

tests.pyを開くとデフォルトで以下のような状態になっていると思います.

tests.py
from django.test import TestCase

# Create your tests here.

ここにTestCaseを継承したクラスに test で始まるメソッドを追加していくと,django側で自動的にメソッドを掻き集め実行してくれるようになります.
下記3つの条件を満たさないと実行されないので注意してください.

  • アプリケーションの下に test から始まるファイルを作る
  • django.test.TestCaseを継承したクラスを作る
  • メソッド名をtestから始める
tests.py
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

早速 assertEqualassertNotEqual を使ってテストを書いてみましょう.
表に書いた通り,上記2つは ==!= での比較なので 1とTrue や 0とFalse は同じものになります.
型まで明確に比べたい場合は assertIsassertIsNone などを使うか
assertTrue(bool(1)) などのように工夫して書く必要があります.
assertIsassertIsNone は python2系にはないらしいです)

polls/tests.py
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モデルのインスタンスを作ってあげる必要があります.

polls/models.py(テスト対象のメソッド)

class Question(models.Model):
...
    def was_published_recently(self):
        return self.pub_date >= timezone.now() - datetime.timedelta(days=1)
polls/tests.py(テストコード)
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日以内としています.
テストの手法はいくつかありますが,このようなメソッドの場合,条件の前後の値を与え,どう判定されるかを確認する手法がよく取られます.
(境界値分析やら境界値テストやらそんな感じで呼ばれます)

Kobito.1aoQFZ.png

図にするとこんな感じです.
というわけで,この条件をチェックするためにテストを追加してみます.
本家チュートリアルでは別メソッドとしてテストを追加していますが,今回はとりあえず test_was_published_recently を拡張します.
①と②,③と④はなるべく近くする必要がありますが,今回は時間なので少しルーズにします.

polls/tests.py
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 にはメッセージを付けられるのでメッセージを設定しておくともう少しわかりやすくなります.

polls/tests.py(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つでもテストが失敗した時点で他がどう動作しようが修正の必要があるので②〜④のチェックをする必要すらない,ともいえますが...

いずれにしても,未来の判定がないということがテストで判明したので早速修正しましょう.

polls/models.py
...
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.clientClientクラスにアクセスできます.

ステータスコードの確認

ソース→852b862423f9908480e60c5657ee028782530933

正常にページが表示できるか? を確かめるにはstatus_codeを確認すればいいです.
djangoを触っててよく目にするステータスコードは以下のような感じかと思います.
ステータスコード(wikipedia)

コード 意味
200 正常終了
400 パラメータエラー.Formに渡された値がおかしい場合など
401 認証が必要なページにログインせずにアクセスした
404 ページがない
500 内部エラー.pythonのコードがおかしい場合はこれになる

200番台なら正常動作,400番台ならクライアント側の操作ミスによるエラー,
500番台はプログラムやサーバが理由のエラーと覚えておいてください.
今回は正常にページが表示できることの確認なのでresponse.status_codeが200であることを確認します.

polls/tests.py
...
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の中身をチェックすることで,動的に生成される部分が意図通りの値になっているか確認できます.

polls/views.py
...
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テーブルの件数を取得しています.

polls/tests.py
...
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())

テストが無事通ったら,今度はデータを登録し,件数の変化や内容を確認してみましょう.

polls/tests.py
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の戻り値を好きな値に置き換えることができます.

Kobito.eoQ3cl.png

試しに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からの操作について説明します.
次のチュートリアルへ

他のチュートリアル

45
43
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
45
43

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?