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


勉強会用資料です．
今回はTestについての色々と説明していきます．

本家チュートリアル（本家ではチュートリアル5として説明してます）
https://docs.djangoproject.com/ja/1.9/intro/tutorial05/

→ [他のチュートリアル](http://qiita.com/maisuto/items/17653f344d1f64afa019)

# テストを実行させてみる

ソース→ `0b1bcccc1da95d274cd92253fa44af7a84c51404`

趣味でちょこっと書くようなプログラムではあまり縁のない話かもしれませんが，
長期間に渡って少しづつ改修していくプログラムの場合，しっかりテストを書いておけば幸せになれます．

テストの目的やどうして必要なのか，どう便利なのかという話は本家チュートリアルや[Qiitaの記事](http://qiita.com/search?q=%E3%83%86%E3%82%B9%E3%83%88)などで調べてください．

pythonでは標準ライブラリとして[unittest](http://docs.python.jp/3/library/unittest.html)が用意されており，
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を開くとデフォルトで以下のような状態になっていると思います．

```python:tests.py
from django.test import TestCase

# Create your tests here.
```

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

- アプリケーションの下に `test` から始まるファイルを作る
- django.test.TestCaseを継承したクラスを作る
- メソッド名を`test`から始める


```python:tests.py
from django.test import TestCase


class PollsTest(TestCase):
    def test_hoge(self):
        print('test!')
```


```bash
$ ./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` を使うほうが楽に（意図がわかりやすく）書けます．

```python
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系にはないらしいです）

```python: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)
```


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

```python:polls/models.py（テスト対象のメソッド）

class Question(models.Model):
...
    def was_published_recently(self):
        return self.pub_date >= timezone.now() - datetime.timedelta(days=1)
```

```python: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())
```

```bash（結果）
$ ./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](https://qiita-image-store.s3.amazonaws.com/0/58048/fc3143f1-a295-cb45-86e5-b706d495b8b4.png "Kobito.1aoQFZ.png")

図にするとこんな感じです．
というわけで，この条件をチェックするためにテストを追加してみます．
[本家チュートリアル](https://docs.djangoproject.com/ja/1.9/intro/tutorial05/#more-comprehensive-tests)では別メソッドとしてテストを追加していますが，今回はとりあえず `test_was_published_recently` を拡張します．
①と②，③と④はなるべく近くする必要がありますが，今回は時間なので少しルーズにします．

```python: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())
```

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

```python: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分後公開')
```
        
```bash
$ ./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つでもテストが失敗した時点で他がどう動作しようが修正の必要があるので②〜④のチェックをする必要すらない，ともいえますが...

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

```python:polls/models.py
...
class Question(models.Model):
...
    def was_published_recently(self):
        return timezone.now() >= self.pub_date >= timezone.now() - datetime.timedelta(days=1)
```

```bash
$ ./manage.py test
Creating test database for alias 'default'...
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

OK
Destroying test database for alias 'default'...
```

これでメソッドを意図通りに動作に修正できました．

## viewのテスト

→ [本家チュートリアル](https://docs.djangoproject.com/ja/1.9/intro/tutorial05/#test-a-view)

modelのテストでは定義したメソッドのロジックについて動作をチェックしました．
viewのテストではユーザが実際にページを表示させた時，

− 正常にページが表示できるか？
− 表示される項目が正しいか？

についてチェックしていくことになります．

djangoではテスト用の `Client` クラスを提供しており，ブラウザでのアクセスをシミュレートしてくれます．
まずはpollsのindex画面に対してのテストを書いてみましょう．
`TestCase` 内では `self.client` で`Client`クラスにアクセスできます．

## ステータスコードの確認

ソース→`852b862423f9908480e60c5657ee028782530933`

`正常にページが表示できるか？` を確かめるにはstatus_codeを確認すればいいです．
djangoを触っててよく目にするステータスコードは以下のような感じかと思います．
→ [ステータスコード(wikipedia)](https://ja.wikipedia.org/wiki/HTTP%E3%82%B9%E3%83%86%E3%83%BC%E3%82%BF%E3%82%B9%E3%82%B3%E3%83%BC%E3%83%89)

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

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


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

```python: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テーブルの件数を取得しています．


```python: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())
```

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

```python: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公式](docs.python.jp/3/library/unittest.mock.html)

今のところありませんが，アプリによってはAPIを使って外部と連携するようなプログラムも当然出てきます．
そのような外部との通信が必要なプログラムのテストをするために，pythonではmockライブラリが提供されています．
外部との通信が発生する部分をmockに置き換えることで，APIの戻り値を好きな値に置き換えることができます．

![Kobito.eoQ3cl.png](https://qiita-image-store.s3.amazonaws.com/0/58048/c2ff0b5a-de8b-f3bb-aacc-807c6fdf40ad.png "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)
```

```bash
$ ./manage.py testCreating test database for alias 'default'...
ret: api response
mocked_ret: dummy api response   # ← withによるpatch適用
.decorator: dummy api response   # ← decoratorによるpatch適用
```

<hr>

次のチュートリアルではModelに関する補足説明と，shellからの操作について説明します．
[次のチュートリアルへ](http://qiita.com/maisuto/items/7aec76e6f6fb906bfffa)

→ [他のチュートリアル](http://qiita.com/maisuto/items/17653f344d1f64afa019)