At the end of the last chapter, we were left with the thought that there was too much duplication of code in the validation handling bits of our views. Django encourages you to use form classes to do the work of validating user input, and choosing what error messages to display. Let’s see how that works.
As we go through the chapter, we’ll also spend a bit of time tidying up our unit tests, and making sure each of them tests only one thing at a time.
Tip
|
In Django, a complex view is a code smell. Could some of that logic be pushed out to a form? Or to some custom methods on the model class? Or maybe even to a non-Django module that represents your business logic? |
Forms have several superpowers in Django:
-
They can process user input and validate it for errors.
-
They can be used in templates to render HTML input elements, and error messages too.
-
And, as we’ll see later, some of them can even save data to the database for you.
You don’t have to use all three form superpowers in every form. You may prefer to roll your own HTML, or do your own saving. But they are an excellent place to keep validation logic.
Let’s do a little experimenting with forms by using a unit test. My plan is to iterate towards a complete solution, and hopefully introduce forms gradually enough that they’ll make sense if you’ve never seen them before.
First we add a new file for our form unit tests, and we start with a test that just looks at the form HTML:
from django.test import TestCase
from lists.forms import ItemForm
class ItemFormTest(TestCase):
def test_form_renders_item_text_input(self):
form = ItemForm()
self.fail(form.as_p())
form.as_p()
renders the form as HTML. This unit test is using a self.fail
for some exploratory coding. You could just as easily use a manage.py shell
session, although you’d need to keep reloading your code for each change.
Let’s make a minimal form. It inherits from the base Form
class, and has
a single field called item_text
:
from django import forms
class ItemForm(forms.Form):
item_text = forms.CharField()
We now see a failure message which tells us what the autogenerated form HTML will look like:
self.fail(form.as_p()) AssertionError: <p><label for="id_item_text">Item text:</label> <input type="text" name="item_text" required id="id_item_text" /></p>
It’s already pretty close to what we have in 'base.html'. We’re missing the placeholder attribute and the Bootstrap CSS classes. Let’s make our unit test into a test for that:
class ItemFormTest(TestCase):
def test_form_item_input_has_placeholder_and_css_classes(self):
form = ItemForm()
self.assertIn('placeholder="Enter a to-do item"', form.as_p())
self.assertIn('class="form-control input-lg"', form.as_p())
That gives us a fail which justifies some real coding. How can we customise the input for a form field? Using a "widget". Here it is with just the placeholder:
class ItemForm(forms.Form):
item_text = forms.CharField(
widget=forms.fields.TextInput(attrs={
'placeholder': 'Enter a to-do item',
}),
)
That gives:
AssertionError: 'class="form-control input-lg"' not found in '<p><label for="id_item_text">Item text:</label> <input type="text" name="item_text" placeholder="Enter a to-do item" required id="id_item_text" /></p>'
And then:
widget=forms.fields.TextInput(attrs={
'placeholder': 'Enter a to-do item',
'class': 'form-control input-lg',
}),
Note
|
Doing this sort of widget customisation would get tedious if we had a much larger, more complex form. Check out django-crispy-forms and django-floppyforms for some help. |
Does this feel a bit like development-driven tests? That’s OK, now and again.
When you’re exploring a new API, you’re absolutely allowed to mess about with it for a while before you get back to rigorous TDD. You might use the interactive console, or write some exploratory code (but you have to promise the Testing Goat that you’ll throw it away and rewrite it properly later).
Here we’re actually using a unit test as a way of experimenting with the forms API. It’s actually a pretty good way of learning how it works.
What’s
next? We want our form to reuse the validation code that we’ve already
defined on our model. Django provides a special class which can autogenerate
a form for a model, called ModelForm
. As you’ll see, it’s configured using a
special attribute called Meta
:
from django import forms
from lists.models import Item
class ItemForm(forms.models.ModelForm):
class Meta:
model = Item
fields = ('text',)
In Meta
we specify which model the form is for, and which fields we want it
to use.
ModelForms do all sorts of smart stuff, like assigning sensible HTML form input types to different types of field, and applying default validation. Check out the docs for more info.
We now have some different-looking form HTML:
AssertionError: 'placeholder="Enter a to-do item"' not found in '<p><label for="id_text">Text:</label> <textarea name="text" cols="40" rows="10" required id="id_text">\n</textarea></p>'
It’s lost our placeholder and CSS class. But you can also see that it’s using
name="text"
instead of name="item_text"
. We can probably live with that.
But it’s using a textarea
instead of a normal input, and that’s not the UI we
want for our app. Thankfully, you can override widgets for ModelForm
fields,
similarly to the way we did it with the normal form:
class ItemForm(forms.models.ModelForm):
class Meta:
model = Item
fields = ('text',)
widgets = {
'text': forms.fields.TextInput(attrs={
'placeholder': 'Enter a to-do item',
'class': 'form-control input-lg',
}),
}
That gets the test passing.
Now let’s see if the ModelForm
has picked up the same validation rules which we
defined on the model. We’ll also learn how to pass data into the form, as if
it came from the user:
def test_form_validation_for_blank_items(self):
form = ItemForm(data={'text': ''})
form.save()
That gives us:
ValueError: The Item could not be created because the data didn't validate.
Good: the form won’t allow you to save if you give it an empty item text.
Now let’s see if we can get it to use the specific error message that we
want. The API for checking form validation 'before' we try to save any
data is a function called is_valid
:
def test_form_validation_for_blank_items(self):
form = ItemForm(data={'text': ''})
self.assertFalse(form.is_valid())
self.assertEqual(
form.errors['text'],
["You can't have an empty list item"]
)
Calling form.is_valid()
returns True
or False
, but it also has the
side effect of validating the input data, and populating the errors
attribute. It’s a dictionary mapping the names of fields to lists of
errors for those fields (it’s possible for a field to have more than
one error).
That gives us:
AssertionError: ['This field is required.'] != ["You can't have an empty list item"]
Django already has a default error message that we could present to the
user—you might use it if you were in a hurry to build your web app,
but we care enough to make our message special. Customising it means
changing error_messages
, another Meta
variable:
class Meta:
model = Item
fields = ('text',)
widgets = {
'text': forms.fields.TextInput(attrs={
'placeholder': 'Enter a to-do item',
'class': 'form-control input-lg',
}),
}
error_messages = {
'text': {'required': "You can't have an empty list item"}
}
OK
You know what would be even better than messing about with all these error strings? Having a constant:
EMPTY_ITEM_ERROR = "You can't have an empty list item"
[...]
error_messages = {
'text': {'required': EMPTY_ITEM_ERROR}
}
Rerun the tests to see that they pass…OK. Now we change the test:
from lists.forms import EMPTY_ITEM_ERROR, ItemForm
[...]
def test_form_validation_for_blank_items(self):
form = ItemForm(data={'text': ''})
self.assertFalse(form.is_valid())
self.assertEqual(form.errors['text'], [EMPTY_ITEM_ERROR])
And the tests still pass:
OK
Great. Totes committable:
$ git status # should show lists/forms.py and tests/test_forms.py $ git add lists $ git commit -m "new form for list items"
I had originally thought to extend this form to capture uniqueness validation as well as empty-item validation. But there’s a sort of corollary to the "deploy as early as possible" lean methodology, which is "merge code as early as possible". In other words: while building this bit of forms code, it would be easy to go on for ages, adding more and more functionality to the form—I should know, because that’s exactly what I did during the drafting of this chapter, and I ended up doing all sorts of work making an all-singing, all-dancing form class before I realised it wouldn’t really work for our most basic use case.
So, instead, try to use your new bit of code as soon as possible. This makes sure you never have unused bits of code lying around, and that you start checking your code against "the real world" as soon as possible.
We have a form class which can render some HTML and do validation of at least one kind of error—let’s start using it! We should be able to use it in our 'base.html' template, and so in all of our views.
Let’s start in our unit tests for the home view. We’ll add a new method that checks whether we’re using the right kind of form:
from lists.forms import ItemForm
class HomePageTest(TestCase):
def test_uses_home_template(self):
[...]
def test_home_page_uses_item_form(self):
response = self.client.get('/')
self.assertIsInstance(response.context['form'], ItemForm) #(1)
-
assertIsInstance
checks that our form is of the correct class.
That gives us:
KeyError: 'form'
So we use the form in our home page view:
[...]
from lists.forms import ItemForm
from lists.models import Item, List
def home_page(request):
return render(request, 'home.html', {'form': ItemForm()})
OK, now let’s try using it in the template—we replace the old <input ..>
with {{ form.text }}
:
<form method="POST" action="{% block form_action %}{% endblock %}">
{{ form.text }}
{% csrf_token %}
{% if error %}
<div class="form-group has-error">
{{ form.text }}
renders just the HTML input for the text
field of the form.
One
thing we have done, though, is changed our form—it no longer uses
the same id
and name
attributes. You’ll see if we run our functional
tests that they fail the first time they try to find the input box:
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate element: [id="id_new_item"]
We’ll need to fix this, and it’s going to involve a big find and replace. Before we do that, let’s do a commit, to keep the rename separate from the logic change:
$ git diff # review changes in base.html, views.py and its tests $ git commit -am "use new form in home_page, simplify tests. NB breaks stuff"
Let’s fix the functional tests. A quick grep
shows us there are several
places where we’re using id_new_item
:
$ grep id_new_item functional_tests/test* functional_tests/test_layout_and_styling.py: inputbox = self.browser.find_element_by_id('id_new_item') functional_tests/test_layout_and_styling.py: inputbox = self.browser.find_element_by_id('id_new_item') functional_tests/test_list_item_validation.py: self.browser.find_element_by_id('id_new_item').send_keys(Keys.ENTER) [...]
That’s a good call for a refactor. Let’s make a new helper method in 'base.py':
class FunctionalTest(StaticLiveServerTestCase):
[...]
def get_item_input_box(self):
return self.browser.find_element_by_id('id_text')
And then we use it throughout—I had to make four changes in 'test_simple_list_creation.py', two in 'test_layout_and_styling.py', and six in 'test_list_item_validation.py', for example:
# She is invited to enter a to-do item straight away
inputbox = self.get_item_input_box()
Or:
# an empty list item. She hits Enter on the empty input box
self.browser.get(self.live_server_url)
self.get_item_input_box().send_keys(Keys.ENTER)
I won’t show you every single one; I’m sure you can manage this for
yourself! You can redo the grep
to check that you’ve caught them all.
We’re past the first step, but now we have to bring the rest of the application
code in line with the change. We need to find any occurrences of the old id
(id_new_item
) and name
(item_text
) and replace them too, with id_text
and
text
, respectively:
$ grep -r id_new_item lists/ lists/static/base.css:#id_new_item {
That’s one change, and similarly for the name
:
$ grep -Ir item_text lists [...] lists/views.py: item = Item(text=request.POST['item_text'], list=list_) lists/views.py: item = Item(text=request.POST['item_text'], list=list_) lists/tests/test_views.py: self.client.post('/lists/new', data={'item_text': 'A new list item'}) lists/tests/test_views.py: response = self.client.post('/lists/new', data={'item_text': 'A new list item'}) [...] lists/tests/test_views.py: data={'item_text': ''} [...]
Once we’re done, we rerun the unit tests to check that everything still works:
$ python manage.py test lists [...] ................. --------------------------------------------------------------------- Ran 17 tests in 0.126s OK
And the functional tests too:
$ python manage.py test functional_tests [...] File "...python-tdd-book/functional_tests/test_simple_list_creation.py", line 37, in test_can_start_a_list_for_one_user return self.browser.find_element_by_id('id_text') File "...python-tdd-book/functional_tests/base.py", line 51, in get_item_input_box return self.browser.find_element_by_id('id_text') [...] selenium.common.exceptions.NoSuchElementException: Message: Unable to locate element: [id="id_text"] [...] FAILED (errors=3)
Not quite! Let’s look at where this is happening—if you check the line number from one of the failures, you’ll see that each time after we’ve submitted a first item, the input box has disappeared from the lists page.
Checking 'views.py' and the new_list
view we can see it’s because if we
detect a validation error, we’re not actually passing the form to the
'home.html' template:
except ValidationError:
list_.delete()
error = "You can't have an empty list item"
return render(request, 'home.html', {"error": error})
We’ll want to use the form in this view too. Before we make any more changes though, let’s do a commit:
$ git status $ git commit -am "rename all item input ids and names. still broken"
Now
we want to adjust the unit tests for the new_list
view, especially the
one that deals with validation. Let’s take a look at it now:
class NewListTest(TestCase):
[...]
def test_validation_errors_are_sent_back_to_home_page_template(self):
response = self.client.post('/lists/new', data={'text': ''})
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'home.html')
expected_error = escape("You can't have an empty list item")
self.assertContains(response, expected_error)
For a start this test is testing too many things at once, so we’ve got an opportunity to clarify things here. We should split out two different assertions:
-
If there’s a validation error, we should render the home template, with a 200.
-
If there’s a validation error, the response should contain our error text.
And we can add a new one too:
-
If there’s a validation error, we should pass our form object to the template.
And while we’re at it, we’ll use our constant instead of the hardcoded string for that error message:
from lists.forms import ItemForm, EMPTY_ITEM_ERROR
[...]
class NewListTest(TestCase):
[...]
def test_for_invalid_input_renders_home_template(self):
response = self.client.post('/lists/new', data={'text': ''})
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'home.html')
def test_validation_errors_are_shown_on_home_page(self):
response = self.client.post('/lists/new', data={'text': ''})
self.assertContains(response, escape(EMPTY_ITEM_ERROR))
def test_for_invalid_input_passes_form_to_template(self):
response = self.client.post('/lists/new', data={'text': ''})
self.assertIsInstance(response.context['form'], ItemForm)
Much better. Each test is now clearly testing one thing, and, with a bit of luck, just one will fail and tell us what to do:
$ python manage.py test lists [...] ====================================================================== ERROR: test_for_invalid_input_passes_form_to_template (lists.tests.test_views.NewListTest) --------------------------------------------------------------------- Traceback (most recent call last): File "...python-tdd-book/lists/tests/test_views.py", line 49, in test_for_invalid_input_passes_form_to_template self.assertIsInstance(response.context['form'], ItemForm) [...] KeyError: 'form' --------------------------------------------------------------------- Ran 19 tests in 0.041s FAILED (errors=1)
And here’s how we use the form in the view:
def new_list(request):
form = ItemForm(data=request.POST) #(1)
if form.is_valid(): #(2)
list_ = List.objects.create()
Item.objects.create(text=request.POST['text'], list=list_)
return redirect(list_)
else:
return render(request, 'home.html', {"form": form}) #(3)
-
We pass the
request.POST
data into the form’s constructor. -
We use
form.is_valid()
to determine whether this is a good or a bad submission. -
In the invalid case, we pass the form down to the template, instead of our hardcoded error string.
That view is now looking much nicer! And all our tests pass, except one:
self.assertContains(response, escape(EMPTY_ITEM_ERROR)) [...] AssertionError: False is not true : Couldn't find 'You can't have an empty list item' in response
We’re failing because we’re not yet using the form to display errors in the template:
<form method="POST" action="{% block form_action %}{% endblock %}">
{{ form.text }}
{% csrf_token %}
{% if form.errors %} (1)
<div class="form-group has-error">
<div class="help-block">{{ form.text.errors }}</div> (2)
</div>
{% endif %}
</form>
-
form.errors
contains a list of all the errors for the form. -
form.text.errors
is a list of just the errors for thetext
field.
What does that do to our tests?
FAIL: test_validation_errors_end_up_on_lists_page (lists.tests.test_views.ListViewTest) [...] AssertionError: False is not true : Couldn't find 'You can't have an empty list item' in response
An unexpected failure—it’s actually in the tests for our final view,
view_list
. Because we’ve changed the way errors are displayed in 'all'
templates, we’re no longer showing the error that we manually pass into the
template.
That means we’re going to need to rework view_list
as well, before we can
get back to a working state.
This view handles both GET and POST requests. Let’s start with checking that the form is used in GET requests. We can have a new test for that:
class ListViewTest(TestCase):
[...]
def test_displays_item_form(self):
list_ = List.objects.create()
response = self.client.get(f'/lists/{list_.id}/')
self.assertIsInstance(response.context['form'], ItemForm)
self.assertContains(response, 'name="text"')
That gives:
KeyError: 'form'
Here’s a minimal implementation:
def view_list(request, list_id):
[...]
form = ItemForm()
return render(request, 'list.html', {
'list': list_, "form": form, "error": error
})
Next
we want to use the form errors in the second view.
We’ll split our current single test for the
invalid case (test_validation_errors_end_up_on_lists_page
) into several
separate ones:
class ListViewTest(TestCase):
[...]
def post_invalid_input(self):
list_ = List.objects.create()
return self.client.post(
f'/lists/{list_.id}/',
data={'text': ''}
)
def test_for_invalid_input_nothing_saved_to_db(self):
self.post_invalid_input()
self.assertEqual(Item.objects.count(), 0)
def test_for_invalid_input_renders_list_template(self):
response = self.post_invalid_input()
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'list.html')
def test_for_invalid_input_passes_form_to_template(self):
response = self.post_invalid_input()
self.assertIsInstance(response.context['form'], ItemForm)
def test_for_invalid_input_shows_error_on_page(self):
response = self.post_invalid_input()
self.assertContains(response, escape(EMPTY_ITEM_ERROR))
By making a little helper function, post_invalid_input
, we can make four
separate tests without duplicating lots of lines of code.
We’ve seen this several times now. It often feels more natural to write view tests as a single, monolithic block of assertions—the view should do this and this and this, then return that with this. But breaking things out into multiple tests is definitely worthwhile; as we saw in previous chapters, it helps you isolate the exact problem you may have, when you later come and change your code and accidentally introduce a bug. Helper methods are one of the tools that lower the psychological barrier.
For example, now we can see there’s just one failure, and it’s a clear one:
FAIL: test_for_invalid_input_shows_error_on_page (lists.tests.test_views.ListViewTest) AssertionError: False is not true : Couldn't find 'You can't have an empty list item' in response
Now let’s see if we can properly rewrite the view to use our form. Here’s a first cut:
def view_list(request, list_id):
list_ = List.objects.get(id=list_id)
form = ItemForm()
if request.method == 'POST':
form = ItemForm(data=request.POST)
if form.is_valid():
Item.objects.create(text=request.POST['text'], list=list_)
return redirect(list_)
return render(request, 'list.html', {'list': list_, "form": form})
That gets the unit tests passing:
Ran 23 tests in 0.086s OK
How about the FTs?
ERROR: test_cannot_add_empty_list_items (functional_tests.test_list_item_validation.ItemValidationTest) --------------------------------------------------------------------- Traceback (most recent call last): File "...python-tdd-book/functional_tests/test_list_item_validation.py", line 15, in test_cannot_add_empty_list_items [...] selenium.common.exceptions.NoSuchElementException: Message: Unable to locate element: .has-error
Nope.
What’s
going on here? Let’s add our usual time.sleep
before the
error, and take a look at what’s happening (or spin up the site
manually with manage.py runserver
if you prefer (see HTML5 validation says no).
It seems like the browser is preventing the user from even submitting the input when it’s empty.
It’s because Django has added the required
attribute to the HTML
input[1]
(take another look at our as_p()
printouts from earlier if you don’t
believe me). This is a
new feature of HTML5,
and browsers nowadays will do some validation at the client side if they
see it, preventing users from even submitting invalid input.
Let’s change our FT to reflect that:
def test_cannot_add_empty_list_items(self):
# Edith goes to the home page and accidentally tries to submit
# an empty list item. She hits Enter on the empty input box
self.browser.get(self.live_server_url)
self.get_item_input_box().send_keys(Keys.ENTER)
# The browser intercepts the request, and does not load the
# list page
self.wait_for(lambda: self.browser.find_elements_by_css_selector(
'#id_text:invalid' #(1)
))
# She starts typing some text for the new item and the error disappears
self.get_item_input_box().send_keys('Buy milk')
self.wait_for(lambda: self.browser.find_elements_by_css_selector(
'#id_text:valid' #(2)
))
# And she can submit it successfully
self.get_item_input_box().send_keys(Keys.ENTER)
self.wait_for_row_in_list_table('1: Buy milk')
# Perversely, she now decides to submit a second blank list item
self.get_item_input_box().send_keys(Keys.ENTER)
# Again, the browser will not comply
self.wait_for_row_in_list_table('1: Buy milk')
self.wait_for(lambda: self.browser.find_elements_by_css_selector(
'#id_text:invalid'
))
# And she can correct it by filling some text in
self.get_item_input_box().send_keys('Make tea')
self.wait_for(lambda: self.browser.find_elements_by_css_selector(
'#id_text:valid'
))
self.get_item_input_box().send_keys(Keys.ENTER)
self.wait_for_row_in_list_table('1: Buy milk')
self.wait_for_row_in_list_table('2: Make tea')
-
Instead of checking for our custom error message, we check using the CSS pseudoselector
:invalid
, which the browser applies to any HTML5 input that has invalid input. -
And its converse in the case of valid inputs.
See how useful and flexible our self.wait_for
function is turning out to
be?
Our FT does look quite different from how it started though, doesn’t it? I’m sure that’s raising a lot of questions in your mind right now. Put a pin in them for a moment; I promise we’ll talk. Let’s first see if we’re back to passing tests:
$ python manage.py test functional_tests [...] .... --------------------------------------------------------------------- Ran 4 tests in 12.154s OK
First let’s give ourselves a massive pat on the back: we’ve just made a major change to our small app—that input field, with its name and ID, is absolutely critical to making everything work. We’ve touched seven or eight different files, doing a refactor that’s quite involved…this is the kind of thing that, without tests, would seriously worry me. In fact, I might well have decided that it wasn’t worth messing with code that works. But, because we have a full tests suite, we can delve around, tidying things up, safe in the knowledge that the tests are there to spot any mistakes we make. It just makes it that much likelier that you’re going to keep refactoring, keep tidying up, keep gardening, keep tending your code, keep everything neat and tidy and clean and smooth and precise and concise and functional and good.
-
'Remove duplication of validation logic in views'
And it’s definitely time for a commit:
$ git diff $ git commit -am "use form in all views, back to working state"
But what about our custom error message? What about all that effort rendering the form in our HTML template? We’re not even passing those errors from Django to the user if the browser is intercepting the requests before the user even makes them? And our FT isn’t even testing that stuff any more!
Well, you’re quite right. But there are two or three reasons all our time hasn’t been wasted. Firstly, client-side validation isn’t enough to guarantee you’re protected from bad inputs, so you always need the server side as well if you really care about data integrity; using a form is a nice way of encapsulating that logic.
Also, not all browsers ('cough—Safari—cough') fully implement HTML5, so some users are still going to see our custom error message. And if or when we come to letting users access our data via an API (see [appendix_rest_api]), then our validation messages will come back into use.
On top of that, we’ll be able to reuse all our validation and forms code and
the front-end .has-error
classes in the next chapter, when we do some more
advanced validation that can’t be done by HTML5 magic.
But you know, even if all that wasn’t true, you still can’t beat yourself up for occasionally going down a blind alley while you’re coding. None of us can see the future, and we should concentrate on finding the right solution rather than the time "wasted" on the wrong solution.
There are a couple more things we can do to make our views even simpler. I’ve mentioned that forms are supposed to be able to save data to the database for us. Our case won’t quite work out of the box, because the item needs to know what list to save to, but it’s not hard to fix that.
We start, as always, with a test. Just to illustrate what the problem is,
let’s see what happens if we just try to call form.save()
:
def test_form_save_handles_saving_to_a_list(self):
form = ItemForm(data={'text': 'do me'})
new_item = form.save()
Django isn’t happy, because an item needs to belong to a list:
django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id
Our solution is to tell the form’s save method what list it should save to:
from lists.models import Item, List
[...]
def test_form_save_handles_saving_to_a_list(self):
list_ = List.objects.create()
form = ItemForm(data={'text': 'do me'})
new_item = form.save(for_list=list_)
self.assertEqual(new_item, Item.objects.first())
self.assertEqual(new_item.text, 'do me')
self.assertEqual(new_item.list, list_)
We then make sure that the item is correctly saved to the database, with the right attributes:
TypeError: save() got an unexpected keyword argument 'for_list'
And here’s how we can implement our custom save method:
def save(self, for_list):
self.instance.list = for_list
return super().save()
The .instance
attribute on a form represents the database object that is
being modified or created. And I only learned that as I was writing this
chapter! There are other ways of getting this to work, including manually
creating the object yourself, or using the commit=False
argument to save,
but this is the neatest I think. We’ll explore a different way of making
a form "know" what list it’s for in the next chapter:
Ran 24 tests in 0.086s OK
Finally we can refactor our views. new_list
first:
def new_list(request):
form = ItemForm(data=request.POST)
if form.is_valid():
list_ = List.objects.create()
form.save(for_list=list_)
return redirect(list_)
else:
return render(request, 'home.html', {"form": form})
Rerun the test to check that everything still passes:
Ran 24 tests in 0.086s OK
And now view_list
:
def view_list(request, list_id):
list_ = List.objects.get(id=list_id)
form = ItemForm()
if request.method == 'POST':
form = ItemForm(data=request.POST)
if form.is_valid():
form.save(for_list=list_)
return redirect(list_)
return render(request, 'list.html', {'list': list_, "form": form})
And we still have full passes:
Ran 24 tests in 0.111s OK
and:
Ran 4 tests in 14.367s OK
Great! Our two views are now looking very much like "normal" Django views:
they take information from a user’s request, combine it with some custom logic
or information from the URL (list_id
), pass it to a form for validation
and possible saving, and then redirect or render a template.
Forms and validation are really important in Django, and in web programming in general, so let’s try to make a slightly more complicated one in the next chapter.
- Thin views
-
If you find yourself looking at complex views, and having to write a lot of tests for them, it’s time to start thinking about whether that logic could be moved elsewhere: possibly to a form, like we’ve done here.
Another possible place would be a custom method on the model class. And—once the complexity of the app demands it—out of Django-specific files and into your own classes and functions, that capture your core business logic.
- Each test should test one thing
-
The heuristic is to be suspicious if there’s more than one assertion in a test. Sometimes two assertions are closely related, so they belong together. But often your first draft of a test ends up testing multiple behaviours, and it’s worth rewriting it as several tests. Helper functions can keep them from getting too bloated.