Proper Django Testing

by Martin Angelov @ EuroPython 2018, Edinburgh

Who am I?

Martin Angelov

https://www.google.com/maps

Burgas

https://www.facebook.com/BurgasCity/photos

Software Engineering at Sofia University

Let's start

What will we talk about today?

  • Testing in Django/Python
  • Mainly about unit tests
  • Django's app structure
  • Some other software testing methodologies

Before we start ...

  • How many of you have written Django?

 

  • How many of you have written a unit test in Django?

Unit testing

Principals/Advantages

  • 1 test should test 1 unit
  • Easy to read
  • Fast to execute
  • Isolated
  • Helps modifiability
  • Can serve as a documentation for developers

Problems/Trade-offs

  • 1 test should test 1 unit
  • Hard to maintain when their code base become too big
  • Mocking
  • Developers' laziness
  • Developers tend to break their principles

Unit testing in Django

Proper Django unit testing is strongly bounded to proper Django app structure

Let's take a look at the following example...

class InvoiceCreateView(View):
    def get(self, request, *args, **kwargs):
        form = self.form_class(initial=self.initial)
        return render(request,
                      self.template_name,
                      {'form': form})

    def post(self, request, *args, **kwargs):
        form = self.form_class(request.POST)
        if form.is_valid():
            Invoice.objects.create(**form.cleaned_data)
            return HttpResponseRedirect('/success/')

        return render(request,
                      self.template_name,
                      {'form': form})

This is how a unit test for this view would look like

class InvoiceCreateViewTests(TestCase):
    def setUp(self):
        self.url = self.reverse('invoices:create')
        self.data = {'amount': faker.pyint(),
                     'description': faker.text(),
                     'created_by': AdminFactory()}

    def test_view_can_be_accessed(self):
        self.get(self.url, follow=True)
        self.response_200()
    
    def test_view_creates_new_invoice_with_valid_data(self):
        assertFalse(
            Invoice.objects.filter(**self.data).exists()
        )
    
        self.post(self.url, data=self.data, follow=True)
        self.response_302()
    
        assertTrue(
            Invoice.objects.filter(**self.data).exists()
        )

Is this properly unit tested?

class InvoiceCreateViewTests(TestCase):
    def setUp(self):
        self.url = self.reverse('invoices:create')
        self.data = {'amount': faker.pyint(),
                     'description': faker.text(),
                     'created_by': AdminFactory()}

    def test_view_can_be_accessed(self):
        self.get(self.url, follow=True)
        self.response_200()
    
    def test_view_creates_new_invoice_with_valid_data(self):
        assertFalse(
            Invoice.objects.filter(**self.data).exists()
        )
    
        self.post(self.url, data=self.data, follow=True)
        self.response_302()
    
        assertTrue(
            Invoice.objects.filter(**self.data).exists()
        )

One unit does too many different things

More problems ...

In real world projects tend to be more complicated.

This view would also need to:

  • Send emails
  • Sync with accounting third party
  • Make validations
  • Add payments to the invoice
  • Call a third party about the payments
  • etc.

Where should all these logic be?

The services concept

View

Service

HTTP

Django ORM

Tasks

Services

Validations

Business Logic

View unit

Service unit

def validate_amount(amount):
    if amount > 0:
        return

    raise InvalidInvoiceAmount()

def create_invoice(*, created_by, description, amount):
    validate_amount(amount)

    invoice = Invoice.objects.create(
        created_by=created_by,
        description=description,
        amount=amount
    )

    tasks.sync_invoice.s(invoice_id)

    return invoice
class InvoiceCreateView(View):
    def get(self, request, *args, **kwargs):
        form = self.form_class(initial=self.initial)
        return render(request,
                      self.template_name,
                      {'form': form})

    def post(self, request, *args, **kwargs):
        form = self.form_class(request.POST)
        if form.is_valid():
            create_invoice(**form.cleaned_data)
            return HttpResponseRedirect('/success/')

        return render(request,
                      self.template_name,
                      {'form': form})
class InvoiceCreateApiView(APIView):
    def post(self, request, *args, **kwargs):
        serializer = self.serializer_class(
            data=request.data
        )
        serializer.is_valid(raise_exception=True)
           
        invoice = create_invoice(
            **serializer.validated_data
        )

        return Response(
            {'invoice_id': invoice.id},
            status=status.HTTP_201_CREATED
        )

Now you can unit test properly ->

class InvoiceCreateViewTests(TestCase):
    def test_view_can_be_accessed(self):
        self.get(self.url, follow=True)
        self.response_200()

    def test_view_redirects_after_successful_post(self):
        self.post(self.url, data=self.data, follow=True)
        self.response_302()

    @mock.patch('app.views.invoices.create_invoice')
    def test_view_calls_service(self, service_mock):
        self.post(self.url, data=self.data, follow=True)

        service_mock.assert_called_once_with(
            **self.data
        )
