Testing in Django

Honza Král

honza.kral@gmail.com

@honzakral

Testing in Django

Confession time!

Raise your hand if you:

Why Test

Is it fun?

Be Lazy! Let the computer do the boring work.

Terminology

Unit Tests

from unittest import TestCase

class TestInterview(TestCase):
    def test_ask_indicators_when_active(self):
        interview = Interview( ... )
        self.assertEquals(True, interview.asking_started())
        self.assertEquals(False, interview.asking_ended())
        self.assertEquals(True, interview.can_ask())

Unit Tests

Integration Tests

from django.test import TestCase

class TestInterview(TestCase):
    def test_unanswered_questions(self):
        interview = Interview.objects.create(...)
        q = Question.objects.create(interview=interview, content='What ?')

        self.assertEquals([q], list(interview.unanswered_questions()))

Integration Tests

(Unit) Testability

Typical View

def my_view(request, some_id, action, ...):
    main_model = get_object_or_404(Model, pk=some_id)
    other = main_model.method(action)
    ...
    compute ... some ... data
    ...
    return render_to_response(...)

To test you need to:

Class-based views FTW!

class MyView(object):
    def get_objects(self, some_id):
        return get_object_or_404(Model, pk=some_id)

    def render_response(self, context):
        return render_to_response(...)

    def compute_data(self, m, action):
        m.method(action)
        ...
        return {'some': data}

    def __call__(self, request, some_id, action, ...):
        m = self.get_objects(some_id)
        context = self.compute_data(m, action)
        return self.render_response(context)

Templatetag

class BoxNode(template.Node):
    def __init__(self, model, lookup):
        self.model, self.lookup = model, lookup

    def get_data(self, model, lookup):
        return model.objects.get(** lookup)

    def render(self, context):
        data = self.get_data(self.model, self.lookup)
        return data.title

def _parse_box(bits):
    if (len(bits) != 4 or bits[2] != 'for') and ...:
        raise template.TemplateSyntaxError(...)
    ...
    return BoxNode(model=model, lookup={smart_str(bits[5]): bits[6]})

@register.tag('box')
def do_box(parser, token):
    return _parse_box(token.split_contents())

Testing templatetags I

from unittest import TestCase

class TestBoxParsing(TestCase):
    def test_parse_fails_on_too_few_arguments(self):
        self.assertRaises(TemplateSyntaxError, _parse_box, ['box', 'box_type', 'for'])

    def test_parse_box_with_pk(self):
        node = _parse_box( ['box', 'box_type', 'for', 'core.category', 'with', 'pk', '1'])

        self.assertTrue(isinstance(node, BoxNode))
        self.assertEquals('box_type', node.box_type)
        self.assertEquals(Category, node.model)
        self.assertEquals({'pk': 1}, node.lookup)

Testing templatetags II

from unittest import TestCase
from mock import Mock

class TestBoxRendering(TestCase):
    def test_box_renders_objects_title(self):
        class MyBoxNode(BoxNode):
            get_data = Mock(return_value=Article(title='Article 1'))

        bn = MyBoxNode('type', Article, lookup=('pk', 1))

        bn.get_data.assert_called_once_with(Article, {'pk': 1})
        self.assertEquals('Article 1', bn.render({}))

Testing Forms

from unittest import TestCase

class TestPaymentAssesmentFormSet(TestCase):
    def setUp(self):
        self.data = {...}

    def test_formset_validates_valid_data(self):
        fset = PaymentAssesmentFormSet(self.data)

        self.assertTrue(fset.is_valid())

    def test_fail_for_change_inside_a_month(self):
        self.data['form-0-valid_to'] = '1.06.2009'
        self.data['form-1-valid_from'] = '2.06.2009'
        fset = PaymentAssesmentFormSet(self.data)

        self.assertFalse(fset.is_valid())

Testing Cron Jobs

@periodic_task(run_every=timedelta(hours=1))
def maintenance(now=None):
    if now is None:
        now = datetime.now()

    site.maintenance(now)
    for cube in base.cubes.values():
        if cube._meta.maintenance:
            cube.data.maintenance(now)

Pass in what you need

def test_custom_crop_box_is_used(self):
    format =  Format(max_height=100, max_width=100)
    i = Image.new('RGB', (200, 200), "red")
    i.putpixel((99, 99), 0)
    f = Formatter(i, format, crop_box=(0,0,100,100))

    i, crop_box = f.format()

    self.assertEquals((0,0,100,100), crop_box)
    self.assertEquals((100, 100), i.size)
    self.assertEquals((0,0,0), i.getpixel((99,99)))

What if I need DB?

Model Factory

def get_user(password='secret', profile_data={}, commit=False, ** kwargs):
    defaults = {'email': 'someone@example.com', 'username': 'some_username'}
    defaults.update(kwargs)
    u = User(username=username, ** defaults)
    u.set_password(password)
    u._password = password # in case a test wants the clear-text password to login
    if commit:
        u.save()

    if hasattr(settings, 'AUTH_PROFILE_MODULE'):
        ProfileClass = get_profile_model()
        p = ProfileClass(user=u, ** profile_data)
        u._profile_cache = p
        if commit:
            p.save()

    return u

Related Model factory

def get_article(commit=False, ** kwargs):
    defaults = {'title': 'A1', 'text': 'Lorem ipsum ' * 10, ...}
    defaults.update(kwargs)
    if 'author' not in defaults:
        defaults['author'] = get_user(commit=commit)
    a = Article(** defaults)
    if commit:
        a.save()
    return a

Testing frameworks

import os, sys
from os.path import join, pardir, abspath, dirname

os.environ['DJANGO_SETTINGS_MODULE'] = 'test_project.settings'
# add test_project to python path
sys.path.insert(0, abspath(join(dirname(__file__), pardir)))

test_runner, old_config = None, None

def setup():
    global test_runner, old_config
    from django.test.simple import DjangoTestSuiteRunner
    test_runner = DjangoTestSuiteRunner()
    test_runner.setup_test_environment()
    old_config = test_runner.setup_databases()

def teardown():
    test_runner.teardown_databases(old_config)
    test_runner.teardown_test_environment()

Tests are Code

Treat it as such.

Questions and discussion

Honza Král

honza.kral@gmail.com

@honzakral