Skip to content

Commit

Permalink
Merge 2e33af4 into 623b753
Browse files Browse the repository at this point in the history
  • Loading branch information
grahamu committed Jun 23, 2015
2 parents 623b753 + 2e33af4 commit d78d328
Show file tree
Hide file tree
Showing 6 changed files with 333 additions and 4 deletions.
114 changes: 114 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,120 @@ can use it like this::
def test_better_than_nothing(self):
response = self.assertGoodView('my-url-name')

Testing class-based "generic" views
------------------------------------

The TestCase methods ``get()`` and ``post()`` work for both function-based
and class-based views. However, in doing so they invoke Django's
URL resolution, middleware, template processing, and decorator systems.
For integration testing this is desirable, as you want to ensure your
URLs resolve properly, view permissions are enforced, etc.
For unit testing this is costly because all these Django request/response
systems are invoked in addition to your method, and they typically do not
affect the end result.

Class-based views (derived from Django's ``generic.models.View`` class)
contain methods and mixins which makes granular unit testing (more) feasible.
Quite often your usage of a generic view class comprises a simple override
of an existing method. Invoking the entire view and the Django request/response
stack is a waste of time... you really want to call the overridden
method directly and test the result.

CBVTestCase to the rescue!

As with TestCase above, simply have your tests inherit
from test\_plus.test.CBVTestCase rather than TestCase like so::

from test_plus.test import CBVTestCase

class MyViewTests(CBVTestCase):

Methods
-------

get_instance(cls, initkwargs=None, request=None, \*args, \*\*kwargs)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

This core method simplifies the instantiation of your class, giving you
a way to invoke class methods directly.

Returns an instance of ``cls``, initialized with ``initkwargs``.
Sets ``request``, ``args``, and ``kwargs`` attributes on the class instance.
``args`` and ``kwargs`` are the same values you would pass to ``reverse()``.

Sample usage::

from django.views import generic
from test_plus import CBVTestCase

class MyClass(generic.DetailView)

def get_context_data(self, **kwargs):
kwargs['answer'] = 42
return kwargs

class MyTests(CBVTestCase):

def test_context_data(self):
my_view = self.get_instance(MyClass, {'object': some_object})
context = my_view.get_context_data()
self.assertEqual(context['answer'], 42)

get(cls, initkwargs=None, \*args, \*\*kwargs)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Invokes ``cls.get()`` and returns the response, rendering template if possible.
Builds on the ``CBVTestCase.get_instance()`` foundation.

All test\_plus.test.TestCase methods are valid, so the following works::

response = self.get(MyClass)
self.assertContext('my_key', expected_value)

All test\_plus TestCase side-effects are honored and all test\_plus
TestCase assertion methods work with ``CBVTestCase.get()``.

**NOTE:** This method bypasses Django's middleware, and therefore context
variables created by middleware are not available. If this affects your
template/context testing you should use TestCase instead of CBVTestCase.

post(cls, data=None, initkwargs=None, \*args, \*\*kwargs)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Invokes ``cls.post()`` and returns the response, rendering template if possible.
Builds on the ``CBVTestCase.get_instance()`` foundation.

Example::

response = self.post(MyClass, data={'search_term': 'revsys'})
self.response_200(response)
self.assertContext('company_name', 'RevSys')

All test\_plus TestCase side-effects are honored and all test\_plus
TestCase assertion methods work with ``CBVTestCase.post()``.

**NOTE:** This method bypasses Django's middleware, and therefore context
variables created by middleware are not available. If this affects your
template/context testing you should use TestCase instead of CBVTestCase.

get_check_200(cls, initkwargs=None, \*args, \*\*kwargs)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Works just like ``TestCase.get_check_200()``.
Caller must provide a view class instead of a URL name or path parameter.

All test\_plus TestCase side-effects are honored and all test\_plus
TestCase assertion methods work with ``CBVTestCase.post()``.

assertGoodView(cls, initkwargs=None, \*args, \*\*kwargs)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Works just like ``TestCase.assertGoodView()``.
Caller must provide a view class instead of a URL name or path parameter.

All test\_plus TestCase side-effects are honored and all test\_plus
TestCase assertion methods work with ``CBVTestCase.post()``.

.. |travis ci status image| image:: https://secure.travis-ci.org/revsys/django-test-plus.png
:target: http://travis-ci.org/revsys/django-test-plus
.. |Coverage Status| image:: https://coveralls.io/repos/revsys/django-test-plus/badge.svg?branch=master
Expand Down
135 changes: 132 additions & 3 deletions test_plus/test.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import warnings
from distutils.version import LooseVersion

import django
from django.conf import settings
from django.core.urlresolvers import reverse, NoReverseMatch
from django.db import connections, DEFAULT_DB_ALIAS
from django.test import TestCase
from distutils.version import LooseVersion
from django.test import RequestFactory, signals, TestCase
from django.test.client import store_rendered_templates
from django.utils.functional import curry


class NoPreviousResponse(Exception):
Expand Down Expand Up @@ -241,4 +244,130 @@ def assertContext(self, key, value):
if self.last_response is not None:
self.assertEqual(self.last_response.context[key], value)
else:
raise NoPreviousResponse("There isn't a previous response to query")
raise NoPreviousResponse("There isn't a previous response to query")


# Note this class inherits from TestCase defined above.
class CBVTestCase(TestCase):
"""
Directly calls class-based generic view methods,
bypassing the Django test Client.
This process bypasses middleware invocation and URL resolvers.
Example usage:
from myapp.views import MyClass
class MyClassTest(CBVTestCase):
def test_special_method(self):
request = RequestFactory().get('/')
instance = self.get_instance(MyClass, request=request)
# invoke a MyClass method
result = instance.special_method()
# make assertions
self.assertTrue(result)
"""

def get_instance(self, cls, initkwargs=None, request=None, *args, **kwargs):
"""
Returns a decorated instance of a class-based generic view class.
Use `initkwargs` to set expected class attributes.
For example, set the `object` attribute on MyDetailView class:
instance = self.get_instance(MyDetailView, initkwargs={'object': obj}, request)
because SingleObjectMixin (part of generic.DetailView)
expects self.object to be set before invoking get_context_data().
`args` and `kwargs` are the same values you would pass to ``reverse()``.
"""
if initkwargs is None:
initkwargs = {}
instance = cls(**initkwargs)
instance.request = request
instance.args = args
instance.kwargs = kwargs
return instance

def get(self, cls, initkwargs=None, *args, **kwargs):
"""
Calls cls.get() method after instantiating view class
with `initkwargs`.
Renders view templates and sets context if appropriate.
"""
if initkwargs is None:
initkwargs = {}
request = RequestFactory().get('/')
instance = self.get_instance(cls, initkwargs=initkwargs, request=request, **kwargs)
self.last_response = self.get_response(request, instance.get)
self.context = self.last_response.context
return self.last_response

def post(self, cls, data=None, initkwargs=None, *args, **kwargs):
"""
Calls cls.post() method after instantiating view class
with `initkwargs`.
Renders view templates and sets context if appropriate.
"""
if data is None:
data = {}
if initkwargs is None:
initkwargs = {}
request = RequestFactory().post('/', data)
instance = self.get_instance(cls, initkwargs=initkwargs, request=request, **kwargs)
self.last_response = self.get_response(request, instance.post)
self.context = self.last_response.context
return self.last_response

def get_response(self, request, view_func):
"""
Obtain response from view class method (typically get or post).
No middleware is invoked, but templates are rendered
and context saved if appropriate.
"""
# Curry a data dictionary into an instance of
# the template renderer callback function.
data = {}
on_template_render = curry(store_rendered_templates, data)
signal_uid = "template-render-%s" % id(request)
signals.template_rendered.connect(on_template_render, dispatch_uid=signal_uid)
try:
response = view_func(request)

if hasattr(response, 'render') and callable(response.render):
response = response.render()
# Add any rendered template detail to the response.
response.templates = data.get("templates", [])
response.context = data.get("context")
else:
response.templates = None
response.context = None

return response
finally:
signals.template_rendered.disconnect(dispatch_uid=signal_uid)

def get_check_200(self, cls, initkwargs=None, *args, **kwargs):
""" Test that we can GET a page and it returns a 200 """
response = self.get(cls, initkwargs=initkwargs, *args, **kwargs)
self.response_200(response)
return response

def assertGoodView(self, cls, initkwargs=None, *args, **kwargs):
"""
Quick-n-dirty testing of a given view.
Ensures view returns a 200 status and that generates less than 50
database queries.
"""
query_count = kwargs.pop('test_query_count', 50)

with self.assertNumQueriesLessThan(query_count):
response = self.get(cls, initkwargs=initkwargs, *args, **kwargs)
self.response_200(response)
return response
1 change: 1 addition & 0 deletions test_project/test_app/templates/other.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<p>Another template</p>
1 change: 1 addition & 0 deletions test_project/test_app/templates/test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<p>Hello world</p>
57 changes: 56 additions & 1 deletion test_project/test_app/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,16 @@

from distutils.version import LooseVersion

from test_plus.test import TestCase, NoPreviousResponse
from test_plus.test import (
CBVTestCase,
NoPreviousResponse,
TestCase
)

from .views import (
CBView,
CBTemplateView,
)

DJANGO_16 = LooseVersion(django.get_version()) >= LooseVersion('1.6')

Expand Down Expand Up @@ -199,3 +208,49 @@ def test_post_is_ajax(self):
data={'item': 1},
extra={'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest'})
self.response_200(response)