class InvoiceCreateApiViewTests(TestCase):
    @mock.patch('app.views.invoices.create_booking')
    def test_view_calls_create_booking_service(
        self, service_mock
    )
        self.client.post(
            self.url, data=self.data, follow=True
        )

        service_mock.assert_called_once_with(
            amount=self.data['amount'],
            description=self.data['description'],
            created_by=self.admin
        )

CreateInvoiceTests

class CreateInvoiceTests(TestCase):
    @mock.patch('app.invoices.services.validate_amount')
    def test_service_creates_new_invoice(
        self, validate_mock
    ):
        assertFalse(
            Invoice.objects.filter(**self.data).exists()
        )

        self.service(**self.kwargs)

        assertTrue(
            Invoice.objects.filter(**self.data).exists()
        )
class CreateInvoiceTests(TestCase):
    @mock.patch('app.invoices.services.validate_amount')
    @mock.patch('app.invoices.services.tasks.sync_invoice.s')
    def test_service_calls_sync_invoice_task(
        self, task_mock, validate_mock
    ):
        invoice = self.service(**self.data)

        task_mock.assert_called_once_with(invoice.id)
class CreateInvoiceTests(TestCase):
    @mock.patch('app.invoices.services.validate_amount')
    @mock.patch('app.invoices.services.tasks.sync_invoice.s')
    def test_invoice_is_not_created_if_amount_is_not_valid(
        self, task_mock, validate_mock
    ):
        validate_mock.side_effect = InvalideInvoiceAmout()

        with self.assertRaises():
            self.service(**self.data)

            assertFalse(
                Invoice.objects.filter(**self.data).exists()
            )
            task_mock.assert_not_called()
            

What did we achieve?

  • Defining a unit
  • => straightforward boundaries between units
  • => proper unit testing
  • => maintainability and modifiability

Unit testing groups in Django

  • views
  • APIs
  • services
  • models
  • utils
  • others

Common mistakes

  • Non-exhaustive tests
  • Unnecessary repetition of asserts/ "overtesting"
  • Hard coding values
  • Too complicated test code
  • Misleading test names
  • Messing up units/ not mocking

Getting along

  • Keep up with the principles
  • Review both code and tests during a code review
  • Keep it simple

Going further...

tooling

Test modules

unittest

django.test

test_plus

without self.subTest()

class InvoiceCreateViewTests(TestCase):
    def test_view_redirects_after_successful_post(self):
        self.post(self.url, data=self.data, follow=True)
        self.response_302()

    @mock.patch('app.views.invoices.create_invoice')
    def test_view_calls_service(self, service_mock):
        self.post(self.url, data=self.data, follow=True)

        service_mock.assert_called_once_with(**self.data)

with self.subTest()

class InvoiceCreateViewTests(TestCase):
    @mock.patch('app.views.invoices.create_invoice')
    def test_successful_post(self, service_mock):
        self.post(self.url, data=self.data, follow=True)

        with self.subTest('Assert view redirects'):
            self.response_302()
        
        with self.subTest('Assert service is called'):
            service_mock.assert_called_once_with(
                **self.data
            )

factory_boy

class InvoiceFactory(factory.DjangoModelFactory):
    class Meta:
        model = Invoice

    amount = factory.LazyAttribute(
        lambda _: faker.pyint()
    )
    description = factory.LazyAttribute(
        lambda _: faker.text()
    )
    created_by = factory.SubFactory(AdminFactory)

faker

original_pyint_provider = faker.pyint


def pyint_provider(*args, **kwargs):
    return abs(original_pyint_provider(*args, **kwargs))


faker.pyint = pyint_provider

py.test

py.test app/tests/views/invoices.py --create-db --reuse-db --lf

Going furtherer...

other testing methodologies

Testing methodologies

  • E2E testing
  • Integration testing
  • Validation testing
  • Visual regression testing
  • Acceptance testing

Thank you!

facebook.com/martin.angelov056

twitter.com/_martin056

github.com/martin056

www.hacksoft.io/blog/

Q&A

Links

  • https://docs.python.org/3/library/unittest.html

  • https://docs.python.org/3/library/unittest.mock.html

  • https://docs.djangoproject.com/en/2.0/topics/testing/

  • https://github.com/revsys/django-test-plus

  • http://factoryboy.readthedocs.io/en/latest/

  • http://factoryboy.readthedocs.io/en/latest/

  • https://docs.pytest.org/en/latest/

  • https://slides.com/hackbulgaria/django-structure-for-scale-and-longevity#/

Proper Django Testing

By Martin Angelov

Proper Django Testing

EuroPython 2018

  • 1,196