Django+Reactで学ぶプログラミング基礎(12): Djangoチュートリアル(投票アプリその5-3)

##### [前回] [Django+Reactで学ぶプログラミング基礎(11): Djangoチュートリアル(投票アプリその5-2)](https://qiita.com/mingchun_zhao/items/fb79779ef74f98098d7b)

## はじめに

[Django公式チュートリアル](https://docs.djangoproject.com/ja/4.0/intro/tutorial01/)、その5-3です。
前回は、自動テストの重要性やテスト駆動開発を理解しました。
今回は、Djangoテストクライアントを使用し、ビューをテストします。

## Djangoアプリ作成(その5-3): 投票(poll)アプリ

### 今回の内容

- Djangoテストクライアント
- ビューをテスト

### クライアントツール: Djangoテストクライアント

- Djangoテストクライアントとは
    - Djangoが提供するクライアントツール
    - ビューレベルで、ユーザとのインタラクションをシミュレート
        - ユーザがWebブラウザを通して経験する動作をチェック
            - コード内部の細かい動作には焦点を当てない
    - `tests.py`コードや`shell`コマンドで使用可能

#### shellからテストクライアントを使用

- テスト環境をセットアップ
```
C:\kanban\pollsite>..\venv\.venv\Scripts\activate
(venv) C:\kanban\pollsite>python manage.py shell
>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()
```

- テストクライアントのクラスをインポート
    - ※ テストクライアントを`tests.py`コードで使用する場合はインポート不要
        - `django.test.TestCase`クラス自体がクライアントを持っているため
```
>>> from django.test import Client
```
- テストクライアントのインスタンスを作成
```
>>> client = Client()
```

- クライアントツールで、URL`'/'`へリクエスト発行し、レスポンス取得
```
>>> response = client.get('/')
Not Found: /
```

- レスポンスのステータスコードを確認(404は、`Not Found`)
```
>>> response.status_code
404
```

- URL`'/polls/'`へリクエスト
    - `'reverse()'`を使用し、URL取得
```
>>> from django.urls import reverse
>>> response = client.get(reverse('polls:index'))
>>> response.status_code
200
>>> response.content
b'\n    <ul>\n    \n    <li><a href="/polls/7/">\xe4\xbb\x8a\xe6\x97\xa5\xe3\x81\xae\xe4\xba\x88\xe5\xae\x9a\xe3\x81\xaf?</a></li>\n    \n    <li><a href="/polls/6/">\xe6\xa5\xbd\xe3\x81\x97\xe3\x81\x84\xe3\x81\x93\xe3\x81\xa8\xe3\x81\xaf\xef\xbc\x9f</a></li>\n    \n    <li><a href="/polls/5/">\xe5\xb9\xb8\xe3\x81\x9b\xe3\x81\xa8\xe3\x81\xaf\xef\xbc\x9f</a></li>\n    \n    <li><a href="/polls/4/">\xe7\x9b\xae\xe6\xa8\x99\xe3\x81\xaf\xef\xbc\x9f</a></li>\n    \n    <li><a href="/polls/3/">\xe8\xb6\xa3\xe5\x91\xb3\xe3\x81\xaf\xef\xbc\x9f</a></li>\n    \n    </ul>\n\n'
>>> response.context['latest_question_list']
<QuerySet [<Question: 今日の予定は?>, <Question: 楽しいことは？>, <Question: 幸せとは？>, <Question: 目標は？>, <Question: 趣味は？>]>
>>> exit()
```

### ビューを改良

- 投票アプリの問題点
    - 未公開の投票(`pub_date`フィールドが未来)も、表示されてしまう
- `Question`ビューを改善
    - `pub_date`の日付になった時に公開する、それまでは表示しない

#### `ListView`クラスをベースとする`IndexView`ビューを修正

- インポート文を追加
```polls/views.py
from django.utils import timezone
```
- `get_queryset()`メソッドを修正
    - `pub_date`日付を`timezone.now()`と比較、
        - `timezone.now`以前の`Question`を含んだクエリセットを返す
```polls/views.py
def get_queryset(self):
    """
    Return the last five published questions (not including those set to be
    published in the future).
    """
    return Question.objects.filter(
        pub_date__lte=timezone.now()
    ).order_by('-pub_date')[:5]
```

### 新しいビューをテスト

- 期待通り満足のいく動作をしてくれるか、手動で確かめる手順
    - まず、`runserver`でサーバー起動
    - ブラウザでサイトを読み込む
    - 過去と未来、それぞれの日付を持つ質問を作成
    - すでに公開されている質問だけがリストに表示されるか確認
- プロジェクトにわずかな変更を加えるたびに、上記手順を手動で確認するのはしんどい
    - 対処: 自動テストを作成(上記、`shell`で実行した内容をベースに)

#### テストを作成

- まず、`polls/tests.py`に次の行を追加
```
from django.urls import reverse
```

- 質問を簡単作成するためのショートカット関数と、新しいテストクラスを作成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2523098/07050354-a834-8486-c4da-d5a8cb7d826f.png)
```
def create_question(question_text, days):
    """
    Create a question with the given `question_text` and published the
    given number of `days` offset to now (negative for questions published
    in the past, positive for questions that have yet to be published).
    """
    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):
        """
        If no questions exist, an appropriate message is displayed.
        """
        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):
        """
        Questions with a pub_date in the past are displayed on the
        index page.
        """
        question = create_question(question_text="Past question.", days=-30)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            [question],
        )

    def test_future_question(self):
        """
        Questions with a pub_date in the future aren't displayed on
        the index page.
        """
        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):
        """
        Even if both past and future questions exist, only past questions
        are displayed.
        """
        question = 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],
        )

    def test_two_past_questions(self):
        """
        The questions index page may display multiple questions.
        """
        question1 = create_question(question_text="Past question 1.", days=-30)
        question2 = create_question(question_text="Past question 2.", days=-5)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            [question2, question1],
        )
```

#### コード説明

- `create_question`
    - 質問を作成するためのショートカット関数
        - 質問作成処理のコード重複をなくしている
- `test_index_view_with_no_questions`
    - 新規質問は作成しない
    - `No polls are available.`というメッセージが表示されるかチェック
    - `latest_question_list`が空になっているか確認
    - `django.test.TestCase`クラスが提供するアサーションメソッドを使用
        - `assertContains()`
        - `assertQuerysetEqual()`
- `test_index_view_with_a_past_question`
    - 質問を作成し、その質問がリストに現れるか検証
- `test_index_view_with_a_future_question`
    - `pub_date`が未来となる質問を作成
        - データベースは各テストメソッドごとにリセットされる
            - 索引ページに、過去作成した質問は残っていない

### 作成したテストを実行

```
(venv) C:\kanban\pollsite>python manage.py test polls
Found 8 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
........
----------------------------------------------------------------------
Ran 8 tests in 0.069s

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

8つのテストケースを一瞬で通し、結果OKです。すごい。

## おわりに

自動テストの作成に手間はかかるけど、
作ってしまえば、アプリを修正する度に再利用できます。
作業効率がぐんと上がりますね。

自動テストでなく、画面上で手動による動作確認はどうでしょうか。
8つのテストケースを一つずつ、手順に従って。。。
ぞっとして、考えたくもありません。

次回も続きます。お楽しみに。

##### [次回] [Django+Reactで学ぶプログラミング基礎(13): Djangoチュートリアル(投票アプリその5-4)](https://qiita.com/mingchun_zhao/items/86b81416e54597abf8df)