もた日記

くだらないことを真面目にやる

Djangoメモ(17) : フォームAPIを使用したフォームの作成とテスト

Python 3.6.4 Django 2.0.2

A Complete Beginner's Guide to Djangoのチュートリアルを参考にフォームAPIを使用してフォームを作成してみる。


フォームのテストを追加

前回フォームAPIを使用せずにフォームを作成したので、まずはそれに対するテストを追加する。
boards/tests.pyNewTopicTestsを以下のように編集する。

from django.urls import reverse, resolve
from django.test import TestCase
from django.contrib.auth.models import User
from .views import home, board_topics, new_topic
from .models import Board, Topic, Post

class NewTopicTests(TestCase):
    def setUp(self):
        Board.objects.create(name='Django', description='Django board.')
        User.objects.create_user(username='john', email='john@doe.com', password='123')  # <- included this line here

    # ...

    def test_csrf(self):
        url = reverse('new_topic', kwargs={'pk': 1})
        response = self.client.get(url)
        self.assertContains(response, 'csrfmiddlewaretoken')

    def test_new_topic_valid_post_data(self):
        url = reverse('new_topic', kwargs={'pk': 1})
        data = {
            'subject': 'Test title',
            'message': 'Lorem ipsum dolor sit amet'
        }
        response = self.client.post(url, data)
        self.assertTrue(Topic.objects.exists())
        self.assertTrue(Post.objects.exists())

    def test_new_topic_invalid_post_data(self):
        '''
        Invalid post data should not redirect
        The expected behavior is to show the form again with validation errors
        '''
        url = reverse('new_topic', kwargs={'pk': 1})
        response = self.client.post(url, {})
        self.assertEquals(response.status_code, 200)

    def test_new_topic_invalid_post_data_empty_fields(self):
        '''
        Invalid post data should not redirect
        The expected behavior is to show the form again with validation errors
        '''
        url = reverse('new_topic', kwargs={'pk': 1})
        data = {
            'subject': '',
            'message': ''
        }
        response = self.client.post(url, data)
        self.assertEquals(response.status_code, 200)
        self.assertFalse(Topic.objects.exists())
        self.assertFalse(Post.objects.exists())

それぞれのテストの意味は以下の通り。

  • setUp : User.objects.create_userでテストで使用するUserインスタンスを作成する
  • test_csrf : CSRF対策用のCSRFトークンが含まれているか確認する
  • test_new_topic_valid_post_data : 適切なデータがPOSTされた場合にTopic, Postインスタンスが存在することを確認する
  • test_new_topic_invalid_post_data : データなしでPOSTされた場合にステータスコードが200であることを確認する(バリデーションエラーと共にフォームが再表示されることを期待しているので)
  • test_new_topic_invalid_post_data_empty_fields : 空文字のデータがPOSTされた場合にステータスコードが200であることとTopic, Postインスタンスが存在しないことを確認する

現在の実装に対してテストを実行すると1 ERROR、1 FAILEDになる。
両方とも入力データのバリデーションに関することで、フォームAPIを使用せずに作成したフォームではバリデーションが考慮されていない。
以下、これらのテストが通るようにフォームAPIを使用してフォームを作成するように変更する。

$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.........EF....
======================================================================
ERROR: test_new_topic_invalid_post_data (boards.tests.NewTopicTests)
----------------------------------------------------------------------
Traceback (most recent call last):
...
django.utils.datastructures.MultiValueDictKeyError: 'subject'

