buy the book ribbon

Appendix B: Django Class-Based Views

This appendix follows on from [chapter_15_advanced_forms], 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 (CBVs) 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 (CBGVs) 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 details.

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:

lists/views.py
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)
[...]
urlpatterns = [
    url(r'^$', list_views.HomePageView.as_view(), name='home'),
    url(r'^lists/', include(list_urls)),
]

And the tests all check out! That was easy…​

$ python manage.py test lists
[...]

Ran 34 tests in 0.119s
OK
$ python manage.py test functional_tests
[...]
Ran 5 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)
[...]
urlpatterns = [
    url(r'^new$', views.NewListView.as_view(), name='new_list'),
    url(r'^(\d+)/$', views.view_list, name='view_list'),
]

Now running the tests gives six errors:

$ python manage.py test lists
[...]

ERROR: test_can_save_a_POST_request (lists.tests.test_views.NewListTest)
TypeError: save() missing 1 required positional argument: 'for_list'

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_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()'


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 (ch31l006)
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!

$ python manage.py test lists
Ran 34 tests in 0.119s
OK
$ python manage.py test functional_tests
Ran 5 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()
        form.save(for_list=list_)
        return redirect(list_)

And all the tests would still pass:

OK
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 #chapter_11_ansible#chapter_12_organising_test_files 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 (ch31l009)
from django.views.generic import FormView, CreateView, DetailView
[...]

class ViewAndAddToList(DetailView):
    model = List

And wiring it up in urls.py:

lists/urls.py (ch31l010)
    url(r'^(\d+)/$', views.ViewAndAddToList.as_view(), name='view_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)
@@ -3,6 +3,6 @@ from lists import views

 urlpatterns = [
     url(r'^new$', views.NewListView.as_view(), name='new_list'),
-    url(r'^(\d+)/$', views.view_list, name='view_list'),
+    url(r'^(?P<pk>\d+)/$', views.ViewAndAddToList.as_view(), name='view_list')
 ]

The next set of errors had one that was fairly helpful:

[...]
django.template.exceptions.TemplateDoesNotExist: lists/list_detail.html

FAILED (failures=5, errors=6)

That’s easily solved:

lists/views.py (ch31l012)
class ViewAndAddToList(DetailView):
    model = List
    template_name = 'list.html'

That takes us down five and two:

[...]
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, and maybe add the form_class:

lists/views.py (ch31l013)
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 (ch31l014)
    def get_form(self):
        self.object = self.get_object()
        return self.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 quite there!

django.core.exceptions.ImproperlyConfigured: No URL to redirect to.  Either
provide a url or define a get_absolute_url method on the Model.

Back on Track

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 (ch31l015)
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
class ViewAndAddToList(DetailView, CreateView):
    model = List
    template_name = 'list.html'
    form_class = ExistingListItemForm

    def get_form(self):
        self.object = self.get_object()
        return self.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:

lists/tests/test_views.py
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 a few tests with a narrative series of assertions.

Consider this monolithic test:

lists/tests/test_views.py
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:

lists/tests/test_views.py
    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.

Lessons Learned from CBGVs
Class-based generic views can do anything

It might not always be clear what’s going on, but you can do just about anything with class-based generic views.

Single-assertion unit tests help refactoring

With each unit test providing individual guidance on what works and what doesn’t, it’s much easier to change the implementation of our views to using this fundamentally different paradigm.

Comments