Pythonでのチームアプリ開発に参画するための学習履歴~Djangoチュートリアル5~

# はじめに

今回はアプリの機能からは離れてテストについて触れていきます。


# テストについて

私は就活をするために以前 [こういうもの]() を作ったのですが、それを実際に公開して面接で担当者の方に見て頂いた時や、触っていただいた方からフレームワークの理解が足りないという指摘を多く頂きました。
その中の1つがテストコードがないというものでした。

公式チュートリアルより

>なぜテストを作成する必要があるのか
どうしてテストを作るのか？また、なぜ今なのか？
もしかしたら、 Python や Django を学ぶのに手一杯で、さらに別のことを学ぶのは大変で不必要なことだと思われるかもしれません。だって投票アプリケーションはきちんと動いているし、わざわざ自動テストを導入したところでアプリケーションがより良くなるわけではないのだから。もし Django プログラミングを学ぶ目的がこの投票アプリケーションを作ることだけならば、確かに自動テストの導入は必要ないと思います。しかし、そうではないなら、今こそ自動テストについて学ぶ絶好の機会です。

公式チュートリアルでもテストについてこのように紹介されています。
ではなぜテストを書くのかというと

- アルゴリズムの検証を自動化して組み込んでおけば、システムを構成する要素(コンポーネント)のどこかに変化を加えた際にそれが正常に動作するかしないか、あるいはしない場合はどこにどういう問題があるかということがすぐに把握することができる。

- アルゴリズムの検証をするコードを書けるということは逆に言えば、そのアルゴリズムに対する理解が十全であるということの証明になる

大きく分けてこの2点に尽きるのだと感じました。
前者はいわゆる完全性とか保守・メンテナンスの観点から重要だということですね。
特に我々のような初学者だと

公式チュートリアルより

> テストを書くことはチームで共同作業を行う上で役に立ちます。
これまでの点は、1人の開発者でアプリケーションをメンテナンスしているという観点から書きました。しかし、複雑なアプリケーションはチームでメンテナンスされるようになるものです。テストは、あなたが書いたコードを他人がうっかり壊してしまうことから守ってくれます (そして、他の人が書いたコードをあなたが壊してしまうことからも)。Django のプログラマとして生きてゆくつもりなら、良いテストを絶対に書かなければなりません！

こういう考えが欠如しがちですが、自分1人でやっていても昨日の自分や数時間前の自分のタイプミスや変なコードで今の自分の書いたコードに支障が出るのですからチームでやるとなるとそのリスクがあるのは言われてみれば想像に難くないです。

後者に関してはつまりテストまでかけて初めてコーダーとして名乗れるということなのだなと感じました。
では、進めていきます。


# テストはいつ作成するべきなのか？


1. テストを書いてからコードを書くという意見
→ テストが書ければコードは書けるという理論に則ったもの。実現したい処理に対する問題を明らかにしてからコードを書き始めるということ。

2. コードを書いてからテストを書くという意見
→ 自分が書いたコードが正常に動くかどうかを確かめるという理論に則って書く。

3. 新しい機能の追加・バグチェック・既存のコンポーネントの変更を行ったときについでに書く




どれが正解なのかというのは私にはわからないですが、1ができる人はもう立派なプログラマーだと思います。
では、私達初学者が目指すのはどこなのかというと1と2の折衷のような3の段階にまず進むことを意識するということなのだと私は思います。

テストを書くことに時間を割くということは単に「動くものを作る」という目的から見れば回り道もいいところで、初学者にとって歯がゆく感じるところであります。

しかし、再三申し上げる通りテストが書けなければ、自分の書いたコードを理解していないという評価を周りから受けてしまっても致し方ないわけであり、ではそこから脱却するのにまずするべきなのはということが3に当たるのだと私は思っています。


# バグチェックのテストコードを書いてみる。

