mizzsugar’s blog

日々感じていることや学んだことを書きます。エンジニアリング以外にも書くかもしれません。

【Django】フォームのカスタムバリデーションをテストする

Djangoのフォームで独自のバリデーションを実装した際のテスト方法を紹介します。 間違いがありましたら、ご指摘お願いします(>人<)

今回使ったバージョン

* Python 3.7.1
* Django 2.1.7


今回実装するフォームはこちら

forms.py
import re

from django import forms
from django.core.exceptions import ValidationError


def is_hiragana(value):
    pattern = r'^[ぁ-ん]+$'
    if not re.fullmatch(pattern, value):
        raise ValidationError('ひらがなで書いてください')


def validate_chatwork_id(value):
    pattern = r'^[0-9]+$'
    if not re.fullmatch(pattern, value) or len(value) != 7:
        raise ValidationError('半角数字7文字で書いてください')


class EmployeeForm(forms.Form):
    name = forms.CharField(
        max_length=225,
        label='名前',
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )
    name_kana = forms.CharField(
        max_length=225,
        label='名前(かな)',
        validators=[is_hiragana],
        required=False,
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )
    chatwork_id = forms.CharField(
        label='チャットワークID',
        validators=[validate_chatwork_id],
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )
テストしたいこと
  • 名前(かな)にひらがな以外が含まれていたらエラーを表示したい

  • チャットワークIDが半角数字7文字以外だったらエラーを表示したい


form.is_valid()でTrueかFalseを返すテストは良くみるんだけど、

正しくエラーメッセージが出力されるかの方法がわからん・・・


とりあえず書いてみた。

from django.core.exceptions import ValidationError
from django.test import TestCase
from .forms import EmployeeForm


class TestEmployeeForm(TestCase):
    @classmethod
    def setUpClass(cls):
        pass

    def test_invalid_form_not_hiragana(self):
        params = dict(
            name='サンプル太郎',
            name_kana='サンプルタロウ',
            chatwork_id='0123456'
        )
        form = EmployeeForm(params)
        form.is_valid()
        self.assertEqual(
            'ひらがなで書いてください',
            form.errors
        )

    @classmethod
    def tearDownClass(cls):
        pass


結果、怒られる。

AssertionError: 'ひらがなで書いてください' != {'name_kana': ['ひらがなで書いてください']}


Dict[List]か・・・なら・・・、と試しにやってみる。

    def test_invalid_form_not_hiragana(self):
        params = dict(
            first_name='サンプル太郎',
            first_name_kana='サンプルタロウ',
            chatwork_id='0123456'
        )
        form = EmployeeForm(params)
        form.is_valid()
        self.assertTrue(
            'ひらがなで書いてください' in form.errors['name_kana']
        )


できた!!!


同じ要領でchatwork_idもやってみると

    def test_invalid_form_chatwork_id(self):
        with self.subTest('半角数字以外を含むとエラーになる'):
            params = dict(
                first_name='サンプル太郎',
                first_name_kana='さんぷるたろう',
                chatwork_id='012aaaaa'
            )
            form = EmployeeForm(params)
            form.is_valid()
            self.assertTrue(
                '半角数字7文字で書いてください' in form.errors['chatwork_id']
            )
            
        with self.subTest('7文字以上だとエラーになる'):
            params = dict(
                first_name='サンプル太郎',
                first_name_kana='さんぷるたろう',
                chatwork_id='012345678'
            )
            form = EmployeeForm(params)
            form.is_valid()
            self.assertTrue(
                '半角数字7文字で書いてください' in form.errors['chatwork_id']
            )

        with self.subTest('7文字以内だとエラーになる'):
            params = dict(
                first_name='サンプル太郎',
                first_name_kana='さんぷるたろう',
                chatwork_id='012345'
            )
            form = EmployeeForm(params)
            form.is_valid()
            self.assertTrue(
                '半角数字7文字で書いてください' in form.errors['chatwork_id']
            )


form.errorsが何者かみてみると・・・

print(type(form.errors))  # django.forms.utils.ErrorDictが出力される
print(type(form.errors['name_kana']))  # django.forms.utils.ErrorListが出力される

print(form.errors)  # <ul class="errorlist"><li>last_name<ul class="errorlist"><li>この項目は必須です。</li></ul></li><li>name_kana<ul class="errorlist"><li>ひらがなで書いてください</li></ul></li></ul>
print(form.errors['name_kana'])  # <ul class="errorlist"><li>ひらがなで書いてください</li></ul>

form.errorsはErrorDictというDictを継承したオブジェクトで

form.errorsはErrorListというListを継承したオブジェクトのようです。


ErrorDictのkeyはそのフォームの各フィールドになっているそうです。 ErrorListは各フィールドのエラーの表示方法を決めてるっぽいですね。

print()でulタグが出力されたのは、__str__の処理の中がas_ulだからかと謎が解けました。

https://github.com/django/django/blob/master/django/forms/utils.py


少しテストから話が逸れますが validatorsを使わずclean_field使用してもテストは同じ結果で通りました。

class EmployeeForm(forms.Form):
    name = forms.CharField(
        max_length=225,
        label='名前',
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )
    name_kana = forms.CharField(
        max_length=225,
        label='名前(かな)',
        required=False,
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )
    chatwork_id = forms.CharField(
        label='チャットワークID',
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )

    def clean_name_kana(self):
        pattern = r'^[ぁ-ん]+$'
        name_kana = self.cleaned_data['name_kana']
        if not re.fullmatch(pattern, name_kana):
            raise ValidationError('ひらがなで書いてください')
        return name_kana

    def clean_chatwork_id(self):
        pattern = r'^[0-9]+$'
        chatwork_id = self.cleaned_data['chatwork_id']
        if not re.fullmatch(pattern, chatwork_id) or len(chatwork_id) != 7:
            raise ValidationError('半角数字7文字で書いてください')
        return chatwork_id

