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#/
Copy of Proper Django Testing
By Daniele Faraglia
Copy of Proper Django Testing
EuroPython 2018
- 285