The Django Test Driven Development Cookbook
This is an annotation of the slides from this YouTube talk.
Speaker: Martin Brochhaus
Martin is currently working as the CTO of theartling.com and luxglove.com and is maintaining a codebase with ~30k lines of code and ~12k lines of tests. In this 45 minutes talk, he will show how you can setup your first Django test-suite with py.test, how you can create fixtures with mixer and how you can measure test coverage. He will also show common patterns and snippets that will help you overtake the initial hurdles of getting in to testing: How to speed up tests? How to mock third party libraries? How to test forms and class based views with Django's RequestFactory? How to test admin functions and management commands? How to test Django Rest Framework's API views and serializers?
Event Page: http://www.meetup.com/Singapore-Djang...
Produced by Engineers.SG
Slide 4
Agenda:
- Project Setup
- Testing Models
- Testing Admins
- Testing Views
- Testing Authentication
- Testing Forms
- Testing POST Requests
- Testing 404 Errors
- Mocking Requests
Slide 5
Project Setup
Let's create a new Django project
mkvirtualenv tried_and_tested
pip install Django
django-admin.py startproject tested
Slide 6
Add a test_settings.py
file
cd tested/tested
touch test_settings.py
Slide 7
Install pytest & plugins and create "pytest.ini"
pip install pytest
pip install pytest-django
pip install git+git://github.com/mverteuil/pytest-ipdb.git
pip install pytest-cov
deactivate
workon tried_and_tested
####pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = tested2.test_settings
addopts = --nomigrations --cov=. --cov-report=html
Slide 8
Put this code into .coveragerc
[run]
omit =
*apps.py,
*migrations/*,
*settings*,
*tests/*,
*urls.py,
*wsgi.py,
manage.py
Slide 9
- We are ready to test!
py.test
will find all files calledtest_*.py
- It wll execute all functions called
test_*()
on all classes that start withTest*
Slide 10
Testing Models
- Install
mixer
and create your first app - Remove
tests.py
and createtests
folder instead - Each Django app will have a
tests
folder - For each code file, i.e.
forms.py
we will have a test. I.e.,test_forms.py
pip install mixer
django-admin.py startapp birdie
rm birdie/tests.py
mkdir birdie/tests
touch birdie/tests/__init__.py
touch birdie/tests/test_models.py
Slide 11
- The main building block of most apps is a model
- We should start writing a test for our model
- Some models can have many mandatory fields and it can be quite tedious to create values for all those fields.
mixer
will help here.
Slide 12
Let's test if the model can be instantiated and saved:
# test_models.py
from mixer.backend.django import mixer
pytestmark = pytest.mark.django_db
class TestPost:
def test_init(self):
obj = mixer.blend('birdie.Post')
assert obj.pk == 1, 'Should save an instance'
Slide 13
- Try to run your first test
- This tells you that you have not created a model named
Post
yet - Also: Make sure to add
birdie
to yourINSTALLED_APPS
setting
Slide 14
Implement the model and run your tests again
from django.db import models
# Create your models here.
class Post(models.Model):
body = models.TextField()
Your test should now pass and you have 100% coverage
Slide 15
Testing models
- Imagine a model function that returns truncated body
- Before you implement the function, you have to write the test
- That means you have to "use" your function before it even exists
- This helps to think deeply about it, come up with a name, with allowed arguments, with type of return value, with different kids of invocations, etc
Slide 16
The function shall be called get_excerpt
and expect one argument
# test_models.py
def test_get_excerpt(self):
obj = mixer.blend('birdie.Post', body="Hello World!")
result = obj.get_excerpt(5)
assert result == "Hello", "Should return first few characters"
Run your tests often and fix each error until they pass.
Slide 17
Implement the function and run the tests again
# models.py
class Post(models.Model):
body = models.TextField()
def get_excerpt(self, char):
return self.body[:char]
Your test should now pass and you have 100% coverage
Slide 18
Testing admins
- We want to show the excerpt in our admin list view
- We need to write a function for this because "excerpt" is not a database field on the model
- Whenever we need to write a function, we know: We must also write atest for that function
- In order to instantiate an admin class, you must pass in a model class and an AdminSite() instance
Slide 19
Instantiate your admin class and call the new excerpt
function
import pytest
from django.contrib.admin.sites import AdminSite
from mixer.backend.django import mixer
from .. import admin
from .. import models
pytestmark = pytest.mark.django_db
class TestPostAdmin:
def test_excerpt(self):
site = AdminSite()
post_admin = admin.PostAdmin(models.Post, site)
obj = mixer.blend('birdie.Post', body='Hello World')
result = post_admin.excerpt(obj)
assert result == 'Hello', "Should return first few characters"
Slide 20
Implement the admin and run the tests again
from django.contrib import admin
from . import models
# Register your models here.
class PostAdmin(admin.ModelAdmin):
model = models.Post
list_display = ('excerpt', )
def excerpt(self, obj):
return obj.get_excerpt(5)
Slide 21
- We want to create a view that can be seen by anyone
- Django's
self.client.get()
is slow - We will use Django's
RequestFactory
instead - We can instantiate our class-based views just like we do it in our
urls.py
, viaViewName.as_view()
- To test our views, we create a
Request
, pass it to ourView
, then make assertions on the returnedResponse
- Treat class-based views as black-boxes
Slide 22
We want to create a view that can be seen by anyone
from django.test import RequestFactory
from .. import views
class TestHomeView:
def test_anonymous(self):
req = RequestFactory().get('/')
resp = views.HomeView.as_view()(req)
assert resp.status_code == 200, "Should be callable by anyone"
Slide 23
Implement the view and ru the tests again
# views.py
from django.views.generic import TemplateView
class HomeView(TemplateView):
template_name = 'birdie/home.html'
- Your tests should pass with 100% coverage
- This does NOT render the view and test the template
- This does NOT call
urls.py
Slide 24
Testing authentication:
- We want to create a view that can only be accessed by superusers
- We will use the
@method_decorator(login_required)
trick to protect our view - That means, that there must be a
.user
attribute on the Request. - Even if we want to rest as an anonymous user, in that case Django automatically
attaches a
AnonymousUser
instance to theRequest
, so we have to fake this as well
Slide 25
Testing authentication
from django.contrib.auth.models import AnonymousUser
from django.test import RequestFactory
from mixer.backend.django import mixer
pytestmark = pytest.mark.django_db
from .. import views
import pytest
pytestmark = pytest.mark.django_db
...
class TestAdminView:
def test_anonymous(self):
req = RequestFactory().get('/')
req.user = AnonymousUser()
resp = views.AdminView.as_view()(req)
assert 'login' in resp.url
def test_superuser(self):
user = mixer.blend('auth.User', is_superuser=True)
req = RequestFactory().get('/')
req.user = user
resp = views.AdminView.as_view()(req)
assert resp.status_code == 200, 'Authenticated user can access'
Slide 26
Implement the view and run the tests again
# test_views.py
from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator
from django.views.generic import TemplateView
...
class AdminView(TemplateView):
template_name = 'birdie/admin.html'
@method_decorator(login_required)
def dispatch(self, request, *args, **kwargs):
return super(AdminView, self).dispatch(request, *args, **kwargs)
Slide 27
We want to create a form that creates a Post object
# test_forms.py
import pytest
from .. import forms
pytestmark = pytest.mark.django_db
class TestPostForm:
def test_form(self):
form = forms.PostForm(data={})
assert form.is_valid() is False, 'Should be invalid if not data given'
form = forms.PostForm(data={'body': 'Hello'})
assert form.is_valid() is False, 'Should be invalid if too short'
assert 'body' in form.errors, 'Should have body field error'
form = forms.PostForm(data={'body': 'Hello World!!!!!!!!!!!!'})
assert form.is_valid() is True, "Should be valid if long enough"
Slide 28
- When you implement the form step by step, you will see various test errors
- They guide you towards your final goal
Slide 29
Implement the form and run the tests again
# forms.py
from django import forms
from . import models
class PostForm(forms.ModelForm):
class Meta:
model = models.Post
fields = ('body', )
def clean_body(self):
data = self.cleaned_data.get('body')
if len(data) <= 5:
raise forms.ValidationError("Message is too short")
return data
Slide 30
- We want to create a view that uses the PostForm to update a Post
- Testing POST requests works in the same way like GET requests
- The next example also shows how to pass POST data into the
view and how to pass URL
kwargs
into the view
Slide 31
Testing POST requests
# test_views.py
import pytest
from django.contrib.auth.models import AnonymousUser
from django.test import RequestFactory
from mixer.backend.django import mixer
pytestmark = pytest.mark.django_db
from .. import views
...
class TestPostUpdateView:
def test_get(self):
req = RequestFactory().get('/')
obj = mixer.blend('birdie.Post')
resp = views.PostUpdateView.as_view()(req, pk=obj.pk)
assert resp.status_code == 200, 'Should be callable by anyone'
def test_post(self):
post = mixer.blend('birdie.Post')
data = {'body': 'New Body Text!'}
req = RequestFactory().post('/', data=data)
resp = views.PostUpdateView.as_view()(req, pk=post.pk)
assert resp.status_code == 302, 'Should redirect to success view'
post.refresh_from_db()
assert post.body == 'New Body Text!', 'Should update the post'
Slide 32
Implement the view
# views.py
from django.views.generic import TemplateView, UpdateView
from . import models
from. import forms
class PostUpdateView(UpdateView):
model = models.Post
form_class = forms.PostForm
template_name = '/birdie/update.html'
success_url = '/'
Slide 33
Testing 404 errors
- Your views will often raise 404 errors
- Unfortunately, they are exceptions and they bubble up all the way into your tests, so you cannot simply check
assert resp.status_code == 404
- Instead, you have to execute the view inside a
with-statement
Slide 34
- If the user's name is "Martin", the PostUpdateView should raise a 404
from django.http import Http404
...
class TestPostUpdateView:
...
def test_security(self):
user = mixer.blend('auth.User', first_name='Martin')
post = mixer.blend('birdie.Post')
req = RequestFactory().post('/', data={})
req.user = user
with pytest.raises(Http404):
views.PostUpdateView.as_view()(req, pk=post.pk)
Slide 35
Update your implementation
# views.py
from django.http import Http404
...
class PostUpdateView(UpdateView):
model = models.Post
form_class = forms.PostForm
template_name = '/birdie/update.html'
success_url = '/'
def post(self, request, *args, **kwargs):
if getattr(request.user, 'first_name', None) == 'Martin':
raise Http404()
return super(PostUpdateView, self).post(request, *args, **kwargs)
Slide 36
Making requests:
- We want to implement a Stripe integration and send an email notification when we get a payment
- We will use the official
stripe
Python wrapper - Fictional: We learned from their docs that we can call
stripe.Charge()
and it returns a dictionary with{'id': 'charged'}"
- How can we avoid making actual HTTP requests to the Strpe API when we run our tests but still get the return dictionary because our view code depends on it?
Slide 37
We will mock the stripe Python wrapper and create our own expected fake-response
from django.core.mail import send_mail
from mock import patch
import stripe
class TestPaymentView:
@patch('birdie.views.stripe')
def test_payment(self, mock_stripe):
mock_stripe.Charge.return_value = {'id': '234'}
req = RequestFactory().post('/', data={'token': '123'})
resp = views.PaymentView.as_view()(req)
assert resp.status_code == 302, 'Should redirect to success_url'
assert len(mail.outbox) == 1, 'Should send an email'
Slide 38
Implement your view
views.py
from django.core.mail import send_mail
import stripe
...
class PaymentView(View):
def post(self, request, *args, **kwargs):
charge = stripe.Charge.create(
amount=100,
currency='sgd',
description='',
token=request.POST.get('token'),
)
send_mail(
'Payment received',
'Charge {} succeeded!'.format(charge['id']),
'server@example.com',
['admin@example.com', ],
)
return redirect('/')
Slide 39
You can run specific tests like so
py.test birdie/tests/test_views.py::TestAdminView::test_superuser
You can put breakpoints into your tests like so:
pytest.set_trace()
Slide 40
To be continued:
- Testing Templatetags
- Testing Django Management Commands
- Testing with Sessions
- Testing with Files
- Testing Django Rest Framework APIViews
- Running Tests in Parallel
Slide 41
Thank you! Ask me anything!