Django Test Coverage
Test coverage is a critical metric in software development that helps you understand how much of your code is being tested. In this tutorial, we'll explore how to measure and improve test coverage in Django applications, ensuring your code is reliable and thoroughly tested.
What is Test Coverage?
Test coverage is a measurement of how much of your code is executed when your test suite runs. It's expressed as a percentage, with 100% coverage indicating that every line of code is executed at least once during testing.
Good test coverage offers several benefits:
- Identifies untested code
- Helps prevent regressions
- Builds confidence in your codebase
- Improves code quality and maintainability
Setting Up Coverage in Django
To get started with measuring test coverage in Django, we'll use the popular coverage.py
package, which works well with Django's testing framework.
Installation
First, let's install the necessary packages:
pip install coverage pytest-django pytest-cov
Add these to your requirements.txt
or requirements-dev.txt
file:
coverage>=7.2.7
pytest-django>=4.5.2
pytest-cov>=4.1.0
Configuration
Create a .coveragerc
file in your project root to configure the coverage tool:
[run]
source = your_django_app/
omit =
*/migrations/*
*/tests/*
*/admin.py
*/apps.py
*/urls.py
*/settings.py
*/wsgi.py
*/asgi.py
manage.py
[report]
exclude_lines =
pragma: no cover
def __str__
def __repr__
if settings.DEBUG
raise NotImplementedError
This configuration tells coverage to:
- Measure code in your Django app
- Ignore migrations, test files, and Django boilerplate files
- Exclude certain patterns like
__str__
methods that don't need testing
Running Tests with Coverage
Using pytest
If you're using pytest (recommended), you can run tests with coverage like this:
pytest --cov=your_django_app
Using Django's test runner
If you prefer Django's built-in test runner:
coverage run manage.py test
coverage report
Understanding Coverage Reports
After running the tests with coverage, you'll get a report like this:
Name Stmts Miss Cover
-------------------------------------------------
your_django_app/__init__.py 1 0 100%
your_django_app/models.py 45 5 89%
your_django_app/views.py 60 10 83%
your_django_app/forms.py 15 2 87%
-------------------------------------------------
TOTAL 121 17 86%
This report shows:
- Files that were measured
- Number of statements in each file
- Number of statements that weren't executed during tests
- Percentage of statements covered
Generating HTML Reports
For a more detailed view, generate an HTML report:
coverage html
This creates a htmlcov
directory. Open htmlcov/index.html
in your browser to view an interactive report highlighting covered and uncovered lines.
Practical Example: Testing a Django Model
Let's see how to write tests for a Django model and measure coverage:
The Model
# books/models.py
from django.db import models
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.CharField(max_length=100)
publication_date = models.DateField()
isbn = models.CharField(max_length=13, unique=True)
page_count = models.PositiveIntegerField()
def is_long_book(self):
return self.page_count > 500
def __str__(self):
return f"{self.title} by {self.author}"
The Test
# books/tests.py
import pytest
from django.utils import timezone
from .models import Book
@pytest.mark.django_db
def test_book_creation():
book = Book.objects.create(
title="Django for Beginners",
author="William S. Vincent",
publication_date=timezone.now().date(),
isbn="1234567890123",
page_count=300
)
assert book.title == "Django for Beginners"
assert book.author == "William S. Vincent"
assert book.isbn == "1234567890123"
assert book.page_count == 300
@pytest.mark.django_db
def test_is_long_book():
short_book = Book.objects.create(
title="Short Book",
author="Author",
publication_date=timezone.now().date(),
isbn="1234567890124",
page_count=200
)
assert not short_book.is_long_book()
long_book = Book.objects.create(
title="Long Book",
author="Author",
publication_date=timezone.now().date(),
isbn="1234567890125",
page_count=600
)
assert long_book.is_long_book()
Running Coverage
pytest --cov=books tests/
This gives us 100% coverage for our Book model, as we've tested both the model's fields and its is_long_book
method.
Best Practices for Test Coverage
1. Aim for Quality, Not Just Quantity
High coverage doesn't necessarily mean good tests. Focus on:
- Testing edge cases
- Testing failure conditions
- Testing business logic thoroughly
2. Set Coverage Thresholds
You can enforce minimum coverage requirements in your CI/CD pipeline:
pytest --cov=your_django_app --cov-fail-under=80
This will fail the build if coverage drops below 80%.
3. Incremental Improvement
If you're working with an existing project with low coverage:
- Set a realistic baseline
- Gradually increase coverage requirements
- Focus on covering critical paths first
4. Integration with CI/CD
Add coverage to your continuous integration workflow:
# .github/workflows/django-test.yml example
name: Django Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Test with pytest and coverage
run: |
pytest --cov=your_django_app --cov-report=xml --cov-fail-under=80
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
Real-World Example: Testing a Django View
Let's test a more complex view with different conditions:
The View
# books/views.py
from django.contrib.auth.decorators import login_required
from django.shortcuts import render, get_object_or_404, redirect
from django.http import HttpResponseForbidden
from .models import Book
from .forms import BookForm
@login_required
def edit_book(request, book_id):
book = get_object_or_404(Book, id=book_id)
# Only staff can edit books
if not request.user.is_staff:
return HttpResponseForbidden("You don't have permission to edit books")
if request.method == 'POST':
form = BookForm(request.POST, instance=book)
if form.is_valid():
form.save()
return redirect('book_detail', book_id=book.id)
else:
form = BookForm(instance=book)
return render(request, 'books/edit_book.html', {'form': form, 'book': book})
The Test
# books/tests/test_views.py
import pytest
from django.urls import reverse
from django.contrib.auth.models import User
from books.models import Book
@pytest.mark.django_db
def test_edit_book_get(client):
# Create a staff user
staff_user = User.objects.create_user(username='staffuser', password='12345', is_staff=True)
client.force_login(staff_user)
# Create a book
book = Book.objects.create(
title="Original Title",
author="Original Author",
publication_date="2023-01-01",
isbn="1234567890123",
page_count=300
)
# Get the edit page
url = reverse('edit_book', args=[book.id])
response = client.get(url)
# Check that the page loads successfully
assert response.status_code == 200
assert 'form' in response.context
assert response.context['book'] == book
@pytest.mark.django_db
def test_edit_book_post(client):
# Create a staff user
staff_user = User.objects.create_user(username='staffuser', password='12345', is_staff=True)
client.force_login(staff_user)
# Create a book
book = Book.objects.create(
title="Original Title",
author="Original Author",
publication_date="2023-01-01",
isbn="1234567890123",
page_count=300
)
# Post updated data
url = reverse('edit_book', args=[book.id])
data = {
'title': 'Updated Title',
'author': 'Updated Author',
'publication_date': '2023-01-01',
'isbn': '1234567890123',
'page_count': 350
}
response = client.post(url, data)
# Check that the book was updated
book.refresh_from_db()
assert book.title == 'Updated Title'
assert book.author == 'Updated Author'
assert book.page_count == 350
# Check redirection
assert response.status_code == 302
assert response.url == reverse('book_detail', args=[book.id])
@pytest.mark.django_db
def test_edit_book_forbidden(client):
# Create a non-staff user
regular_user = User.objects.create_user(username='regularuser', password='12345')
client.force_login(regular_user)
# Create a book
book = Book.objects.create(
title="Original Title",
author="Original Author",
publication_date="2023-01-01",
isbn="1234567890123",
page_count=300
)
# Try to access the edit page
url = reverse('edit_book', args=[book.id])
response = client.get(url)
# Check that access is forbidden
assert response.status_code == 403
Running coverage on this will show if we've tested all the paths through this view.
Improving Coverage
If your coverage isn't 100%, examine the coverage report to identify untested code. For example, you might need to:
- Add tests for edge cases
- Test validation failures
- Test different user permissions
- Test error conditions
Summary
Test coverage is a valuable tool for ensuring your Django application is well-tested. By measuring coverage and gradually improving it, you can:
- Identify areas lacking tests
- Build confidence in your codebase
- Prevent regressions
- Create more maintainable applications
While 100% coverage shouldn't be the only goal, striving for high coverage will generally result in more robust applications.
Additional Resources
Exercises
- Set up test coverage for one of your Django projects.
- Identify the parts of your code with the lowest coverage and write tests for them.
- Create a CI/CD workflow that enforces a minimum coverage threshold.
- Try to achieve 90% coverage for a small Django application.
- Write tests that cover edge cases and error conditions in your application.
By following these practices, you'll develop more resilient Django applications with fewer bugs and easier maintenance.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)