======================================================================
FAIL: test_new_topic_invalid_post_data_empty_fields (boards.tests.NewTopicTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/vagrant/django/myproject/myproject/boards/tests.py", line 112, in test_new_topic_invalid_post_data_empty_fields
    self.assertEquals(response.status_code, 200)
AssertionError: 302 != 200

----------------------------------------------------------------------
Ran 15 tests in 1.243s

FAILED (failures=1, errors=1)
Destroying test database for alias 'default'...


フォームAPI : ビュー側

フォームAPIはdjango.formsモジュールで利用できforms.Formforms.ModelFormの2種類のフォームがある。
forms.Formは直接モデルと対応していないフォームの作成にも使える汎用的なクラスで、forms.ModelFormはモデルに対応した適切なフィールドのフォームを簡単に作成できるクラス。
今回はTopicモデルに対応したフォームを作成するのでforms.ModelFormを使用して下記内容のboards/forms.pyを作成する。

from django import forms
from .models import Topic

class NewTopicForm(forms.ModelForm):
    message = forms.CharField(widget=forms.Textarea(), max_length=4000)

    class Meta:
        model = Topic
        fields = ['subject', 'message']

class Meta内のmodel = TopicでTopicモデルと対応していること示しており、fields内のsubjectはTopicモデルのsubjectと対応する。
messageはTopicモデルではなくPostモデルに含まれるフィールドなのでmessage = forms.CharField()のように明示的に指定する必要がある。

次にboards/views.pyを以下のように修正する。

from django.contrib.auth.models import User
from django.shortcuts import render, redirect, get_object_or_404
from .forms import NewTopicForm
from .models import Board, Topic, Post

def new_topic(request, pk):
    board = get_object_or_404(Board, pk=pk)
    user = User.objects.first()  # TODO: get the currently logged in user
    if request.method == 'POST':
        form = NewTopicForm(request.POST)
        if form.is_valid():
            topic = form.save(commit=False)
            topic.board = board
            topic.starter = user
            topic.save()
            post = Post.objects.create(
                message=form.cleaned_data.get('message'),
                topic=topic,
                created_by=user
            )
            return redirect('board_topics', pk=board.pk)  # TODO: redirect to the created topic page
    else:
        form = NewTopicForm()
    return render(request, 'new_topic.html', {'board': board, 'form': form})

if request.method == 'POST':の分岐はPOSTメソッドの場合、つまりフォームで入力されて送信された場合はこの処理を通りform = NewTopicForm(request.POST)により送信されたデータでインスタンスが生成される。
一方、GETメソッドだった場合、つまりブラウザで普通にアクセスした場合はNewTopicForm()が初期化されrender()により空のフォームが表示されることになる。

POSTメソッドの場合は次にif form.is_valid():によりデータのバリデーションを実行する。バリデーションに成功した後はtopicpostに送信されたデータをセットしてデータベースに保存し、別ページへリダイレクトする。
バリデーションに失敗した場合は、フォームインスタンスにバリデーションエラーが追加された後でrender()が呼ばれるので、バリデーションエラーメッセージありのフォームが表示される。

先ほどのテストを実行してみると全部のテストが通ることが確認できる。

$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...............
----------------------------------------------------------------------
Ran 15 tests in 1.177s

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


フォームAPI : テンプレート側

テンプレート側でもフォームAPIを使用するようにtemplates/new_topic.htmlを変更する。

{% extends 'base.html' %}

{% block title %}Start a New Topic{% endblock %}

{% block breadcrumb %}
  <li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li>
  <li class="breadcrumb-item"><a href="{% url 'board_topics' board.pk %}">{{ board.name }}</a></li>
  <li class="breadcrumb-item active">New topic</li>
{% endblock %}

{% block content %}
  <form method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit" class="btn btn-success">Post</button>
  </form>
{% endblock %}

複数行書いていた処理が{{ form.as_p }}の1行だけで置き換えられる。
ページにアクセスするとフォームが表示されるが、Bootstrapを適用した書き方ではないので見た目は悪くなる(後で修正する)。

f:id:wonder-wall:20180318000356p:plain

HTMLの出力方法にはas_pの他にas_ul, as_tableもありそれぞれの出力は以下の通り。

as_pの場合(クリックで展開)

<p>
    <label for="id_subject">Subject:</label>
    <input type="text" name="subject" maxlength="255" required id="id_subject" />
</p>
<p>
    <label for="id_message">Message:</label>
    <textarea name="message" cols="40" rows="10" maxlength="4000" required id="id_message">
    </textarea>
</p>

as_ulの場合(クリックで展開)

<li>
    <label for="id_subject">Subject:</label>
    <input type="text" name="subject" maxlength="255" required id="id_subject" />
</li>
<li>
    <label for="id_message">Message:</label>
    <textarea name="message" cols="40" rows="10" maxlength="4000" required id="id_message">
    </textarea>
</li>

as_tableの場合(クリックで展開)

<tr>
    <th>
        <label for="id_subject">Subject:</label>
    </th>
    <td>
        <input type="text" name="subject" maxlength="255" required id="id_subject" />
    </td>
</tr>
<tr>
    <th>
        <label for="id_message">Message:</label>
    </th>
    <td>
        <textarea name="message" cols="40" rows="10" maxlength="4000" required id="id_message">
        </textarea>
    </td>
</tr>


見た目は悪くてもフォームとしては動作するので、バリデーションが動作するか何も入力せずにPostボタンをクリックしてみる。

f:id:wonder-wall:20180318000920p:plain

上記のメッセージが表示されたが、これはDjangoではなくブラウザ(上の図はChrome)が出しているメッセージのため、Djangoの動作が確認できるようにnovalidate属性を追加して無効にする。

<form method="post" novalidate>

再度PostボタンをクリックするとDjangoが出しているバリデーションエラーメッセージが確認できる。

f:id:wonder-wall:20180318001119p:plain

なお、Formクラスでは以下のようにヘルプテキスト、その他の属性(プレースホルダなど)を指定することができる。

from django import forms
from .models import Topic

class NewTopicForm(forms.ModelForm):
    message = forms.CharField(
        widget=forms.Textarea(
            attrs={'rows': 5, 'placeholder': 'What is on your mind?'}
        ),
        max_length=4000,
        help_text='The max length of the text is 4000.'
    )

    class Meta:
        model = Topic
        fields = ['subject', 'message']

f:id:wonder-wall:20180318001501p:plain


まとめ

  • forms.Formforms.ModelFormの2種類のフォームがある
  • forms.ModelFormはモデルに対応したフォームを作成する
  • is_valid()でバリデーションの結果を確認できる
  • {{ form.as_p }}でフォームのHTMLを生成できる
  • as_pの他にas_ul, as_tableもある