Mocking django class based views

Aug. 17, 2018, 10 p.m.   wschaub   django TDD  


While going through Appendix C I quickly ran into trouble with the mocky Unit test for the new list view when I converted it to a class based view. the following is a description of how I made it work.

Remember when Harry warned us mocks tie us to the implementation? Well, the implementation of class based views are very much outside of our control. First let's take a look at the implementation of my class based view for new_list.

lists/views.py

...
from django.views.generic import FormView, CreateView, DetailView
...

class NewListView(CreateView):
    template_name = 'lists/home.html'
    form_class = NewListForm

    def form_valid(self, form):
        self.object = form.save(owner=self.request.user)
        return redirect(self.object)

Looks simple enough and indeed all of our integrated tests that use this view run fine. but the NewListUnitTest tests fail.

The following code and it's comments describe how I worked around that. basically mocking the form isn't enough, we need to mock the form_class property of the class instead, there's also other things I ran into that are described in the modified test code below.

lists/tests/test_views.py

...
from django.test.client import RequestFactory
...
import lists.views
from lists.forms import (
    DUPLICATE_ITEM_ERROR, EMPTY_ITEM_ERROR,
    ExistingListItemForm, ItemForm,
)
...

#Patching the form doesn't work with the class based view
#we need to patch classname.form_class instead.
@patch('lists.views.NewListView.form_class')
class NewListViewUnitTest(unittest.TestCase):

    #This is just so we don't have to define new_list
    #at the module level.
    def new_list(self,request):
        return lists.views.NewListView.as_view()(request)

    def setUp(self):
        #We can't use a raw HttpRequest() with a CBV
        #We have to use a RequestFactory instead.
        self.request = RequestFactory().post(reverse('new_list'),data={'text': 'new list item'})
        self.request.user = Mock()
        self.request.session = {}

    def test_passes_POST_data_to_NewListForm(self, mockNewListForm):
        self.new_list(self.request)

        #Essentailly the same as before but the CBV adds a lot of other kwargs
        #This is likely to break if the class implementation changes.
        #We should probably extract the args and test only what we want
        #to see instead since it would be less brittle.
        mockNewListForm.assert_called_once_with(data=self.request.POST, files=MultiValueDict(), initial={}, instance=None,
                prefix=None)

    def test_saves_form_wtih_owner_if_form_valid(self, mockNewListForm):
        mock_form = mockNewListForm.return_value
        mock_form.is_valid.return_value = True
        self.new_list(self.request)
        mock_form.save.assert_called_once_with(owner=self.request.user)
    def test_does_not_save_if_form_invalid(self, mockNewListForm):
        mock_form = mockNewListForm.return_value
        mock_form.is_valid.return_value = False
        self.new_list(self.request)
        self.assertFalse(mock_form.save.called)

    #This is the only test I didn't have to change
    #I think it's because we override the form_valid method
    #inside lists.views and we call redirect inside of it.
    @patch('lists.views.redirect')
    def test_redirects_to_form_returned_object_if_form_valid(
            self, mock_redirect, mockNewListForm
    ):
        mock_form = mockNewListForm.return_value
        mock_form.is_valid.return_value = True

        response = self.new_list(self.request)

        self.assertEqual(response, mock_redirect.return_value)
        mock_redirect.assert_called_once_with(mock_form.save.return_value)

    #Render isn't called by GCBVs response_class seems to be the right
    #Thing to patch
    @patch('lists.views.NewListView.response_class')
    def test_renders_home_template_with_form_if_form_invalid(
            self, mock_render, mockNewListForm
    ):
        mock_form = mockNewListForm.return_value
        mock_form.is_valid.return_value = False

        response = self.new_list(self.request)

        #These two lines ensure that the mock was called
        #Instead of something in the real class.
        mock_render.assert_called_once()
        self.assertEqual(response, mock_render.return_value)

        #assumes only one call to the method and extracts its
        #arguments for comparason.
        kall = mock_render.call_args
        args, kwargs = kall

        #Does the class use the right template?
        self.assertEqual(kwargs['template'], ['lists/home.html'])
        #Did our (mocked)form get passed to the template?
        self.assertEqual(kwargs['context']['form'], mock_form)

testinggoat TDD django