Introduction to Unit Testing in Django

This article introduces unit testing in Django and explains how to verify models, forms, views, URLs, authentication, permissions, email, file uploads, and database behavior. It covers Django’s test classes, the test client, setup methods, assertions, mocking, fixtures, test organization, common mistakes, and practical testing workflows.

Introduction to Unit Testing in Django

A Django application can appear to work correctly while still containing hidden problems.

A model method may return the wrong value for unusual data. A form may accept input that should be rejected. A protected view may accidentally become available to anonymous visitors. A change in one part of the application may silently break another feature.

Manual testing can catch some of these problems, but repeatedly checking every page and workflow becomes impractical as an application grows.

Automated tests solve this problem by running code and checking that it behaves as expected.

Django includes a test framework built on Python’s standard unittest module. It also provides additional tools for testing models, forms, views, templates, databases, authentication, email, and HTTP responses.

What Is a Unit Test?

A unit test checks one small piece of application behavior.

The unit might be:

  • a function
  • a model method
  • a form validator
  • a view
  • a permission rule
  • a serializer method
  • a utility class

A basic test follows three steps:

text

1
2
3
4
5
6
7
8
Arrange
    Create the required data and conditions.

Act
    Run the code being tested.

Assert
    Check that the result is correct.

For example:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from django.test import SimpleTestCase


def add(a, b):
    return a + b


class AddTests(SimpleTestCase):
    def test_adds_two_numbers(self):
        result = add(2, 3)

        self.assertEqual(result, 5)

The test arranges two numbers, calls the function, and asserts that the result equals 5.

Why Write Tests?

Tests provide a repeatable way to verify application behavior.

They are useful when:

  • adding a new feature
  • fixing a bug
  • refactoring existing code
  • upgrading Django
  • changing database models
  • reviewing another developer’s work
  • deploying to production

A test suite acts as a safety net.

Suppose a model method currently works:

python

1
2
def get_total(self):
    return self.price * self.quantity

Later, someone modifies it:

python

1
2
def get_total(self):
    return self.price + self.quantity

The application may still run, but the calculation is now wrong.

A test detects the change:

python

1
2
3
4
5
6
7
def test_get_total_multiplies_price_and_quantity(self):
    product = Product(
        price=10,
        quantity=3,
    )

    self.assertEqual(product.get_total(), 30)

Tests are especially valuable during refactoring because they help confirm that behavior remains unchanged even when the implementation is reorganized. Django’s documentation describes automated tests as a way to validate new code and detect unintended changes to existing behavior.

Tests Do Not Prove That an Application Has No Bugs

A passing test suite proves only that the tested cases passed.

It does not prove that:

  • every possible case was tested
  • every requirement is correct
  • the interface is easy to use
  • the application is secure
  • production configuration is correct
  • third-party services will always work

Tests reduce risk, but their value depends on what they check.

A test suite containing only easy or irrelevant cases may pass while important workflows remain broken.

Good tests focus on behavior that matters.

Django’s Test Classes

Django provides several test-case classes.

Test class Common use
SimpleTestCase Code that does not need database access
TestCase Most tests that use the database
TransactionTestCase Tests involving transaction behavior
LiveServerTestCase Tests requiring a running development-style server
StaticLiveServerTestCase Live-server tests that also need static files

For most application tests involving models, views, or forms, django.test.TestCase is the usual starting point. Django wraps TestCase tests in transactions to provide isolation and efficient database cleanup. Tests that need to examine transaction behavior directly should use TransactionTestCase instead.

SimpleTestCase

Use SimpleTestCase when the test does not need the database.

python

1
2
3
4
5
6
7
8
from django.test import SimpleTestCase


class PriceFormatterTests(SimpleTestCase):
    def test_formats_price_with_currency(self):
        result = format_price(12.5)

        self.assertEqual(result, "$12.50")

By default, SimpleTestCase prevents database queries.

This helps make it clear that the test is meant to be independent of the database.

Common uses include:

  • pure functions
  • utility methods
  • URL resolution
  • template rendering without models
  • validation that does not query the database

TestCase

Use TestCase for most tests that interact with Django models or the database.

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
from django.test import TestCase

from .models import Article


class ArticleTests(TestCase):
    def test_article_can_be_created(self):
        article = Article.objects.create(
            title="Testing Django",
            content="Example content",
        )

        self.assertEqual(
            Article.objects.count(),
            1,
        )
        self.assertEqual(
            article.title,
            "Testing Django",
        )

Django creates a separate test database, runs the tests against it, and isolates database changes between tests. Your normal development or production database should not be used as the test data store.

Where Django Finds Tests

A new Django app usually contains a tests.py file:

text

1
2
3
4
5
6
7
articles/
├── admin.py
├── apps.py
├── models.py
├── tests.py
├── urls.py
└── views.py

Tests can be written directly in this file:

python

1
2
3
4
5
6
from django.test import TestCase


class ArticleTests(TestCase):
    def test_example(self):
        self.assertTrue(True)

For a larger app, use a tests package:

text

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
articles/
├── tests/
│   ├── __init__.py
│   ├── test_forms.py
│   ├── test_models.py
│   ├── test_urls.py
│   └── test_views.py
├── models.py
├── urls.py
└── views.py

