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)

    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.