【随時更新】djangoの単体テストに必要な予備知識

今回のお題
-
djangoのunittestを書く上で必要になる基礎的な予備知識を列挙していきます。

モデル編
-
<dl><dt>バリデーションは効いていない</dt>
<dd>モデルのテストにおいてバリデーションは効いていない。</dd>
<dd>逆にいえば、リレーション先のインスタンスを仮で用意する場合などは属性値は適当でも許される。</dd></dl>

フォーム編
-
<dl><dt>Formクラス、ModelFormクラスのインスタンスの属性値は後から変更できない。</dt>
<dd>（テストのみに言えることではないが）FormクラスやModelFormクラスのインスタンスの属性値は後から変更できない。</dd>
<dd>このため、setUp系のメソッドで共通のフォームを用意した上でテストごとに値を変えたい場合には以下のようにする必要がある（例１）。</dd>
<dt>各種フィールドの属性値としてどのオブジェクトが入るのかに注意する。</dt>
<dd>例えばフィールドがDateTimeFieldであればdatetimeオブジェクト、PhonenumberFieldであればphonenumberオブジェクトなど、許容されるオブジェクトはフィールドによって異なる。</dd>
<dd>当然フォームに値をセットする際も文字列ではなく然るべきオブジェクトを用意する必要がある（図２）。</dd>
<dd>ただし一部のフィールドに関しては、規定のフォーマットを満たした文字列でも許容される。</dl>

```python:図１
class TestDemo(TestCase):
  def setUp(self):
    self.menu_data = {
      "name": "curry",
      "price": 300,
    }
    self.form = MenuForm(self.menu_data) # これはOK
  # 以下はNG
  """def setUpd(self):
       self.form = MenuForm()
       self.form.name = "curry"
       self.form.price = 300"""
  # バリデーションOK
  def test_menu_form(self):
    self.assertTrue(self.form.is_valid())

  # バリデーションNGにしたい場合は都度menu_dataを書き換える
  def test_menu_form_name(self):
    self.form_data["name"] = ""
    self.form = MenuForm(self.form_data)
    self.assertFalse(self.form.is_valid())
    self.assertEqual(self.form.errors["name"], ["このフィールドは必須です"])
```

```python:図２
from datetime import time
import phonenumbers
class TestShopForm(TestCase):
  def setUp(self):
    self.shop_data = {
      "name": "テスト店舗",
      "open_time": time(10, 0, 0)
      # 以下はOK
      # "open_time" "10:0:0",
      # 以下はNG(誰もしないと思うが）
      # "open_time": "10-0-0",
      "close_time": time(20, 0, 0)
      "phonenumber": phonenumbers.parse("+819012345678", None)
      # 以下はOK
      # "phonenumbers": "+819012345678",
      # 以下はNG
      # phonenumbers: "09012345678",
    }
```