Test filenames should normally begin with:

text

1
test

Examples:

text

1
2
3
test_models.py
test_forms.py
test_views.py

Test methods should also begin with test:

python

1
2
def test_article_title_is_required(self):
    ...

Django’s default test discovery follows Python’s unittest conventions.

Running Tests

Run all tests with:

bash

1
python manage.py test

Django discovers tests in the installed applications and runs them.

Typical output looks like:

text

1
2
3
4
5
6
7
8
Found 8 test(s).
Creating test database for alias 'default'...
........
----------------------------------------------------------------------
Ran 8 tests in 0.142s

OK
Destroying test database for alias 'default'...

A dot represents a passing test:

text

1
.

An F represents a failed assertion:

text

1
F

An E represents an unexpected error:

text

1
E

Running Tests for One App

Run only the tests in one app:

bash

1
python manage.py test articles

Run one module:

bash

1
python manage.py test articles.tests.test_models

Run one test class:

bash

1
2
python manage.py test \
    articles.tests.test_models.ArticleTests

Run one method:

bash

1
2
python manage.py test \
    articles.tests.test_models.ArticleTests.test_title

Running a focused subset is useful while developing one feature.

Increasing Test Output

Use greater verbosity:

bash

1
python manage.py test --verbosity 2

This displays more information, including individual test names and database setup activity.

Short form:

bash

1
python manage.py test -v 2

Keeping the Test Database

Creating the test database can add time to repeated test runs.

Use:

bash

1
python manage.py test --keepdb

Django keeps the test database after the run and reuses it later when possible. The test infrastructure supports retaining an existing test database through the keepdb option.

Test Method Names

A test method should describe the behavior it checks.

Less useful:

python

1
2
def test_article(self):
    ...

More useful:

python

1
2
def test_unpublished_article_is_not_public(self):
    ...
python

1
2
def test_article_string_representation_uses_title(self):
    ...
python

1
2
def test_anonymous_user_is_redirected_to_login(self):
    ...

A descriptive name makes failures easier to understand.

Assertions

Assertions check whether a result matches the expected behavior.

Common assertions include:

Assertion Purpose
assertEqual(a, b) Values are equal
assertNotEqual(a, b) Values are different
assertTrue(value) Value is true
assertFalse(value) Value is false
assertIsNone(value) Value is None
assertIsNotNone(value) Value is not None
assertIn(item, collection) Item exists in collection
assertNotIn(item, collection) Item does not exist
assertRaises() Code raises an exception
assertContains() Response contains text
assertNotContains() Response does not contain text
assertRedirects() Response redirects correctly
assertTemplateUsed() A template was rendered
assertFormError() A form contains an expected error

Examples:

python

1
self.assertEqual(article.title, "Django Tests")
python

1
self.assertTrue(article.is_published)
python

1
2
3
4
self.assertContains(
    response,
    "Django Tests",
)

A test should fail when the application’s behavior differs from the requirement.

Testing a Model

Consider this model:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from django.db import models


