This appendix follows on from [chapter_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.
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.
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:
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
:
[...]
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…
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:
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:
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:
[...]
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?
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():
:
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!
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
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.
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_making_deployment_production_ready–#chapter_organising_test_files as a result.
Here’s how things might go. Start by thinking we want a DetailView
,
something that shows you the detail of an object:
from django.views.generic import FormView, CreateView, DetailView
[...]
class ViewAndAddToList(DetailView):
model = List
And wiring it up in 'urls.py':
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:
@@ -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:
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)
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
:
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
:
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.
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:
class Item(models.Model):
[...]
def get_absolute_url(self):
return reverse('view_list', args=[self.list.id])
We end up with a view class that looks like this:
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)
Let’s see the old version for comparison?
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.
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.
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:
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.
- 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.