では、ここからは3で書いたことを意識しながらDjangoのチュートリアルのテストの項目をやっていきます。
[以前](https://qiita.com/shitikakei/items/753ba603b5bca272c69c) において`models.py`においてバグがあったのを覚えているでしょうか。
いわゆる**未来日付**が表示されてしまうバグです。

shellコマンドを使って以下のコマンドを実行します。


```python
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()`メソッドを実行しています。
何を実行してるかというと

```python
return self.pub_date >= timezone.now() - datetime.timedelta(days=1)
```

インスタンスの日付情報が過去日付でなければ`True`、そうでなければ`False`を返すというメソッドでした。
ところがこのままだと未来日付の場合でも`True`になってしまうというバグがあるというお話でした。
なので、2の結果も`True`となります。
ではこのバグを発見するためのテストを作成しましょう。


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

        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を継承したクラスがあるかどうかの検索が始まります。
そしてそれを発見すると


1. テストのためのデータベースを作成してマイグレーションを適用する。
2. testで始まるメソッドを検索して実行する。
3. ロールバックする。
4. 他にメソッドがあれば2と3を繰り返す。
5. TestCaseクラス実行前までロールバックする。
6. 1で作ったデータベースを削除する。

という処理が行われます。
つまり、テスト・及びテストにおける各メソッドはアプリケーションから見るとある種独立したものであるとも言えるわけです。
[参考1](https://qiita.com/okoppe8/items/eb7c3be5b9f6be244549#%E5%87%A6%E7%90%86%E3%81%AE%E6%B5%81%E3%82%8C)
[参考2](https://docs.djangoproject.com/ja/3.0/intro/tutorial05/)


次に`test_was_published_recently_with_future_question`メソッドがなにをしているのかというと

1. `time`変数に未来日付を代入。
2. `future_question`変数に`time`変数の日付情報を持った`Question`インスタンスを代入する。
3. `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を返してしまっていることがここでわかります。
バグを発見したというわけですね。

# バグの修正

ではバグを発見したということなのでこれを修正するコードを書いていきます。

```python
# 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`を返してしまうというのであれば条件指定に現在の日付を加えて、それを最大値にしてしまえばいいということですね。


ちなみにより厳密なテストをするとなると

```polls/tests.py
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`のテストを書いて行こうと思います。

まず、先程作ったクラスに以下のモジュールとテストクラスを追加します。


```pols/tests.py
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`クラスはテストで用いるクラスでブラウザでのアクセスのシミュレートをしてくれるクラスになります。
[参考](https://docs.djangoproject.com/ja/3.0/topics/testing/tools/#django.test.Client)
例えば`self.client.get`のように書くと`self.objects.get`のような実際のリクエスト処理をテストしてくれるというわけです。

`test_no_questions`は`Question`オブジェクトが存在しない場合の動作を検証するメソッドです。
では`response = self.client.get(reverse('polls:index'))`でURLを叩いて見ます。

```python
    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()`の実行結果についてですがテンプレートにおいて


```html
<li><a href="{% url 'polls:detail' question.id %}">{{ question.question_text }}</a></li>
```

こういう記述をしたので引数に`question_text`を指定したので`{{ question.question_text }}`にはそこで指定したものが入るのは覚えていますね。
つまり例えば`test_past_question()`メソッドだと`Past question.`がここに入ることになります。
よって

```python
self.assertQuerysetEqual(response.context['latest_question_list'],['<Question: Past question.>'])
```

このような記述は戻り値で`response`の中のhtmlコンテキストの中に`Past question.`が含まれているか検証するという意味になるということを理解しておきましょう。
ここまでわかれば`test_future_question()`において

```python
self.assertQuerysetEqual(response.context['latest_question_list'], [])
```

がTrueになるのもわかると思うのであとは難しいことはないと思います。


最後にテストの前に`View`をを修正します。
modelのバグは修正しましたが、これまでのままだと未来日付を持つオブジェクトをしてしまうためです。
`django.utils`モジュールから`timezone`オブジェクトをインポートして以下のように`IndexView`クラスを修正します。

```python

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`で未来日付のデータが表示されてしまいますのでここも修正しておきましょう。
以下のように修正します。

```polls/views.py

class DetailView(generic.DetailView):
    ...
    def get_queryset(self):
        return Question.objects.filter(pub_date__lte=timezone.now())
```

次に`tests.py`に下記のテストクラスを追加します。


```python
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]コンテナとホスト間でファイルをコピーする](http://a4dosanddos.hatenablog.com/entry/2018/11/11/091502)
[[Django] 自動テストについてのまとめ](https://qiita.com/okoppe8/items/eb7c3be5b9f6be244549#%E5%87%A6%E7%90%86%E3%81%AE%E6%B5%81%E3%82%8C)
[はじめての Django アプリ作成、その 5](https://docs.djangoproject.com/ja/3.0/intro/tutorial05/)