### form.errorsとform.non_field_errors()について
フォームに表示されるバリデーションエラーの内、特定のフィールドに紐づいているもの(フィールドオプション、validator, clean_xxxメソッドなど）は`form.errors["フィールド名"]`で取得可能。

cleanメソッドで取得したものだけはフィールドに紐づかないので`form.non_field_errors()`メソッドで取得する。

```python
self.assertEqual(form.errors["price"], ["このフィールドは必須です。"])
self.assertEqual(form.non_field_errors(), ["割引額が本体価格よりも大きく設定されています"])
```

### CustomUserCreationFormについて

CustomUserCreationFormを定義する際にはフィールド名にpasswordを含める必要がない（含めなくてもパスワード入力欄が自動で用意される）が、フォームのテストをする際にはpassword1とpassword2にも値をセットする必要がある。

```python
class CustomUserCreationForm(UserCreationForm):
  class Meta:
    model = get_user_model()
    fields = ["username", "email", "phonenumber"] # フィールド名としてパスワードを指定していない。
    labels = {
      "username": "お名前"
    }
    error_messages = {
      "email": {
        "unique": "このメールアドレスは既に登録済みです。"
      },
      "phonenumber": {
        "unique": "この電話番号は既に登録済みです。"
      }
    }
```

```python
def test_user_creation_form(self):
  # これはOK
  user_data = {
    "username": "user",
    "email": "email@gmail.com",
    "password1": "0000000a",
    "password2": "0000000a",
  }
  form = CustomUserCreationForm(user_data)
  # これはNG
  user_data = {
    "username": "user",
    "email": "email@gmail.com",
  }
  form = CustomUserCreationForm(user_data)
```

### パスワード関連のバリデーションの種類

パスワード関連のバリデーションには、`blank=false`、`パスワード１と２の不一致`以外にも

- 文字数（最低8文字）
- 英数字が共に使用されているかどうか
- 単純すぎないかどうか

の3つが用意されている。

文字数や英数字混合のバリデーションの検証をする際、用意するパスワードが単純すぎると3つ目のバリデーションにも引っかかってしまいテストがうまくいかなくなることがある。

```python
def test_short_password(self):
  user_data = {
    "username": "user",
    "email": "email@gmail.com",
    "password1": "000000a",
    "password2": "000000a",
  }
  form = CustomUserCreationForm(user_data)
  self.assertFalse(form.is_valid())
  # これはNG
  self.assertEqual(form.errors["password2"], ["このパスワードは短すぎます。最低 8 文字以上必要です。"] 
  # こちらが正解
  self.assertEqual(form.errors["password2"], ["このパスワードは短すぎます。最低 8 文字以上必要です。", "このパスワードは一般的すぎます。"]   
```

### パスワードバリデーションのエラーメッセージの出現場所
基本的にバリデーションエラーのメッセージは`form.errors["フィールド名"]`とすることでリスト形式で取得できる。

しかし、パスワードに関しては以下のルールがあり、フィールド名を正しく指定しないとキーエラーになる。

- blank=false以外のルールについては、`password2`にしかエラーメッセージが表示されない(すなわち文字数などに関してはpassword2でしか検証されていない）。
- blank=falseに関してはpassword1と２の両方で検証され、不備があったフィールド全てでエラーメッセージがセットされる。
- パスワードの不一致に関してはパスワード１と２が両方とも入力されていた場合にのみ検証される。またパスワード不一致と他のエラー（文字数の不足など）が重なった場合にはパスワード不一致のみがエラーメッセージとして表示される。

urls編
-

<dl>
<dt>テンプレート名の指定にはresolveメソッドを用いる</dt>
<dd>resolveメソッドは、urlからビューを検索するためのメソッド</dd>
<dd>ビューからテンプレートクラス名への変換は、resolve(url).func.view_class（図３）</dd></dl>

```python:図３その１
# プロジェクト側urls.py
urlpatterns = [
  path("menu/", include(("menu.urls", "menu"))),
]

# アプリ側urls.py
urlpatterns = [
  path("create/", views.MenuCreateView.as_view(), name="create"),
]

# menu/views.py
class MenuCreateView(CreateView):
  pass
```

```python:図３その２
class TestMenuUrls(TestCase):
  def test_memu_create(self):
    self.assertEqual(resolve("/menu/create/").func.view_class, CreateView)
```
なお、resolveはあくまでも`url`と`テンプレートビュークラス`の対応関係を確認するだけであり、Mixinに引っかかって`403エラー`が出るかどうかなどは見られていない（403エラーが出たとしてもテストは成功扱いになる）。

#### 参考〜reverse, reverse_lazy, resolveの比較

| 関数名 | 役割 | 備考 |
|:-:|:-:|:-:|
| reverse() | ルーティング名→urlへの変換 | ルーティングを通った後に呼びださないとエラーになる |
| reverse_lazy() | ルーティング→urlへの変換 | ルーティングを通るまで処理が遅延される。<br>クラスの属性値などの設定にはこちらを使う。 |
| resolve() | url→ビューへの変換 |  |

views編
-
#### Clientクラス
uittestの中でリクエストを送信する際に用いられるクラス。

`get`または`post`メソッドを用いて、リクエスト時のHTTPメソッドやリクエストパスを指定する。

```python
from django.test import Client
from django.test.testcases import TestCase
from django.urls import reverse
class TestShopCreate(TestCase):
  def test_not_login(self):
    response = Client().get(reverse("shop:create")) 
    # "shop:create"というルーティング名のurlに対してgetメソッドでリクエスト。

  def test_shop_create(self):
    response = Client().post(reverser("shop:create"), {"name": "テスト店舗"})
    # "shop:create"にpostメソッドでアクセスし、その際に"name=テスト店舗"という情報を送信。
```

#### Client().login()
テスト内でログインした状況を再現するためのメソッド。

```python
class MyTestCase(TestCase):
  @classmethod
  def setUpClass(cls):
    MyTestCase.user = get_user_model()(username="testuser")
    MyTestCase.user.set_password("testpassword")
    MyTestCase.user.save()

  def test_mytest(self):
    c = Client()
    c.login(username=self.user.username, password=self.user.password)
```
loginメソッドのキーワード引数としてユーザー名とパスワードを指定することで、そのユーザーでログインした状態になる。

なお、引数として与える項目は`settings.py`で設定しているバックエンド認証の内容によって変わる。

`django.contrib.auth.backends.ModelBackends`を指定している場合は上記の通りユーザー名とパスワードで認証する(`ModelBackends.authenticateで要求しているのがユーザー名とパスワードであるため）。

逆にsettings.pyの`ACCOUNT_AUTHENTICATION_METHOD`の値はここでは影響しない。

#### assertQuerySetEqualメソッド
レスポンスに含まれる`queryset`が期待通りであることを確認するためのメソッド。

引数の形式は

```python
self.assertQuerySetEqual(response.context["キー名"], "querysetオブジェクト"[, order=True])
# querysetオブジェクト同士の順番を無視する場合はorder=falseを指定する。
```

contextのキー名やquerysetオブジェクトの書き方などについては別記事参照。

https://qiita.com/keishi04hrikzira/items/0d7a0cde7ecf5ad9fd19

#### assertRedirectsメソッド
リダイレクトに関する挙動をテストする際には、`assertRedirect`メソッドを用いる。

基本系は以下の通りで、レスポンスとリダイレクト先のurlを与えることで期待通りのリダイレクトがなされているかを検証する（他にもいくつか省略可能なキーワード引数があるが、今回は割愛）。

```python
from django.test import Client
from django.test.testcases import TestCase
class TestShopCreate(TestCase):
  # ログインせずに店舗作成ページに移動するとログインページにリダイレクトされる
  def test_not_login(self):
    response = Client().get(reverse("shop:create"))
    self.assertRedirects(response, f'{reverse("account_login")}?next={reverse("shop:create")}')
```

### assertTemplateUsedメソッド
レスポンスによって返されたテンプレート名を確かめるメソッド。
テンプレート名は`templates`ディレクトリ以下の相対パスで指定する。

```python
self.assertTemplateUsed(response, "user/detail.html")
```
リダイレクト先を調べるのに使う。

### assertFormErrorメソッド
あるレスポンスにおいて発生したフォームエラーを確かめるためのメソッド。

フォームの単体テストでエラーメッセージを調べる場合には`self.assertEqual(form.errors["フィールド名"], xxxx)`とすれば良いが、viewsの単体テストの中で調べたい場合にはこちらのメソッドを用いる。

```python
response = self.client.post(reverse("menu:create"), {"name": "ハンバーグ", "price": "四百"})
self.assertFormError(response, "form", "price", ["価格は半角数字で入力してください"])
# 引数:(response, "フォームをcontextから取り出す際のキー", "エラーを調べるフィールド名", "期待されるエラーメッセージの配列")
# フィールド名をNoneにした場合にはnon_field_errorsが検出される
```

### assertContainsメソッドの注意点
assertContainsメソッドは、第一引数の中に第二引数が含まれているかどうかを確かめるメソッドである。

```python
self.assertContains(response, 'こんにちは')
# レスポンスの中に"こんにちは"という文字列が含まれているかどうかを確認する。
```

このメソッドを用いてテンプレート含まれる要素の属性値を検証する場合には、属性値をシングルクォートではなくダブルクォートで囲む必要がある。

これはdjango側がhtml要素をレンダリングする際に属性値をダブルクォートで囲む処理をしているためであり、この部分をシングルにしてしまうとテストは失敗する。

```html:レスポンスの出力例（一部抜粋）
<!-- 出力方法はprint(response.context) -->
<!-- 要素の属性値は全て""で囲まれている -->
<div id="div_id_username" class="mb-3"> 
  <label for="id_username" class="form-label requiredField">
  \n                \xe3\x81\x8a\xe5\x90\x8d\xe5\x89\x8d
    <span class="asteriskField">*</span> 
  </label> 
  <input type="text" name="username" maxlength="150" autofocus class="textinput textInput form-control" required id="id_username"> 
  <small id="hint_id_username" class="form-text text-muted">
```

```python
# これはOK
self.assertContains(response, '<input type="text" name="username" maxlength="150" autofocus class="textinput textInput form-control" required id="id_username"> '

# これはNG
self.assertContains(response, "<input type='text' name='username' maxlength='150' autofocus class='textinput textInput form-control' required id='id_username'> "
```

### レスポンスコンテントをテストする場合の注意点
あるリクエストに対して返されるテンプレートの中身は`response.content`を用いて取得できる。

このことを利用してテンプレートの表示内容が期待通りかを確かめる場合、html要素のタグ内部もしくはテキストのどちらかのみのテストにとどめることが望ましい。

タグ内部とテキストの両方を一度にテストすると基本的に失敗する。

これは`response.content`の中にはhtml要素には表示されない空白や改行タグなどが含まれていたり、またそもそも文字列の一部がcontentの時点では特殊文字の状態であったりと実際に表示される状態とは異なっており、それらも含めて`assertContains`メソッドの引数に指定しないとテストが成功しないため。

OK/NGパターンや上記理由の具体例については以下を参照。

```html:テンプレート上の表示
  <label for="id_username" class="form-label requiredField">
    お名前
    <span class="asteriskField">*</span> 
  </label> 
  <input type="text" name="username" value="testuser1" maxlength="150" autofocus class="textinput textInput form-control" required id="id_username"> 
```

```html:response.contentの中身
<!-- print(response.content)で出力 -->
<label for="id_username" class="form-label requiredField">\n                \xe3\x81\x8a\xe5\x90\x8d\xe5\x89\x8d<span class="asteriskField">*</span> </label> <input type="text" name="username" value="testuser1" maxlength="150" class="textinput textInput form-control" required id="id_username"> 
```

`<label>`から`<span>`までに改行タグが追加されたり文字列が特殊文字に置き換わったりしている。

```python:OK/NGパターン
# これはOK(html要素のタグの内部のみ）
self.assertContains(response, '<input type="text" name="username" value="testuser1"')

# これもOK(*という文字列はresponse.contentにも含まれているため）
self.assertContains(response, '<span class="asteriskField">*</span>')

# これはNG(content上ではlabelと"お名前"の間に特殊文字などが挟まっているため）
self.assertContains(response, '<label for="id_username" class="form-label requiredField">お名前')
```
なお、NGパターンの"お名前"の部分をresponse.contentの特殊文字に置き換えてもテストは失敗しました。

タグをまたぐテストは控えた方が賢明かもしれないです。

### HTTPステータスコードについて
頻繁に登場するステータスコードは以下の通り。

ここに登場するものについては、`self.assertEqual(response.status_code, xxx)`でリクエスト後の挙動を確認することができる。

なお、バリデーションに失敗した場合に関してはステータスコードは200になるので、ステータスコードを用いてバリデーションの検証をすることはできない。

| コード | 意味 |
|:-:|:-:|
| 200 | リクエスト成功 |
| 301 | 恒久的なリダイレクト |
| 302 | 一時的なリダイレクト |
| 403 | 許可されていないリクエスト |
| 404 | ページが見つからない |