Forms¶
Adding a Comment form¶
To allow users to create comments we need to accept a form submission. HTML forms are the most common method used to accept user input on web sites and send that data to a server. We can use Django’s form framework for this task.
First let’s write some tests. We’ll need to create a blog Entry
and a User
for our tests. Let’s make a setup method for our
tests which creates an entry and adds it to the database. The setup
method is called before each test in the given test class so that each
test will be able to use the User
and Entry
.
class CommentFormTest(TestCase):
def setUp(self):
user = get_user_model().objects.create_user('zoidberg')
self.entry = Entry.objects.create(author=user, title="My entry title")
Let’s make sure we’ve imported CommentForm
in our tests file. Our
imports should look like this:
from django.test import TestCase
from django.contrib.auth import get_user_model
from .forms import CommentForm
from .models import Entry, Comment
Before we start testing our form remember that we are writing our tests before actually writing our CommentForm code. In other words, we’re pretending that we’ve already written our code in the way that we want it to work, then we’re writing tests for that not-yet-written code. Once we’ve seen that the tests have failed, we then write the actual code. Lastly, we run the tests again against our implemented code and, if necessary, modify the actual code so the tests run successfully.
Our first test should ensure that our form’s __init__
accepts an
entry
keyword argument:
def test_init(self):
CommentForm(entry=self.entry)
We want to link our comments to entries by allowing our form to accept
an entry
keyword argument. Assuming our CommentForm
has been
written this is how we’d like to use it
(you don’t need to type this code anywhere):
>>> form = CommentForm(entry=entry) # Without form data
>>> form = CommentForm(request.POST, entry=entry) # with form data
Our next test should ensure that our form raises an exception if an
entry
keyword argument isn’t specified:
def test_init_without_entry(self):
with self.assertRaises(KeyError):
CommentForm()
Let’s run our tests:
$ python manage.py test blog
ImportError: No module named 'blog.forms'
We haven’t created our forms file yet so our import is failing. Let’s
create an empty blog/forms.py
file.
Now we get:
$ python manage.py test blog
ImportError: cannot import name 'CommentForm'
We need to create our CommentForm
model form in blog/forms.py
.
This form will process the data sent from users trying to comment on a
blog entry and ensure that it can be saved to our blog database. Let’s
start with something simple:
from django import forms
from .models import Comment
class CommentForm(forms.ModelForm):
class Meta:
model = Comment
fields = ('name', 'email', 'body')
Here we have created a simple form associated with our Comment model and we have specified that the form handle only a subset of all of the fields on the comment.
Important
Django forms are a powerful way to handle HTML forms. They provide
a unified way to check submissions against validation rules and
in the case of ModelForm
subclasses, share any of the associated
model’s validators. In our example, this will ensure that the
Comment email
is a valid email address.
Now our tests should fail because the entry
keyword argument is not
accepted nor required:
$ python manage.py test blog
Creating test database for alias 'default'...
EF.............
======================================================================
ERROR: test_init (blog.tests.CommentFormTest)
----------------------------------------------------------------------
Traceback (most recent call last):
...
TypeError: __init__() got an unexpected keyword argument 'entry'
======================================================================
FAIL: test_init_without_entry (blog.tests.CommentFormTest)
----------------------------------------------------------------------
Traceback (most recent call last):
...
AssertionError: KeyError not raised
----------------------------------------------------------------------
Ran 15 tests in 0.097s
FAILED (failures=1, errors=1)
Destroying test database for alias 'default'...
Our two form tests fail as expected. Let’s create a couple more tests for our form before we start fixing it. We should create at least two tests to make sure our form validation works:
- Ensure that
form.is_valid()
isTrue
for a form submission with valid data - Ensure that
form.is_valid()
isFalse
for a form submission with invalid data (preferably a separate test for each type of error)
This is a good start:
def test_valid_data(self):
form = CommentForm({
'name': "Turanga Leela",
'email': "leela@example.com",
'body': "Hi there",
}, entry=self.entry)
self.assertTrue(form.is_valid())
comment = form.save()
self.assertEqual(comment.name, "Turanga Leela")
self.assertEqual(comment.email, "leela@example.com")
self.assertEqual(comment.body, "Hi there")
self.assertEqual(comment.entry, self.entry)
def test_blank_data(self):
form = CommentForm({}, entry=self.entry)
self.assertFalse(form.is_valid())
self.assertEqual(form.errors, {
'name': ['required'],
'email': ['required'],
'body': ['required'],
})
It’s usually better to test too much than to test too little.
Okay now let’s finally write our form code.
from django import forms
from .models import Comment
class CommentForm(forms.ModelForm):
class Meta:
model = Comment
fields = ('name', 'email', 'body')
def __init__(self, *args, **kwargs):
self.entry = kwargs.pop('entry') # the blog entry instance
super().__init__(*args, **kwargs)
def save(self):
comment = super().save(commit=False)
comment.entry = self.entry
comment.save()
return comment
The CommentForm
class is instantiated by passing the blog entry that the
comment was written against as well as the HTTP POST data containing the
remaining fields such as comment body and email. The save
method is
overridden here to set the associated blog entry before saving the comment.
Let’s run our tests again to see whether they pass:
$ python manage.py test blog
Creating test database for alias 'default'...
F................
======================================================================
FAIL: test_blank_data (blog.tests.CommentFormTest)
----------------------------------------------------------------------
Traceback (most recent call last):
...
AssertionError: {'name': ['This field is required.'], 'email': ['Thi[55 chars]d.']} != {'name': ['required'], 'email': ['required'], 'body': ['required']}
----------------------------------------------------------------------
Ran 17 tests in 0.178s
FAILED (failures=1)
Destroying test database for alias 'default'...
Our test for blank form data is failing because we aren’t checking for the correct error strings. Let’s fix that and make sure our tests pass:
$ python manage.py test blog
Creating test database for alias 'default'...
.................
----------------------------------------------------------------------
Ran 17 tests in 0.179s
OK
Destroying test database for alias 'default'...
Displaying the comment form¶
We’ve made a form to create comments, but we still don’t yet have a way for visitors to use the form. The Django test client cannot test form submissions, but WebTest can. We’ll use django-webtest to handle testing the form submission.
Let’s create a test to verify that a form is displayed on our blog entry detail page.
First we need to import the WebTest
class (in blog/tests.py
):
from django_webtest import WebTest
Now let’s make our EntryViewTest
class inherit from WebTest
.
Change our EntryViewTest
to inherit from WebTest
instead of
from TestCase
:
class EntryViewTest(WebTest):
Caution
Do not create a new EntryViewTest
class. We already have
an EntryViewTest
class with tests in it. If we create a new
one, our old class will be overwritten and those tests won’t run
anymore. All we want to do is change the parent class for our test
from TestCase
to WebTest
.
Our tests should continue to pass after this because WebTest
is a
subclass of the Django TestCase
class that we were using before.
Now let’s add a test to this class:
def test_view_page(self):
page = self.app.get(self.entry.get_absolute_url())
self.assertEqual(len(page.forms), 1)
Now let’s update our EntryDetail
view (in blog/views.py
) to
inherit from CreateView
so we can use it to handle submissions to a
CommentForm
:
from django.shortcuts import get_object_or_404
from django.views.generic import CreateView
from .forms import CommentForm
from .models import Entry
class EntryDetail(CreateView):
model = Entry
template_name = 'blog/entry_detail.html'
form_class = CommentForm
Now if we run our test we’ll see 6 failures. Our blog entry detail view
is failing to load the page because we aren’t passing an entry
keyword argument to our form:
$ python manage.py test
Creating test database for alias 'default'...
........EEEEEE....
======================================================================
ERROR: test_basic_view (blog.tests.EntryViewTest)
----------------------------------------------------------------------
...
KeyError: 'entry'
----------------------------------------------------------------------
Ran 18 tests in 0.079s
FAILED (errors=6)
Destroying test database for alias 'default'...
Let’s get the Entry
from the database and pass it to our form. We
need to add a get_form_kwargs
method, and a get_context_data
method to our view:
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['entry'] = self.entry
return kwargs
def get_context_data(self, **kwargs):
d = super().get_context_data(**kwargs)
d['entry'] = self.get_object()
return d
Now when we run our tests we’ll see the following assertion error because we have not yet added the comment form to our blog detail page:
$ python manage.py test blog
Creating test database for alias 'default'...
.............F....
======================================================================
FAIL: test_view_page (blog.tests.EntryViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
...
AssertionError: 0 != 1
----------------------------------------------------------------------
Ran 18 tests in 0.099s
FAILED (failures=1)
Destroying test database for alias 'default'...
Let’s add a comment form to the bottom of our content
block in our
blog entry detail template (templates/blog/entry_detail.html
):
<h5>Add a comment</h5>
<form method="post">
{{ form.as_table }}
<input type="submit" value="Create Comment">
</form>
Now our tests pass again:
$ python manage.py test
Creating test database for alias 'default'...
..................
----------------------------------------------------------------------
Ran 18 tests in 0.106s
OK
Destroying test database for alias 'default'...
Let’s test that our form actually submits. We should write two tests
in our EntryViewTest
: one to test for errors, and one to test a
successful form submission.
def test_form_error(self):
page = self.app.get(self.entry.get_absolute_url())
page = page.form.submit()
self.assertContains(page, "This field is required.")
def test_form_success(self):
page = self.app.get(self.entry.get_absolute_url())
page.form['name'] = "Phillip"
page.form['email'] = "phillip@example.com"
page.form['body'] = "Test comment body."
page = page.form.submit()
self.assertRedirects(page, self.entry.get_absolute_url())
Now let’s run our tests:
$ python manage.py test blog
Creating test database for alias 'default'...
............EE......
======================================================================
ERROR: test_form_error (blog.tests.EntryViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
...
webtest.app.AppError: Bad response: 403 FORBIDDEN (not 200 OK or 3xx redirect for http://localhost/1/)
b'\n<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta http-equiv="content-type" content="text/html; charset=utf-8">\n <meta name="robots" content="NONE,NOARCHIVE">\n <title>403 Forbidden</title>\n <style type="text/css">\n html * { padding:0; margin:0; }\n body * { padding:10px 20px; }\n body * * { padding:0; }\n body { font:small sans-serif; background:#eee; }\n body>div { border-bottom:1px solid #ddd; }\n h1 { font-weight:normal; margin-bottom:.4em; }\n h1 span { font-size:60%; color:#666; font-weight:normal; }\n #info { background:#f6f6f6; }\n #info ul { margin: 0.5em 4em; }\n #info p, #summary p { padding-top:10px; }\n #summary { background: #ffc; }\n #explanation { background:#eee; border-bottom: 0px none; }\n </style>\n</head>\n<body>\n<div id="summary">\n <h1>Forbidden <span>(403)</span></h1>\n <p>CSRF verification failed. Request aborted.</p>\n\n\n <p>You are seeing this message because this site requires a CSRF cookie when submitting forms. This cookie is required for security reasons, to ensure that your browser is not being hijacked by third parties.</p>\n <p>If you have configured your browser to disable cookies, please re-enable them, at least for this site, or for 'same-origin' requests.</p>\n\n</div>\n\n<div id="explanation">\n <p><small>More information is available with DEBUG=True.</small></p>\n</div>\n\n</body>\n</html>\n'
======================================================================
ERROR: test_form_success (blog.tests.EntryViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
...
webtest.app.AppError: Bad response: 403 FORBIDDEN (not 200 OK or 3xx redirect for http://localhost/1/)
b'\n<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta http-equiv="content-type" content="text/html; charset=utf-8">\n <meta name="robots" content="NONE,NOARCHIVE">\n <title>403 Forbidden</title>\n <style type="text/css">\n html * { padding:0; margin:0; }\n body * { padding:10px 20px; }\n body * * { padding:0; }\n body { font:small sans-serif; background:#eee; }\n body>div { border-bottom:1px solid #ddd; }\n h1 { font-weight:normal; margin-bottom:.4em; }\n h1 span { font-size:60%; color:#666; font-weight:normal; }\n #info { background:#f6f6f6; }\n #info ul { margin: 0.5em 4em; }\n #info p, #summary p { padding-top:10px; }\n #summary { background: #ffc; }\n #explanation { background:#eee; border-bottom: 0px none; }\n </style>\n</head>\n<body>\n<div id="summary">\n <h1>Forbidden <span>(403)</span></h1>\n <p>CSRF verification failed. Request aborted.</p>\n\n\n <p>You are seeing this message because this site requires a CSRF cookie when submitting forms. This cookie is required for security reasons, to ensure that your browser is not being hijacked by third parties.</p>\n <p>If you have configured your browser to disable cookies, please re-enable them, at least for this site, or for 'same-origin' requests.</p>\n\n</div>\n\n<div id="explanation">\n <p><small>More information is available with DEBUG=True.</small></p>\n</div>\n\n</body>\n</html>\n'
----------------------------------------------------------------------
Ran 20 tests in 0.110s
FAILED (errors=2)
Destroying test database for alias 'default'...
We got a HTTP 403 error because we forgot to add the cross-site request forgery token to our form. Every HTTP POST request made to our Django site needs to include a CSRF token. Let’s change our form to add a CSRF token field to it:
<form method="post">
{% csrf_token %}
{{ form.as_table }}
<input type="submit" value="Create Comment">
</form>
Now only one test fails:
$ python manage.py test blog
Creating test database for alias 'default'...
.............E......
======================================================================
ERROR: test_form_success (blog.tests.EntryViewTest)
----------------------------------------------------------------------
Traceback (most recent call last):
...
AttributeError: 'Comment' object has no attribute 'get_absolute_url'
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
...
django.core.exceptions.ImproperlyConfigured: No URL to redirect to. Either provide a url or define a get_absolute_url method on the Model.
----------------------------------------------------------------------
Ran 20 tests in 0.141s
FAILED (errors=1)
Destroying test database for alias 'default'...
Let’s fix this by adding a get_success_url
to our view, EntryDetail
, in blog/views.py
:
def get_success_url(self):
return self.get_object().get_absolute_url()
Now our tests pass again and we can submit comments as expected.