ちなみに、validatorとclean_fieldの違いについてはこちらの記事をご参照ください。 validatorはそれぞれのフィールドに入力された値をチェックするけれどもフォーム自体の内容は確認しないっぽいですね。

cleanが通らない場合があると考えると、clean_fieldを使った方が無難そう

hateda.hatenadiary.jp

今回使用したコードまとめ

forms.py

import re

from django import forms
from django.core.exceptions import ValidationError


def is_hiragana(value):
    pattern = r'^[ぁ-ん]+$'
    if not re.fullmatch(pattern, value):
        raise ValidationError('ひらがなで書いてください')


def validate_chatwork_id(value):
    pattern = r'^[0-9]+$'
    if not re.fullmatch(pattern, value) or len(value) != 7:
        raise ValidationError('半角数字7文字で書いてください')


class EmployeeForm(forms.Form):
    name = forms.CharField(
        max_length=225,
        label='名前',
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )
    name_kana = forms.CharField(
        max_length=225,
        label='名前(かな)',
        validators=[is_hiragana],
        required=False,
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )
    chatwork_id = forms.CharField(
        label='チャットワークID',
        validators=[validate_chatwork_id],
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )

"""こっちでも良い

class EmployeeForm(forms.Form):
    name = forms.CharField(
        max_length=225,
        label='名前',
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )
    name_kana = forms.CharField(
        max_length=225,
        label='名前(かな)',
        required=False,
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )
    chatwork_id = forms.CharField(
        label='チャットワークID',
        widget=forms.TextInput(attrs={'class': 'form-control'})
    )
    
    def clean_name_kana(self):
        pattern = r'^[ぁ-ん]+$'
        name_kana = self.cleaned_data['name_kana']
        if not re.fullmatch(pattern, name_kana):
            raise ValidationError('ひらがなで書いてください')
        return name_kana
    
    def clean_chatwork_id(self):
        pattern = r'^[0-9]+$'
        chatwork_id = self.cleaned_data['chatwork_id']
        if not re.fullmatch(pattern, chatwork_id) or len(chatwork_id) != 7:
            raise ValidationError('半角数字7文字で書いてください')
        return chatwork_id

 """


test_forms.py(ちょっとテスト増やして整えた)

from django.core.exceptions import ValidationError
from django.test import TestCase
from .forms import EmployeeForm


class TestEmployeeForm(TestCase):
    @classmethod
    def setUpClass(cls):
        pass

    def test_valid_form(self):
        params = dict(
            name='サンプル太郎',
            name_kana='さんぷるたろう',
            chatwork_id='0123456'
        )
        form = EmployeeForm(params)
        self.assertTrue(form.is_valid())

    def test_invalid_form_not_hiragana(self):
        params = dict(
            first_name='サンプル太郎',
            first_name_kana='サンプルタロウ',
            chatwork_id='0123456'
        )
        form = EmployeeForm(params)
        form.is_valid()

        with self.subTest('フォームのバリーデーションで妥当ではないと返される'):
            self.assertFalse(form.is_valid())

        with self.subTest('ひらがなで書けというエラーになる'):
            self.assertTrue(
                'ひらがなで書いてください' in form.errors['name_kana']
            )

        self.assertTrue(
            'ひらがなで書いてください' in form.errors['name_kana']
        )

    def test_invalid_form_chatwork_id_has_charactor(self):
        params = dict(
            name='サンプル太郎',
            name_kana='さんぷるたろう',
            chatwork_id='012aaaaa'
        )
        form = EmployeeForm(params)
        form.is_valid()
        with self.subTest('半角数字以外を含むとフォームのバリーデーションで妥当ではないと返される'):
            self.assertFalse(form.is_valid())

        with self.subTest('半角数字以外を含むと半角数字7文字で書けというエラーになる'):
            self.assertTrue(
                '半角数字7文字で書いてください' in form.errors['chatwork_id']
            )

    def test_invalid_form_chatwork_id_is_over_7(self):
        params = dict(
            name='サンプル太郎',
            name_kana='さんぷるたろう',
            chatwork_id='012345678'
        )
        form = EmpForm(params)
        form.is_valid()

        with self.subTest('7文字以上だとフォームのバリーデーションで妥当ではないと返される'):
            self.assertFalse(form.is_valid())

        with self.subTest('7文字以上だと半角数字7文字で書けというエラーになる'):
            self.assertTrue(
                '半角数字7文字で書いてください' in form.errors['chatwork_id']
            )

    def test_invalid_form_chatwork_id_is_less_than_7(self):
        params = dict(
            name='サンプル太郎',
            name_kana='さんぷるたろう',
            chatwork_id='012345'
        )
        form = EmpForm(params)
        form.is_valid()
        with self.subTest('7文字以内だとフォームのバリーデーションで妥当ではないと返される'):
            self.assertFalse(form.is_valid())

        with self.subTest('7文字以内だと半角数字7文字で書けというエラーになる'):
            self.assertTrue(
                '半角数字7文字で書いてください' in form.errors['chatwork_id']
            )

    @classmethod
    def tearDownClass(cls):
        pass