今回のお題
djangoのunittestを書く上で必要になる基礎的な予備知識を列挙していきます。
モデル編
- バリデーションは効いていない
- モデルのテストにおいてバリデーションは効いていない。
- 逆にいえば、リレーション先のインスタンスを仮で用意する場合などは属性値は適当でも許される。
フォーム編
- Formクラス、ModelFormクラスのインスタンスの属性値は後から変更できない。
- (テストのみに言えることではないが)FormクラスやModelFormクラスのインスタンスの属性値は後から変更できない。
- このため、setUp系のメソッドで共通のフォームを用意した上でテストごとに値を変えたい場合には以下のようにする必要がある(例1)。
- 各種フィールドの属性値としてどのオブジェクトが入るのかに注意する。
- 例えばフィールドがDateTimeFieldであればdatetimeオブジェクト、PhonenumberFieldであればphonenumberオブジェクトなど、許容されるオブジェクトはフィールドによって異なる。
- 当然フォームに値をセットする際も文字列ではなく然るべきオブジェクトを用意する必要がある(図2)。
- ただし一部のフィールドに関しては、規定のフォーマットを満たした文字列でも許容される。
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"], ["このフィールドは必須です"])
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()
メソッドで取得する。
self.assertEqual(form.errors["price"], ["このフィールドは必須です。"])
self.assertEqual(form.non_field_errors(), ["割引額が本体価格よりも大きく設定されています"])
CustomUserCreationFormについて
CustomUserCreationFormを定義する際にはフィールド名にpasswordを含める必要がない(含めなくてもパスワード入力欄が自動で用意される)が、フォームのテストをする際にはpassword1とpassword2にも値をセットする必要がある。
class CustomUserCreationForm(UserCreationForm):
class Meta:
model = get_user_model()
fields = ["username", "email", "phonenumber"] # フィールド名としてパスワードを指定していない。
labels = {
"username": "お名前"
}
error_messages = {
"email": {
"unique": "このメールアドレスは既に登録済みです。"
},
"phonenumber": {
"unique": "この電話番号は既に登録済みです。"
}
}
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
、パスワード1と2の不一致
以外にも
- 文字数(最低8文字)
- 英数字が共に使用されているかどうか
- 単純すぎないかどうか
の3つが用意されている。
文字数や英数字混合のバリデーションの検証をする際、用意するパスワードが単純すぎると3つ目のバリデーションにも引っかかってしまいテストがうまくいかなくなることがある。
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と2の両方で検証され、不備があったフィールド全てでエラーメッセージがセットされる。
- パスワードの不一致に関してはパスワード1と2が両方とも入力されていた場合にのみ検証される。またパスワード不一致と他のエラー(文字数の不足など)が重なった場合にはパスワード不一致のみがエラーメッセージとして表示される。
urls編
- テンプレート名の指定にはresolveメソッドを用いる
- resolveメソッドは、urlからビューを検索するためのメソッド
- ビューからテンプレートクラス名への変換は、resolve(url).func.view_class(図3)
# プロジェクト側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
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への変換 | ルーティングを通るまで処理が遅延される。 クラスの属性値などの設定にはこちらを使う。 |
resolve() | url→ビューへの変換 |
views編
Clientクラス
uittestの中でリクエストを送信する際に用いられるクラス。
get
またはpost
メソッドを用いて、リクエスト時のHTTPメソッドやリクエストパスを指定する。
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()
テスト内でログインした状況を再現するためのメソッド。
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
が期待通りであることを確認するためのメソッド。
引数の形式は
self.assertQuerySetEqual(response.context["キー名"], "querysetオブジェクト"[, order=True])
# querysetオブジェクト同士の順番を無視する場合はorder=falseを指定する。
contextのキー名やquerysetオブジェクトの書き方などについては別記事参照。
assertRedirectsメソッド
リダイレクトに関する挙動をテストする際には、assertRedirect
メソッドを用いる。
基本系は以下の通りで、レスポンスとリダイレクト先のurlを与えることで期待通りのリダイレクトがなされているかを検証する(他にもいくつか省略可能なキーワード引数があるが、今回は割愛)。
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
ディレクトリ以下の相対パスで指定する。
self.assertTemplateUsed(response, "user/detail.html")
リダイレクト先を調べるのに使う。
assertFormErrorメソッド
あるレスポンスにおいて発生したフォームエラーを確かめるためのメソッド。
フォームの単体テストでエラーメッセージを調べる場合にはself.assertEqual(form.errors["フィールド名"], xxxx)
とすれば良いが、viewsの単体テストの中で調べたい場合にはこちらのメソッドを用いる。
response = self.client.post(reverse("menu:create"), {"name": "ハンバーグ", "price": "四百"})
self.assertFormError(response, "form", "price", ["価格は半角数字で入力してください"])
# 引数:(response, "フォームをcontextから取り出す際のキー", "エラーを調べるフィールド名", "期待されるエラーメッセージの配列")
# フィールド名をNoneにした場合にはnon_field_errorsが検出される
assertContainsメソッドの注意点
assertContainsメソッドは、第一引数の中に第二引数が含まれているかどうかを確かめるメソッドである。
self.assertContains(response, 'こんにちは')
# レスポンスの中に"こんにちは"という文字列が含まれているかどうかを確認する。
このメソッドを用いてテンプレート含まれる要素の属性値を検証する場合には、属性値をシングルクォートではなくダブルクォートで囲む必要がある。
これはdjango側が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">
# これは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パターンや上記理由の具体例については以下を参照。
<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">
<!-- 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>
までに改行タグが追加されたり文字列が特殊文字に置き換わったりしている。
# これは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 | ページが見つからない |