class Article(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    is_published = models.BooleanField(
        default=False,
    )

    def __str__(self):
        return self.title

    def word_count(self):
        return len(self.content.split())

Tests can check its default values and methods:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from django.test import TestCase

from .models import Article


class ArticleModelTests(TestCase):
    def test_string_representation_uses_title(self):
        article = Article(
            title="Testing Django",
        )

        self.assertEqual(
            str(article),
            "Testing Django",
        )

    def test_article_is_unpublished_by_default(self):
        article = Article.objects.create(
            title="Draft",
            content="Draft content",
        )

        self.assertFalse(article.is_published)

    def test_word_count_returns_number_of_words(self):
        article = Article(
            title="Example",
            content="Django tests are useful",
        )

        self.assertEqual(
            article.word_count(),
            4,
        )

These tests focus on the behavior defined by the model.

Testing Database Constraints

Suppose an article slug must be unique:

python

1
2
3
class Article(models.Model):
    title = models.CharField(max_length=200)
    slug = models.SlugField(unique=True)

Test the database rule:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from django.db import IntegrityError
from django.test import TestCase

from .models import Article


class ArticleConstraintTests(TestCase):
    def test_slug_must_be_unique(self):
        Article.objects.create(
            title="First",
            slug="django-testing",
        )

        with self.assertRaises(IntegrityError):
            Article.objects.create(
                title="Second",
                slug="django-testing",
            )

Database constraints are important because model forms are not the only way records can be created.

setUp()

Use setUp() to prepare data before every test method.

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
from django.test import TestCase

from .models import Article


class ArticleTests(TestCase):
    def setUp(self):
        self.article = Article.objects.create(
            title="Django Testing",
            content="Example content",
        )

    def test_title(self):
        self.assertEqual(
            self.article.title,
            "Django Testing",
        )

    def test_is_unpublished(self):
        self.assertFalse(
            self.article.is_published,
        )

setUp() runs before each test.

Each test receives fresh test state because database changes are isolated.

setUpTestData()

Django’s TestCase provides setUpTestData() for data shared by every test method in a class.

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from django.test import TestCase

from .models import Article


class ArticleTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.article = Article.objects.create(
            title="Django Testing",
            content="Example content",
        )

    def test_title(self):
        self.assertEqual(
            self.article.title,
            "Django Testing",
        )

    def test_content(self):
        self.assertEqual(
            self.article.content,
            "Example content",
        )

setUpTestData() runs once for the class, while setUp() runs before every test method. Creating shared database records in setUpTestData() can make a test class faster.

Use setUp() when each test needs newly prepared mutable state.

Use setUpTestData() when the same mostly unchanged records can be shared.

Avoid Depending on Test Order

Tests should run independently.

Do not write tests like this:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class ArticleTests(TestCase):
    def test_1_create_article(self):
        Article.objects.create(
            title="Example",
        )

    def test_2_update_article(self):
        article = Article.objects.get(
            title="Example",
        )

The second test depends on the first.

Instead, create the required data in each test or in a setup method:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class ArticleTests(TestCase):
    def setUp(self):
        self.article = Article.objects.create(
            title="Example",
        )

    def test_article_can_be_updated(self):
        self.article.title = "Updated"
        self.article.save()

        self.assertEqual(
            self.article.title,
            "Updated",
        )

Django supports options such as --shuffle and --reverse that can help reveal accidental dependencies on test execution order.

Testing Forms

Consider a form:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
from django import forms

from .models import Article


class ArticleForm(forms.ModelForm):
    class Meta:
        model = Article
        fields = [
            "title",
            "content",
        ]

    def clean_title(self):
        title = self.cleaned_data["title"].strip()

        if len(title) < 5:
            raise forms.ValidationError(
                "The title is too short."
            )

        return title

Test valid data:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from django.test import TestCase

from .forms import ArticleForm


class ArticleFormTests(TestCase):
    def test_form_accepts_valid_data(self):
        form = ArticleForm(
            data={
                "title": "Django Tests",
                "content": "Example content",
            }
        )

        self.assertTrue(form.is_valid())

Test invalid data:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def test_form_rejects_short_title(self):
    form = ArticleForm(
        data={
            "title": "Test",
            "content": "Example content",
        }
    )

    self.assertFalse(form.is_valid())
    self.assertIn(
        "title",
        form.errors,
    )

Test the error message:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def test_short_title_error_message(self):
    form = ArticleForm(
        data={
            "title": "Test",
            "content": "Example",
        }
    )

    form.is_valid()

    self.assertEqual(
        form.errors["title"],
        ["The title is too short."],
    )

Testing Views

Django provides a test client that behaves like a lightweight browser.

It can make requests to Django views without starting the development server.

Every Django test case has access to:

python

1
self.client

The client is recreated for each test, so cookies and other client state do not automatically leak between test methods.

Consider this view:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from django.shortcuts import render

from .models import Article


def article_list(request):
    articles = Article.objects.filter(
        is_published=True,
    )

    return render(
        request,
        "articles/article_list.html",
        {"articles": articles},
    )

Test the response:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
from django.test import TestCase
from django.urls import reverse

from .models import Article


class ArticleListViewTests(TestCase):
    def test_view_returns_success(self):
        response = self.client.get(
            reverse("article-list"),
        )

        self.assertEqual(
            response.status_code,
            200,
        )

Use reverse() in Tests

Avoid hard-coding URLs:

python

1
response = self.client.get("/articles/")

Prefer named URL reversal:

python

1
2
3
4
5
6
from django.urls import reverse


response = self.client.get(
    reverse("article-list"),
)

This keeps tests working when the URL path changes but its name remains the same.

Testing the Template

Check that the correct template was used:

python

1
2
3
4
5
6
7
8
9
def test_view_uses_article_list_template(self):
    response = self.client.get(
        reverse("article-list"),
    )

    self.assertTemplateUsed(
        response,
        "articles/article_list.html",
    )

Testing Template Content

Check that text appears in the response:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def test_published_article_is_displayed(self):
    Article.objects.create(
        title="Published Article",
        content="Content",
        is_published=True,
    )

    response = self.client.get(
        reverse("article-list"),
    )

    self.assertContains(
        response,
        "Published Article",
    )

Check that content is absent:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def test_unpublished_article_is_not_displayed(self):
    Article.objects.create(
        title="Draft Article",
        content="Content",
        is_published=False,
    )

    response = self.client.get(
        reverse("article-list"),
    )

    self.assertNotContains(
        response,
        "Draft Article",
    )

Testing Context Data

Inspect the context passed to the template:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
def test_context_contains_published_articles(self):
    published = Article.objects.create(
        title="Published",
        content="Content",
        is_published=True,
    )

    Article.objects.create(
        title="Draft",
        content="Content",
        is_published=False,
    )

    response = self.client.get(
        reverse("article-list"),
    )

    self.assertQuerySetEqual(
        response.context["articles"],
        [published],
    )

Testing a Detail View

Suppose the URL is:

python

1
2
3
4
5
path(
    "articles/<int:pk>/",
    article_detail,
    name="article-detail",
)

Test an existing object:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
def test_detail_view_displays_article(self):
    article = Article.objects.create(
        title="Django Tests",
        content="Testing content",
    )

    response = self.client.get(
        reverse(
            "article-detail",
            kwargs={"pk": article.pk},
        )
    )

    self.assertEqual(
        response.status_code,
        200,
    )
    self.assertContains(
        response,
        "Django Tests",
    )

Test a missing object:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def test_missing_article_returns_404(self):
    response = self.client.get(
        reverse(
            "article-detail",
            kwargs={"pk": 999},
        )
    )

    self.assertEqual(
        response.status_code,
        404,
    )

Testing POST Requests

Suppose a view creates an article:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def test_valid_post_creates_article(self):
    response = self.client.post(
        reverse("article-create"),
        data={
            "title": "New Article",
            "content": "New content",
        },
    )

    self.assertEqual(
        Article.objects.count(),
        1,
    )

Also check the redirect:

python

1
2
3
4
self.assertRedirects(
    response,
    reverse("article-list"),
)

Test invalid input:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def test_invalid_post_does_not_create_article(self):
    response = self.client.post(
        reverse("article-create"),
        data={
            "title": "",
            "content": "",
        },
    )

    self.assertEqual(
        Article.objects.count(),
        0,
    )
    self.assertEqual(
        response.status_code,
        200,
    )

An invalid form normally returns the form page with validation errors rather than redirecting.

Testing Class-Based Views

Class-based views are tested through their URLs in the same way as function-based views.

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class ArticleListViewTests(TestCase):
    def test_list_view_returns_success(self):
        response = self.client.get(
            reverse("article-list"),
        )

        self.assertEqual(
            response.status_code,
            200,
        )

Testing through the URL exercises:

  • URL routing
  • middleware
  • the view
  • template rendering
  • response generation

For isolated view testing, Django also provides RequestFactory, but the test client is usually simpler for beginner-level view tests. The client is intended to simulate requests and inspect response status, content, redirects, templates, and context without requiring a running server.

Testing Authentication

Create a user with the configured user model:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from django.contrib.auth import get_user_model
from django.test import TestCase


User = get_user_model()


class DashboardTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = User.objects.create_user(
            username="alex",
            password="test-password",
        )

Use create_user() rather than assigning a plain-text password directly.

Logging In Through the Test Client

Use:

python

1
2
3
4
logged_in = self.client.login(
    username="alex",
    password="test-password",
)

Check the result:

python

1
self.assertTrue(logged_in)

Then request a protected view:

python

1
2
3
response = self.client.get(
    reverse("dashboard"),
)

Using force_login()

When the login process itself is not being tested, use:

python

1
self.client.force_login(self.user)

This logs in the user without checking the password through the authentication backend.

Example:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def test_authenticated_user_can_open_dashboard(self):
    self.client.force_login(self.user)

    response = self.client.get(
        reverse("dashboard"),
    )

    self.assertEqual(
        response.status_code,
        200,
    )

Testing Anonymous Access

Test that an anonymous user is redirected:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def test_anonymous_user_is_redirected(self):
    response = self.client.get(
        reverse("dashboard"),
    )

    expected_url = (
        f"{reverse('login')}"
        f"?next={reverse('dashboard')}"
    )

    self.assertRedirects(
        response,
        expected_url,
    )

This verifies that the protection exists at the view level.

Testing Permissions

Suppose a view requires:

text

1
articles.change_article

Create the permission:

python

1
2
3
4
5
6
7
8
from django.contrib.auth.models import Permission


permission = Permission.objects.get(
    codename="change_article",
)

self.user.user_permissions.add(permission)

Test permitted access:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
def test_user_with_permission_can_edit(self):
    permission = Permission.objects.get(
        codename="change_article",
    )
    self.user.user_permissions.add(permission)

    self.client.force_login(self.user)

    response = self.client.get(
        reverse(
            "article-update",
            kwargs={"pk": self.article.pk},
        )
    )

    self.assertEqual(
        response.status_code,
        200,
    )

Also test the negative case:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def test_user_without_permission_cannot_edit(self):
    self.client.force_login(self.user)

    response = self.client.get(
        reverse(
            "article-update",
            kwargs={"pk": self.article.pk},
        )
    )

    self.assertIn(
        response.status_code,
        [302, 403],
    )

The expected response depends on the view’s permission configuration.

Testing Ownership Rules

Permission tests should also cover object ownership when relevant.

Suppose users may edit only their own articles:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def test_user_cannot_edit_another_users_article(self):
    other_user = User.objects.create_user(
        username="sam",
        password="test-password",
    )

    article = Article.objects.create(
        title="Sam's Article",
        content="Content",
        author=other_user,
    )

    self.client.force_login(self.user)

    response = self.client.get(
        reverse(
            "article-update",
            kwargs={"pk": article.pk},
        )
    )

    self.assertEqual(
        response.status_code,
        403,
    )

Test both what users are allowed to do and what they must not be allowed to do.

Testing URLs

Use resolve() to test URL routing:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from django.test import SimpleTestCase
from django.urls import resolve, reverse

from .views import article_list


class ArticleURLTests(SimpleTestCase):
    def test_article_list_url_resolves(self):
        url = reverse("article-list")
        match = resolve(url)

        self.assertEqual(
            match.func,
            article_list,
        )

For a class-based view:

python

1
2
3
4
self.assertEqual(
    match.func.view_class,
    ArticleListView,
)

URL tests are most useful when routing itself contains meaningful complexity.

Testing Exceptions

Use assertRaises() when code should reject an invalid operation.

python

1
2
3
4
5
6
def test_negative_price_is_rejected(self):
    with self.assertRaises(ValueError):
        calculate_discounted_price(
            price=-10,
            discount=5,
        )

You can also inspect the message:

python

1
2
3
4
5
6
7
8
9
def test_negative_price_error_message(self):
    with self.assertRaisesMessage(
        ValueError,
        "Price cannot be negative.",
    ):
        calculate_discounted_price(
            price=-10,
            discount=5,
        )

Testing Email

Django replaces normal email delivery with an in-memory test outbox during tests.

Suppose a function sends an email:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from django.core.mail import send_mail


def send_welcome_email(user):
    send_mail(
        subject="Welcome",
        message="Thanks for registering.",
        from_email="noreply@example.com",
        recipient_list=[user.email],
    )

Test it:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from django.core import mail
from django.test import TestCase


class WelcomeEmailTests(TestCase):
    def test_welcome_email_is_sent(self):
        user = User.objects.create_user(
            username="alex",
            email="alex@example.com",
            password="test-password",
        )

        send_welcome_email(user)

        self.assertEqual(
            len(mail.outbox),
            1,
        )
        self.assertEqual(
            mail.outbox[0].subject,
            "Welcome",
        )
        self.assertEqual(
            mail.outbox[0].to,
            ["alex@example.com"],
        )

Django’s test environment installs a dummy email outbox so tests can inspect messages without sending real email.

Fixtures

A fixture contains predefined data that Django can load into the test database.

Example file:

text

1
articles/fixtures/articles.json
json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
[
    {
        "model": "articles.article",
        "pk": 1,
        "fields": {
            "title": "Fixture Article",
            "content": "Fixture content",
            "is_published": true
        }
    }
]

Load it in a test:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from django.test import TestCase


class ArticleFixtureTests(TestCase):
    fixtures = ["articles.json"]

    def test_fixture_article_exists(self):
        self.assertEqual(
            Article.objects.count(),
            1,
        )

Django loads declared fixtures before the tests use them. For TestCase, fixture data is loaded for the class and database isolation prevents test methods from affecting one another.

Fixtures can be useful for stable reference data, but they may become difficult to maintain when models change.

For many tests, creating only the required objects directly is clearer.

Factories and Helper Functions

Repeated model creation can be placed in a helper:

python

1
2
3
4
5
6
7
8
9
def create_article(**overrides):
    data = {
        "title": "Example Article",
        "content": "Example content",
        "is_published": False,
    }
    data.update(overrides)

    return Article.objects.create(**data)

Use it in tests:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def test_published_article_is_visible(self):
    article = create_article(
        is_published=True,
    )

    response = self.client.get(
        reverse("article-list"),
    )

    self.assertContains(
        response,
        article.title,
    )

Helper functions keep tests concise while making test data explicit.

Third-party factory libraries can provide more features, but beginners should first understand ordinary model creation and setup methods.

Mocking External Services

Unit tests should not normally make real network requests or contact production services.

Suppose a service calls an external API:

python

1
2
3
4
5
def publish_article(article):
    analytics.track(
        event="article_published",
        article_id=article.pk,
    )

Mock the dependency:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from unittest.mock import patch


class PublishArticleTests(TestCase):
    @patch("articles.services.analytics.track")
    def test_publish_tracks_event(self, track):
        article = Article.objects.create(
            title="Testing",
            content="Content",
        )

        publish_article(article)

        track.assert_called_once_with(
            event="article_published",
            article_id=article.pk,
        )

Mock where the dependency is used, not necessarily where it was originally defined.

Mocks are useful for:

  • external APIs
  • email service wrappers
  • payment providers
  • file storage
  • background task dispatch
  • slow or unreliable dependencies

Avoid mocking so much that the test no longer exercises meaningful application behavior.

Testing Time-Dependent Behavior

Code involving the current time can produce fragile tests.

Suppose an article is considered recent for seven days:

python

1
2
3
4
5
6
7
8
9
from datetime import timedelta

from django.utils import timezone


def is_recent(article):
    return article.created_at >= (
        timezone.now() - timedelta(days=7)
    )

A test can create controlled data:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from datetime import timedelta

from django.utils import timezone


def test_article_from_yesterday_is_recent(self):
    article = Article.objects.create(
        title="Recent",
        content="Content",
    )

    Article.objects.filter(
        pk=article.pk,
    ).update(
        created_at=(
            timezone.now()
            - timedelta(days=1)
        )
    )

    article.refresh_from_db()

    self.assertTrue(is_recent(article))

For complex time behavior, freeze or mock time through a well-defined boundary.

Testing File Uploads

Use SimpleUploadedFile:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from django.core.files.uploadedfile import (
    SimpleUploadedFile,
)
from django.test import TestCase


class DocumentUploadTests(TestCase):
    def test_pdf_can_be_uploaded(self):
        file = SimpleUploadedFile(
            "document.pdf",
            b"example file content",
            content_type="application/pdf",
        )

        response = self.client.post(
            reverse("document-upload"),
            data={
                "title": "Example",
                "file": file,
            },
        )

        self.assertEqual(
            response.status_code,
            302,
        )

Tests involving storage should clean up generated files or use a temporary storage directory.

Testing JSON Views

Send JSON:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def test_api_creates_article(self):
    response = self.client.post(
        reverse("article-api"),
        data={
            "title": "API Article",
            "content": "Content",
        },
        content_type="application/json",
    )

    self.assertEqual(
        response.status_code,
        201,
    )

Inspect JSON:

python

1
2
3
4
5
6
data = response.json()

self.assertEqual(
    data["title"],
    "API Article",
)

For Django REST Framework projects, its APITestCase and APIClient provide API-specific conveniences, but the same arrange-act-assert principles apply.

Testing Redirects

Use assertRedirects():

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
def test_create_redirects_to_detail(self):
    response = self.client.post(
        reverse("article-create"),
        data={
            "title": "New Article",
            "content": "Content",
        },
    )

    article = Article.objects.get()

    self.assertRedirects(
        response,
        reverse(
            "article-detail",
            kwargs={"pk": article.pk},
        ),
    )

This checks the redirect URL and response status.

Testing Messages

Suppose a view adds a success message:

python

1
2
3
4
5
6
7
from django.contrib import messages


messages.success(
    request,
    "Article created.",
)

Test it:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
from django.contrib.messages import get_messages


def test_create_adds_success_message(self):
    response = self.client.post(
        reverse("article-create"),
        data={
            "title": "New Article",
            "content": "Content",
        },
        follow=True,
    )

    messages = list(
        get_messages(response.wsgi_request)
    )

    self.assertEqual(
        str(messages[0]),
        "Article created.",
    )

The Test Database

When database tests run, Django creates a separate test database.

Conceptually:

text

1
2
3
4
5
Development database
    db.sqlite3

Test database
    temporary test database

Django applies migrations to the test database and runs tests against it. Test data is then cleaned up according to the test class being used. The database test utilities create a test database and run migrations before executing the suite.

Never rely on records in the development database.

Create every record required by the test.

Test Isolation

A test should not change the outcome of another test.

This means tests should not depend on:

  • execution order
  • leftover database records
  • global mutable state
  • files created by another test
  • a previous login
  • a previous cache value
  • an external service’s current state

Django provides database and test-client isolation, but application-level global state may still need explicit cleanup.

Unit Tests and Integration Tests

The phrase “unit test” is often used broadly in Django projects.

A narrow unit test checks one isolated function:

python

1
2
3
4
5
6
class DiscountTests(SimpleTestCase):
    def test_discount(self):
        self.assertEqual(
            calculate_discount(100, 10),
            90,
        )

A view test may involve:

  • URL routing
  • middleware
  • database queries
  • templates
  • authentication

That is closer to an integration test because several components work together.

Both kinds are valuable.

A practical Django test suite often contains:

text

1
2
3
4
5
6
7
8
Small unit tests
    Functions, validators, model methods

Application integration tests
    Views, forms, database behavior, permissions

Browser tests
    JavaScript and complete user workflows

The goal is not to force every test into one category. The goal is to test behavior at the most useful level.

What to Test

Prioritize behavior that contains risk or business value.

Good candidates include:

  • custom model methods
  • form validation
  • permission rules
  • authentication requirements
  • object ownership
  • important database constraints
  • calculations
  • status transitions
  • redirects
  • API validation
  • bug fixes
  • custom queryset logic

Do not spend large amounts of time testing Django’s own framework behavior.

For example, this test provides little value:

python

1
2
3
4
5
6
7
8
9
def test_char_field_stores_text(self):
    article = Article.objects.create(
        title="Example",
    )

    self.assertEqual(
        article.title,
        "Example",
    )

It mainly checks that Django’s CharField works.

This test is more valuable:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def test_published_queryset_excludes_drafts(self):
    published = Article.objects.create(
        title="Published",
        is_published=True,
    )
    Article.objects.create(
        title="Draft",
        is_published=False,
    )

    self.assertQuerySetEqual(
        Article.objects.published(),
        [published],
    )

It checks custom application behavior.

Test Boundaries and Edge Cases

Do not test only the normal case.

If a title must contain between 5 and 200 characters, useful cases include:

text

1
2
3
4
5
Empty title
4 characters
5 characters
200 characters
201 characters

For a numeric calculation, consider:

text

1
2
3
4
5
Zero
Negative numbers
Maximum value
Decimal value
Missing value

For permissions, consider:

text

1
2
3
4
5
6
7
8
Anonymous user
Authenticated user
Object owner
Different user
Staff user
Superuser
User with permission
User without permission

Bugs often exist at boundaries rather than in the most common case.

One Behavior per Test

A test should usually focus on one behavior.

Less focused:

python

1
2
3
4
5
6
7
8
def test_article_everything(self):
    # Creates an article.
    # Checks the title.
    # Checks the list page.
    # Checks editing.
    # Checks deletion.
    # Checks permissions.
    ...

More focused:

python

1
2
def test_article_string_uses_title(self):
    ...
python

1
2
def test_published_article_appears_in_list(self):
    ...
python

1
2
def test_author_can_edit_article(self):
    ...
python

1
2
def test_non_author_cannot_delete_article(self):
    ...

Focused tests make failures easier to diagnose.

Avoid Excessive Assertions

Several assertions are reasonable when they describe one behavior.

For example:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def test_valid_form_creates_article(self):
    response = self.client.post(
        reverse("article-create"),
        data={
            "title": "New Article",
            "content": "Content",
        },
    )

    self.assertEqual(
        Article.objects.count(),
        1,
    )

    article = Article.objects.get()

    self.assertEqual(
        article.title,
        "New Article",
    )

    self.assertRedirects(
        response,
        reverse(
            "article-detail",
            kwargs={"pk": article.pk},
        ),
    )

All assertions describe the successful creation behavior.

Avoid combining unrelated requirements merely to reduce the number of test methods.

Do Not Repeat Production Logic in Tests

Suppose production code calculates:

python

1
total = price * quantity

A weak test may repeat the same implementation:

python

1
2
3
4
5
6
expected = product.price * product.quantity

self.assertEqual(
    product.get_total(),
    expected,
)

If the requirement is known, state it directly:

python

1
2
3
4
5
6
7
8
9
product = Product(
    price=10,
    quantity=3,
)

self.assertEqual(
    product.get_total(),
    30,
)

A test should verify behavior independently rather than reproduce the same algorithm.

Bug-Fix Tests

When fixing a bug:

  1. Write a test that reproduces the bug.
  2. Run it and confirm that it fails.
  3. Fix the code.
  4. Run the test and confirm that it passes.
  5. Keep the test to prevent the bug from returning.

Example bug:

text

1
An unpublished article is visible publicly.

Regression test:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
def test_draft_article_is_not_public(self):
    article = Article.objects.create(
        title="Draft",
        content="Content",
        is_published=False,
    )

    response = self.client.get(
        reverse(
            "article-detail",
            kwargs={"pk": article.pk},
        )
    )

    self.assertEqual(
        response.status_code,
        404,
    )

This is called a regression test because it prevents previously fixed behavior from regressing.

Test Coverage

Coverage tools measure which lines or branches ran during tests.

A common tool is installed with:

bash

1
python -m pip install coverage

Run tests through it:

bash

1
coverage run manage.py test

View the report:

bash

1
coverage report

Generate an HTML report:

bash

1
coverage html

Coverage can identify untested code, but a high percentage does not guarantee high-quality tests.

A test may execute a line without making a meaningful assertion about its behavior.

Use coverage to locate gaps, not as the only measure of test quality.

Organizing a Test Suite

A practical structure is:

text

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
articles/
├── tests/
│   ├── __init__.py
│   ├── factories.py
│   ├── test_forms.py
│   ├── test_models.py
│   ├── test_permissions.py
│   ├── test_services.py
│   ├── test_urls.py
│   └── test_views.py

Possible responsibilities:

text

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
test_models.py
    Model methods and constraints

test_forms.py
    Form validation

test_views.py
    Responses, templates, context, redirects

test_permissions.py
    Authentication, authorization, ownership

test_services.py
    Business workflows

test_urls.py
    URL resolution

Do not create many tiny files before the app needs them.

A small app may be perfectly clear with one tests.py.

Common Beginner Mistakes

Not Creating Test Data

Tests do not use the normal development database.

Create the required records inside the test:

python

1
2
3
4
Article.objects.create(
    title="Example",
    content="Content",
)

Depending on Test Order

Each test must prepare its own state.

Never assume another test ran first.

Testing Only Status Code 200

This is incomplete:

python

1
2
3
4
self.assertEqual(
    response.status_code,
    200,
)

Also consider checking:

  • the template
  • context data
  • visible content
  • hidden content
  • database changes
  • permissions
  • redirects

Testing Only the Successful Case

Also test:

  • invalid data
  • missing data
  • anonymous access
  • unauthorized access
  • missing objects
  • duplicate values
  • boundary values

Using Plain-Text Password Assignment

Avoid:

python

1
2
3
4
user = User.objects.create(
    username="alex",
    password="password",
)

Use:

python

1
2
3
4
user = User.objects.create_user(
    username="alex",
    password="password",
)

Hard-Coding URLs

Avoid:

python

1
self.client.get("/articles/")

Prefer:

python

1
2
3
self.client.get(
    reverse("article-list"),
)

Making Real External Requests

Mock or replace external dependencies.

Tests should remain fast, repeatable, and independent of network availability.

Overusing Mocks

Mock external boundaries, not every internal method.

Too many mocks can make a test pass even when the real components no longer work together.

Testing Implementation Details

Avoid checking internal calls unless the call itself is part of the required behavior.

Prefer testing observable outcomes:

  • returned value
  • database change
  • response
  • sent message
  • permission decision

Writing Huge Tests

Split unrelated behaviors into separate test methods.

A failing test should clearly indicate what requirement broke.

Ignoring Failing Tests

A failing test means one of three things:

  • the application is wrong
  • the test is wrong
  • the requirement changed

Investigate the cause rather than removing the test merely to make the suite pass.

A Complete Example

Model:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from django.conf import settings
from django.db import models


class Article(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
    )
    is_published = models.BooleanField(
        default=False,
    )

    def __str__(self):
        return self.title

View:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from django.shortcuts import (
    get_object_or_404,
    render,
)


def article_detail(request, pk):
    article = get_object_or_404(
        Article,
        pk=pk,
        is_published=True,
    )

    return render(
        request,
        "articles/article_detail.html",
        {"article": article},
    )

URL:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from django.urls import path

from .views import article_detail


urlpatterns = [
    path(
        "articles/<int:pk>/",
        article_detail,
        name="article-detail",
    ),
]

Tests:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse

from .models import Article


User = get_user_model()


class ArticleDetailTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = User.objects.create_user(
            username="alex",
            password="test-password",
        )

        cls.published_article = (
            Article.objects.create(
                title="Published Article",
                content="Published content",
                author=cls.user,
                is_published=True,
            )
        )

        cls.draft_article = Article.objects.create(
            title="Draft Article",
            content="Draft content",
            author=cls.user,
            is_published=False,
        )

    def test_published_article_is_visible(self):
        response = self.client.get(
            reverse(
                "article-detail",
                kwargs={
                    "pk": (
                        self.published_article.pk
                    )
                },
            )
        )

        self.assertEqual(
            response.status_code,
            200,
        )
        self.assertTemplateUsed(
            response,
            "articles/article_detail.html",
        )
        self.assertContains(
            response,
            "Published Article",
        )

    def test_draft_article_returns_404(self):
        response = self.client.get(
            reverse(
                "article-detail",
                kwargs={
                    "pk": self.draft_article.pk,
                },
            )
        )

        self.assertEqual(
            response.status_code,
            404,
        )

    def test_missing_article_returns_404(self):
        response = self.client.get(
            reverse(
                "article-detail",
                kwargs={"pk": 9999},
            )
        )

        self.assertEqual(
            response.status_code,
            404,
        )

This test class checks:

  • the normal successful case
  • unpublished content
  • missing content
  • response status
  • template selection
  • rendered content

A practical workflow is:

  1. Identify one behavior.
  2. Create the minimum required test data.
  3. Perform the action.
  4. Assert the expected result.
  5. Run the focused test.
  6. Confirm the test fails when the behavior is broken.
  7. Make the implementation pass.
  8. Run the entire test suite.
  9. Refactor while keeping the tests green.

Useful commands include:

bash

1
python manage.py test
bash

1
python manage.py test articles
bash

1
2
python manage.py test \
    articles.tests.test_models
bash

1
python manage.py test --keepdb
bash

1
python manage.py test --shuffle

What to Test First

When adding tests to an existing application, begin with:

  1. Important business calculations
  2. Authentication requirements
  3. Permission and ownership rules
  4. Form and API validation
  5. Critical model methods
  6. Important create, update, and delete workflows
  7. Previously reported bugs
  8. High-risk edge cases

Do not begin by trying to test every line.

Start with behavior whose failure would matter most.

Mini Reference

text

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
SimpleTestCase
    Test code without database access

TestCase
    Standard Django database tests

TransactionTestCase
    Tests requiring real transaction behavior

self.client
    Simulates HTTP requests

setUp()
    Runs before every test method

setUpTestData()
    Creates shared class-level test data

assertEqual()
    Checks equality

assertContains()
    Checks response content

assertRedirects()
    Checks redirects

assertTemplateUsed()
    Checks template rendering

assertRaises()
    Checks expected exceptions

Basic model test:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from django.test import TestCase


class ArticleTests(TestCase):
    def test_string_representation(self):
        article = Article(
            title="Django Testing",
        )

        self.assertEqual(
            str(article),
            "Django Testing",
        )

Basic view test:

python

1
2
3
4
5
6
7
8
9
def test_article_list_returns_success(self):
    response = self.client.get(
        reverse("article-list"),
    )

    self.assertEqual(
        response.status_code,
        200,
    )

Run all tests:

bash

1
python manage.py test

Unit testing in Django provides a structured way to verify that application behavior remains correct.

The main ideas are:

  • tests arrange data, perform an action, and assert a result
  • Django’s testing tools build on Python’s unittest framework
  • SimpleTestCase is useful without a database
  • TestCase is the standard choice for most database-backed tests
  • Django creates an isolated test database
  • the test client simulates requests without starting a server
  • models, forms, views, templates, authentication, permissions, and email can all be tested
  • each test should be independent
  • tests should focus on important behavior and edge cases
  • bug fixes should include regression tests
  • coverage is useful, but meaningful assertions matter more than percentages

Start with small tests around the most important parts of the application. As the project grows, the test suite becomes both documentation of expected behavior and protection against accidental changes.

Join the Newsletter

Practical insights on Django, backend systems, deployment, architecture, and real-world development — delivered without noise.

Get updates when new guides, learning paths, cheat sheets, and field notes are published.

No spam. Unsubscribe anytime.



There is no third-party involved so don't worry - we won't share your details with anyone.