Tests, Tests and More Tests
As a software programmer, you often hear others talking about tests being one of the most important components of any project. A software project often succeeds when there's proper test coverage. While it often fails when there's little or none. You might be wondering: what are tests anyway? Why is everyone constantly emphasizing its importance?
Tests are simple routines or mini-programs that check the correctness and completeness of your code. Some tests check tiny details of a software project - does a particular Django model get updated when a POST method is called?, while others check the overall operation of the software - does the code execute my business logic about customer order submission correctly?. No matter how small, every test is important since it tells you whether your code is broken or not. Although having 100% test coverage of your code is pretty hard and takes a significant amount of effort, you should always try to cover as much of your code with tests as possible.
Overall, tests:
- Save you time since they allow you to test the application without manually running the code all the time.
- Help you verify and clarify the software requirements since they force you to think about the problem at hand and write proper tests to prove that the solution works.
- Make your code more robust and attractive to others since they enable any reader to see your code has been proved to be working correctly under tests.
- Help teams work together since they allow teammates to verify each other's code by writing tests against the code.
Write Your First Automated Django Test
In our existing application myblog's index views, we're returning recent posts made by users less than two days from now. The code of the index view is attached here:
[python]
def index(request):
two_days_ago = datetime.utcnow() - timedelta(days=2)
recent_posts = m.Post.objects.filter(created_at__gt=two_days_ago).all()
context = Context({
'post_list': recent_posts
})
return render(request, 'index.html', context)
[/python]
There's a little bug in this view. Can you find it?
It seems that we're assuming that all posts in our website are "posted" in the past, namely that the Post.created_at
is earlier than timezone.now()
. However, it's highly possible that a user prepared a post in advance and wants to publish it in a future datetime instead. Obviously, the current code will also return those future posts. It can be verified in the following snippet:
[python]
>>> m.Post.objects.all().delete()
>>> import datetime
>>> from django.utils import timezone
>>> from myblog import models as m
>>> future_post = m.Post(content='Future Post',
>>> created_at=timezone.now() + datetime.timedelta(days=10))
>>> future_post.save()
>>> two_days_ago = datetime.datetime.utcnow() - datetime.timedelta(days=2)
>>> recent_posts = m.Post.objects.filter(created_at__gt=two_days_ago).all()
# recent_posts contain future_post, which is wrong.
>>> recent_posts[0].content
u'Future Post'
[/python]
Before we go ahead and fix the bug in the view, let's pause a little bit and write a test to expose this bug. First, we add a new method recent_posts()
onto the model Post
so we could extract the incorrect code out of the view:
[python]
import datetime
from django.db import models as m
from django.utils import timezone
class Post(m.Model):
content = m.CharField(max_length=256)
created_at = m.DateTimeField('datetime created')
@classmethod
def recent_posts(cls):
two_days_ago = timezone.now() - datetime.timedelta(days=2)
return Post.objects.filter(created_at__gt=two_days_ago)
[/python]
Then, we modify the index view's code to use the recent_posts()
method from the model Post
:
[python]
def index(request):
recent_posts = m.Post.recent_posts()
context = Context({
'post_list': recent_posts
})
return render(request, 'index.html', context)
[/python]
Now we add the following code into myblog/tests.py
so that we can run it to test the behaviour of our code:
[python]
import datetime
from django.utils import timezone
from django.test import TestCase
from myblog import models as m
class PostModelTests(TestCase):
def setUp(self):
''' Create a Post from the future. '''
super(PostModelTests, self).setUp()
self.future_post = m.Post(
content='Future Post', created_at=timezone.now() + datetime.timedelta(days=10))
self.future_post.save()
def tearDown(self):
''' Delete the post from the future. '''
super(PostModelTests, self).tearDown()
m.Post.objects.get(content='Future Post').delete()
def test_recent_posts_not_including_future_posts(self):
''' m.Post.recent_posts() should not return posts from the future. '''
recent_posts = m.Post.recent_posts()
self.assertNotIn(self.future_post, recent_posts)
[/python]
In this test case, we want to verify that future posts are not included in the list of posts returned from m.Post.recent_posts()
. Now you can run the tests by:
[shell]
$ python manage.py test
Creating test database for alias 'default'...
....................................................
======================================================================
FAIL: test_recent_posts_not_including_future_posts (myblog.tests.PostModelTests)
m.Post.recent_posts() should not return posts from the future.
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/user/python2-workspace/pythoncentral/django_series/article7/myblog/myblog/tests.py", line 23, in test_recent_posts_not_including_future_posts
self.assertNotIn(self.future_post, recent_posts)
AssertionError: unexpectedly found in []
----------------------------------------------------------------------
Ran 483 tests in 11.877s
FAILED (failures=1, skipped=1, expected failures=1)
Destroying test database for alias 'default'...
[/shell]
Since the post from the future is in the list returned from recent_posts()
and our test complained about it, we know for sure there's a bug in our code.
Fix Our Test Case Bug
We can easily fix the bug by making sure that m.Post.created_at
is earlier than timezone.now()
in recent_posts()
's query:
[python]
class Post(m.Model):
content = m.CharField(max_length=256)
created_at = m.DateTimeField('datetime created')
@classmethod
def recent_posts(cls):
now = timezone.now()
two_days_ago = now - datetime.timedelta(days=2)
return Post.objects.\
filter(created_at__gt=two_days_ago). \
filter(created_at__lt=now)
[/python]
Now you can re-run the test and it should pass without warning:
[shell]
$ python manage.py test
Creating test database for alias 'default'...
.................................................................................................................................................s.....................................................................................................................................x...........................................................................................................................................................................................................
----------------------------------------------------------------------
Ran 483 tests in 12.725s
OK (skipped=1, expected failures=1)
Destroying test database for alias 'default'...
[/shell]
Automated Test Case Summary and Tips
In this article, we learned how to write automated tests for our first Django application. Since writing tests is one of the best software engineering practices, it always pays off. It may seem counter-intuitive since you have to write more code to implement the same functionality, but tests will save a lot of your time in the future.
When writing a Django application, we put our test code into tests.py
and run them by running $ python manage.py test
. If there's any test that did not pass, then Django reports the error back to us so we can fix any bug accordingly. If all tests passed, then Django shows that there's no error and we can be very confident that our code works. Therefore, Having a proper test coverage over your code is one of the best ways to write high quality software.