Appendix B. Django Class-Based Views
This appendix follows on from Chapter 12, in which we implemented Django forms for validation, and refactored our views. By the end of that chapter, our views were still using functions.
The new shiny in the Django world, however, is class-based views. In this appendix, we’ll refactor our application to use them instead of view functions. More specifically, we’ll have a go at using class-based generic views.
Class-Based Generic Views
There’s a difference between class-based views and class-based generic views. Class-based views are just another way of defining view functions. They make few assumptions about what your views will do, and they offer one main advantage over view functions, which is that they can be subclassed. This comes, arguably, at the expense of being less readable than traditional function-based views. The main use case for plain class-based views is when you have several views that reuse the same logic. We want to obey the DRY principle. With function-based views, you would use helper functions or decorators. The theory is that using a class structure may give you a more elegant solution.
Class-based generic views are class-based views that attempt to provide ready-made solutions to common use cases: fetching an object from the database and passing it to a template, fetching a list of objects, saving user input from a POST request using a ModelForm, and so on. These sound very much like our use cases, but as we’ll soon see, the devil is in the detail.
I should say at this point that I’ve not used either kind of class-based views much. I can definitely see the sense in them, and there are potentially many use cases in Django apps where CBGVs would fit in perfectly. However, as soon as your use case is slightly outside the basics—as soon as you have more than one model you want to use, for example—I find that using class-based views can (again, debatably) lead to code that’s much harder to read than a classic view function.
Still, because we’re forced to use several of the customisation options for class-based views, implementing them in this case can teach us a lot about how they work, and how we can unit test them.
My hope is that the same unit tests we use for function-based views should work just as well for class-based views. Let’s see how we get on.
The Home Page as a FormView
Our home page just displays a form on a template:
def
home_page
(
request
):
return
render
(
request
,
'home.html'
,
{
'form'
:
ItemForm
()})
Looking through
the options, Django has a generic view called FormView
—let’s see how that
goes:
lists/views.py (ch31l001).
from
django.views.generic
import
FormView
[
...
]
class
HomePageView
(
FormView
):
template_name
=
'home.html'
form_class
=
ItemForm
We tell it what template we want to use, and which form. Then, we
just need to update urls.py, replacing the line that used to say
lists.views.home_page
:
superlists/urls.py (ch31l002).
from
lists.views
import
HomePageView
[
...
]
url
(
r'^$'
,
HomePageView
.
as_view
(),
name
=
'home'
),
And the tests all check out! That was easy…
$ python3 manage.py test lists
[...]
Ran 34 tests in 0.119s
OK
$ python3 manage.py test functional_tests
[...]
Ran 4 tests in 15.160s
OK
So far so good. We’ve replaced a one-line view function with a two-line class, but it’s still very readable. This would be a good time for a commit…
Using form_valid to Customise a CreateView
Next we have a crack at the view we use to create a brand new list, currently
the new_list
function. Here’s what it looks like now:
lists/views.py.
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
})
Looking through the possible CBGVs, we probably want a CreateView
, and we
know we’re using the ItemForm
class, so let’s see how we get on with them,
and whether the tests will help us:
lists/views.py (ch31l003).
from
django.views.generic
import
FormView
,
CreateView
[
...
]
class
NewListView
(
CreateView
):
form_class
=
ItemForm
def
new_list
(
request
):
[
...
]
I’m going to leave the old view function in views.py, so that we can copy code across from it. We can delete it once everything is working. It’s harmless as soon as we switch over the URL mappings, this time in:
lists/urls.py (ch31l004).
from
django.conf.urls
import
patterns
,
url
from
lists.views
import
NewListView
urlpatterns
=
patterns
(
''
,
url
(
r'^(\d+)/$'
,
'lists.views.view_list'
,
name
=
'view_list'
),
url
(
r'^new$'
,
NewListView
.
as_view
(),
name
=
'new_list'
),
)
Now running the tests gives three errors:
$ python3 manage.py test lists
ERROR: test_for_invalid_input_passes_form_to_template
(lists.tests.test_views.NewListTest)
django.core.exceptions.ImproperlyConfigured: TemplateResponseMixin requires
either a definition of 'template_name' or an implementation of
'get_template_names()'
ERROR: test_for_invalid_input_renders_home_template (lists.tests.test_views.NewListTest)
django.core.exceptions.ImproperlyConfigured: TemplateResponseMixin requires
either a definition of 'template_name' or an implementation of
'get_template_names()'
ERROR: test_invalid_list_items_arent_saved (lists.tests.test_views.NewListTest)
django.core.exceptions.ImproperlyConfigured: TemplateResponseMixin requires
either a definition of 'template_name' or an implementation of
'get_template_names()'
ERROR: test_redirects_after_POST (lists.tests.test_views.NewListTest)
TypeError: save() missing 1 required positional argument: 'for_list'
ERROR: test_saving_a_POST_request (lists.tests.test_views.NewListTest)
TypeError: save() missing 1 required positional argument: 'for_list'
ERROR: test_validation_errors_are_shown_on_home_page (lists.tests.test_views.NewListTest)
django.core.exceptions.ImproperlyConfigured: TemplateResponseMixin requires
either a definition of 'template_name' or an implementation of
'get_template_names()'
Ran 34 tests in 0.125s
FAILED (errors=6)
Let’s start with the third—maybe we can just add the template?
lists/views.py (ch31l005).
class
NewListView
(
CreateView
):
form_class
=
ItemForm
template_name
=
'home.html'
That gets us down to just two failures: we can see they’re both happening
in the generic view’s form_valid
function, and that’s one of the ones that
you can override to provide custom behaviour in a CBGV. As its name implies,
it’s run when the view has detected a valid form. We can just copy some of
the code from our old view function, that used to live after
if form.is_valid():
:
lists/views.py (ch31l005).
class
NewListView
(
CreateView
):
template_name
=
'home.html'
form_class
=
ItemForm
def
form_valid
(
self
,
form
):
list_
=
List
.
objects
.
create
()
form
.
save
(
for_list
=
list_
)
return
redirect
(
list_
)
That gets us a full pass!
$ python3 manage.py test lists Ran 34 tests in 0.119s OK $ python3 manage.py test functional_tests Ran 4 tests in 15.157s OK
And we could even save two more lines, trying to obey “DRY”, by using one of the main advantages of CBVs: inheritance!
lists/views.py (ch31l007).
class
NewListView
(
CreateView
,
HomePageView
):
def
form_valid
(
self
,
form
):
list
=
List
.
objects
.
create
()
Item
.
objects
.
create
(
text
=
form
.
cleaned_data
[
'text'
],
list
=
list
)
return
redirect
(
'/lists/
%d
/'
%
(
list
.
id
,))
And all the tests would still pass:
OK
Warning
This is not really good object-oriented practice. Inheritance implies an “is-a” relationship, and it’s probably not meaningful to say that our new list view “is-a” home page view…so, probably best not to do this.
With or without that last step, how does it compare to the old version? I’d say that’s not bad. We save some boilerplate code, and the view is still fairly legible. So far, I’d say we’ve got one point for CBGVs, and one draw.
A More Complex View to Handle Both Viewing and Adding to a List
This took me several attempts. And I have to say that, although the tests
told me when I got it right, they didn’t really help me to figure out the
steps to get there…mostly it was just trial and error, hacking about
in functions like get_context_data
, get_form_kwargs
, and so on.
One thing it did made me realise was the value of having lots of individual tests, each testing one thing. I went back and rewrote some of Chapters 10–12 as a result.
The Tests Guide Us, for a While
Here’s how things might go. Start by thinking we want a DetailView
,
something that shows you the detail of an object:
lists/views.py.
from
django.views.generic
import
FormView
,
CreateView
,
DetailView
[
...
]
class
ViewAndAddToList
(
DetailView
):
model
=
List
That gives:
[...] AttributeError: Generic detail view ViewAndAddToList must be called with either an object pk or a slug. FAILED (failures=5, errors=6)
Not totally obvious, but a bit of Googling around led me to understand that I needed to use a “named” regex capture group:
lists/urls.py (ch31l011).
@@ -1,7 +1,7 @@
from django.conf.urls import patterns, url-from lists.views import NewListView
+from lists.views import NewListView, ViewAndAddToList
urlpatterns = patterns('',- url(r'^(\d+)/$', 'lists.views.view_list', name='view_list'),
+ url(r'^(?P<pk>\d+)/$', ViewAndAddToList.as_view(), name='view_list'),
url(r'^new$', NewListView.as_view(), name='new_list'), )
The next error was fairly helpful:
[...] django.template.base.TemplateDoesNotExist: lists/list_detail.html FAILED (failures=5, errors=6)
That’s easily solved:
lists/views.py.
class
ViewAndAddToList
(
DetailView
):
model
=
List
template_name
=
'list.html'
That takes us down three errors:
[...] ERROR: test_displays_item_form (lists.tests.test_views.ListViewTest) KeyError: 'form' FAILED (failures=5, errors=2)
Until We’re Left with Trial and Error
So I figured, our view doesn’t just show us the detail of an object,
it also allows us to create new ones. Let’s make it both a
DetailView
and a CreateView
:
lists/views.py.
class
ViewAndAddToList
(
DetailView
,
CreateView
):
model
=
List
template_name
=
'list.html'
form_class
=
ExistingListItemForm
But that gives us a lot of errors saying:
[...] TypeError: __init__() missing 1 required positional argument: 'for_list'
And the KeyError: 'form'
was still there too!
At this point the errors stopped being quite as helpful, and it was no longer obvious what to do next. I had to resort to trial and error. Still, the tests did at least tell me when I was getting things more right or more wrong.
My first attempts to use get_form_kwargs
didn’t really work, but I found
that I could use get_form
:
lists/views.py.
def
get_form
(
self
,
form_class
):
self
.
object
=
self
.
get_object
()
return
form_class
(
for_list
=
self
.
object
,
data
=
self
.
request
.
POST
)
But it would only work if I also assigned to self.object
, as a side effect,
along the way, which was a bit upsetting. Still, that takes us down
to just three errors, but we’re still apparently not passing that form to the
template!
KeyError: 'form' FAILED (errors=3)
Back on Track
A bit more experimenting led me to swap out the DetailView
for a
SingleObjectMixin
(the docs had some useful pointers here):
from django.views.generic.detail import SingleObjectMixin [...] class ViewAndAddToList(CreateView, SingleObjectMixin): [...]
That takes us down to just two errors:
django.core.exceptions.ImproperlyConfigured: No URL to redirect to. Either provide a url or define a get_absolute_url method on the Model.
And for this final failure, the tests are being helpful again.
It’s quite easy to define a get_absolute_url
on the Item
class, such
that items point to their parent list’s page:
lists/models.py.
class
Item
(
models
.
Model
):
[
...
]
def
get_absolute_url
(
self
):
return
reverse
(
'view_list'
,
args
=
[
self
.
list
.
id
])
Is That Your Final Answer?
We end up with a view class that looks like this:
lists/views.py (ch31l010).
class
ViewAndAddToList
(
CreateView
,
SingleObjectMixin
):
template_name
=
'list.html'
model
=
List
form_class
=
ExistingListItemForm
def
get_form
(
self
,
form_class
):
self
.
object
=
self
.
get_object
()
return
form_class
(
for_list
=
self
.
object
,
data
=
self
.
request
.
POST
)
Compare Old and New
Let’s see the old version for comparison?
lists/views.py.
def
view_list
(
request
,
list_id
):
list_
=
List
.
objects
.
get
(
id
=
list_id
)
form
=
ExistingListItemForm
(
for_list
=
list_
)
if
request
.
method
==
'POST'
:
form
=
ExistingListItemForm
(
for_list
=
list_
,
data
=
request
.
POST
)
if
form
.
is_valid
():
form
.
save
()
return
redirect
(
list_
)
return
render
(
request
,
'list.html'
,
{
'list'
:
list_
,
"form"
:
form
})
Well, it has reduced the number of lines of code from nine to seven. Still, I find
the function-based version a little easier to understand, in that it has a
little bit less magic—“explicit is better than implicit”, as the Zen of
Python would have it. I mean…SingleObjectMixin
? What? And, more
offensively, the whole thing falls apart if we don’t assign to self.object
inside get_form
? Yuck.
Still, I guess some of it is in the eye of the beholder.
Best Practices for Unit Testing CBGVs?
As I was working through this, I felt like my “unit” tests were sometimes a little too high-level. This is no surprise, since tests for views that involve the Django test client are probably more properly called integrated tests.
They told me whether I was getting things right or wrong, but they didn’t always offer enough clues on exactly how to fix things.
I occasionally wondered whether there might be some mileage in a test that was closer to the implementation—something like this:
def
test_cbv_gets_correct_object
(
self
):
our_list
=
List
.
objects
.
create
()
view
=
ViewAndAddToList
()
view
.
kwargs
=
dict
(
pk
=
our_list
.
id
)
self
.
assertEqual
(
view
.
get_object
(),
our_list
)
But the problem is that it requires a lot of knowledge of the internals of Django CBVs to be able to do the right test setup for these kinds of tests. And you still end up getting very confused by the complex inheritance hierarchy.
Take-Home: Having Multiple, Isolated View Tests with Single Assertions Helps
One thing I definitely did conclude from this appendix was that having many short unit tests for views was much more helpful than having few tests with a narrative series of assertions.
Consider this monolithic test:
def
test_validation_errors_sent_back_to_home_page_template
(
self
):
response
=
self
.
client
.
post
(
'/lists/new'
,
data
=
{
'text'
:
''
})
self
.
assertEqual
(
List
.
objects
.
all
()
.
count
(),
0
)
self
.
assertEqual
(
Item
.
objects
.
all
()
.
count
(),
0
)
self
.
assertTemplateUsed
(
response
,
'home.html'
)
expected_error
=
escape
(
"You can't have an empty list item"
)
self
.
assertContains
(
response
,
expected_error
)
That is definitely less useful than having three individual tests, like this:
def
test_invalid_input_means_nothing_saved_to_db
(
self
):
self
.
post_invalid_input
()
self
.
assertEqual
(
List
.
objects
.
all
()
.
count
(),
0
)
self
.
assertEqual
(
Item
.
objects
.
all
()
.
count
(),
0
)
def
test_invalid_input_renders_list_template
(
self
):
response
=
self
.
post_invalid_input
()
self
.
assertTemplateUsed
(
response
,
'list.html'
)
def
test_invalid_input_renders_form_with_errors
(
self
):
response
=
self
.
post_invalid_input
()
self
.
assertIsinstance
(
response
.
context
[
'form'
],
ExistingListItemForm
)
self
.
assertContains
(
response
,
escape
(
empty_list_error
))
The reason is that, in the first case, an early failure means not all the assertions are checked. So, if the view was accidentally saving to the database on invalid POST, you would get an early fail, and so you wouldn’t find out whether it was using the right template or rendering the form. The second formulation makes it much easier to pick out exactly what was or wasn’t working.
Get Test-Driven Development with Python now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.