LoginSignup
1
2

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-11-23

今回のお題

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

モデル編

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

フォーム編

Formクラス、ModelFormクラスのインスタンスの属性値は後から変更できない。
(テストのみに言えることではないが)FormクラスやModelFormクラスのインスタンスの属性値は後から変更できない。
このため、setUp系のメソッドで共通のフォームを用意した上でテストごとに値を変えたい場合には以下のようにする必要がある(例1)。
各種フィールドの属性値としてどのオブジェクトが入るのかに注意する。
例えばフィールドがDateTimeFieldであればdatetimeオブジェクト、PhonenumberFieldであればphonenumberオブジェクトなど、許容されるオブジェクトはフィールドによって異なる。
当然フォームに値をセットする際も文字列ではなく然るべきオブジェクトを用意する必要がある(図2)。
ただし一部のフィールドに関しては、規定のフォーマットを満たした文字列でも許容される。
図1
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"], ["このフィールドは必須です"])
図2
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)
図3その1
# プロジェクト側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
図3その2
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"> 
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>までに改行タグが追加されたり文字列が特殊文字に置き換わったりしている。

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 ページが見つからない
1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2