class TestPlusCBViewTests(CBVTestCase):

def test_get(self):
response = self.get(CBView)
self.assertEqual(response.status_code, 200)

def test_post(self):
data = {'testing': True}
response = self.post(CBView, data=data)
self.assertEqual(response.status_code, 200)

def test_get_check_200(self):
self.get_check_200(CBView)

def test_assert_good_view(self):
self.assertGoodView(CBView)


class TestPlusCBTemplateViewTests(CBVTestCase):

def test_get(self):
response = self.get(CBTemplateView)
self.assertEqual(response.status_code, 200)
self.assertInContext('revsys')
self.assertContext('revsys', 42)
self.assertTemplateUsed(response, template_name='test.html')

def test_get_new_template(self):
template_name = 'other.html'
response = self.get(CBTemplateView, initkwargs={'template_name': template_name})
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, template_name=template_name)


class TestPlusCBCustomMethodTests(CBVTestCase):

def test_custom_method_with_value(self):
special_value = 42
instance = self.get_instance(CBView, {'special_value': special_value})
self.assertEqual(instance.special(), special_value)

def test_custom_method_no_value(self):
instance = self.get_instance(CBView)
self.assertFalse(instance.special())
29 changes: 29 additions & 0 deletions test_project/test_app/views.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render
from django.views import generic

from .models import Data


# Function-based test views

def view_200(request):
return HttpResponse('', status=200)

Expand Down Expand Up @@ -54,3 +57,29 @@ def view_context_without(request):

def view_is_ajax(request):
return HttpResponse('', status=200 if request.is_ajax() else 404)


# Class-based test views

class CBView(generic.View):

def get(self, request):
return HttpResponse('', status=200)

def post(self, request):
return HttpResponse('', status=200)

def special(self):
if hasattr(self, 'special_value'):
return self.special_value
else:
return False


class CBTemplateView(generic.TemplateView):

template_name = 'test.html'

def get_context_data(self, **kwargs):
kwargs['revsys'] = 42
return kwargs

0 comments on commit d78d328

Please sign in to comment.