Introduction

A bug caught locally costs you five minutes. A bug that reaches production costs you a customer, an incident call, and a late night.

Most Django applications start without tests. The first version ships, the product grows, and somewhere around the third or fourth refactor something breaks in a way nobody expected. A model method that worked in isolation fails because a view was now calling it with different arguments. A serializer quietly started dropping a field. A permission check that worked for one user type silently passed for another.

This is not bad luck. It is the predictable result of a codebase that has no automated safety net.

Tests are not about slowing you down. They are about making it safe to move fast. A good test suite lets you refactor confidently, deploy on a Friday, and add a new feature without spending an hour manually checking that nothing regressed.

This is the first article in an eight-part series on testing in Django. We are starting with the landscape: the concepts, the tools, and the vocabulary. Get this mental model right and everything that follows will click into place.

What is testing?

Testing is the practice of running your code against a set of known inputs and verifying that the outputs match your expectations.

When you write a function that calculates a discount and then open the Django shell to check if it returns the right number, that is testing. The difference between that and automated testing is that automated tests do the checking for you, every time, in milliseconds, without you having to remember.

Manual testing does not scale. As your application grows, the surface area of things that could break grows faster than any human can track. Automated tests give you a regression net: run them before every deploy and anything you previously verified will still be verified, forever, for free.

Think of it this way. When you write a feature and manually test it, you are building knowledge in your head: "the checkout flow works when the user has a coupon." That knowledge lives only in your head and it fades. Six months later, a different developer changes the coupon model and has no idea that checkout depends on it in a specific way. With automated tests, that knowledge lives in code. It never fades and it runs on every change.

A test suite is documentation that runs. It tells you what the code is supposed to do, and it tells you the moment it stops doing it.

The test pyramid

Not all tests are equal. They differ in scope, speed, confidence, and cost. The test pyramid is a model that describes how to balance them.

The pyramid has three layers. The base is wide and cheap: unit tests. The middle is narrower: integration tests. The top is thin and expensive: end-to-end tests.

The pyramid shape is not aesthetic. It is economic. A unit test that runs in 2ms tells you a function returns the right value. An end-to-end test that boots a browser, logs in, clicks through a flow, and checks the result takes 10 seconds and costs ten times more to maintain. You want many of the former and few of the latter.

The mistake most teams make is inverting the pyramid: lots of slow end-to-end tests and almost no unit tests. The suite takes 20 minutes to run, nobody runs it locally, and when it fails in CI the failure is so far removed from the actual bug that it takes an hour to diagnose.

Note

The three layers at a glance

Unit tests: many, fast, isolated — test a single function or method. Integration tests: some, moderate speed — test how components work together. End-to-end tests: few, slow — test the full system from the outside as a real user would.

Unit tests

A unit test verifies a single piece of logic in isolation. The word "unit" usually means a function or a method. The test calls it with specific inputs and asserts on the output. Nothing else is involved: no database, no network, no filesystem.

In Django this means testing model methods, utility functions, custom validators, serializer logic, and any standalone business rules your application has.

The reason for strict isolation is precision. When a unit test fails, you know exactly which function broke. There is no guessing, no log hunting, no reproducing a request. The test points at the problem directly.

orders/tests/test_discount.py
from django.test import TestCase
from orders.utils import calculate_discount


class CalculateDiscountTest(TestCase):
    def test_ten_percent_off_for_loyalty_customers(self):
        price = 100
        discount = calculate_discount(price, customer_type="loyalty")
        self.assertEqual(discount, 90)

    def test_no_discount_for_regular_customers(self):
        price = 100
        discount = calculate_discount(price, customer_type="regular")
        self.assertEqual(discount, 100)

    def test_discount_cannot_produce_negative_price(self):
        discount = calculate_discount(5, customer_type="loyalty")
        self.assertGreaterEqual(discount, 0)

Notice that each test method covers one specific scenario. Three behaviours, three tests. This granularity matters. When test_discount_cannot_produce_negative_price fails, you know exactly what broke without reading a single line of application code.

Unit tests are the bedrock of your suite. They run fast (hundreds per second), they pinpoint failures precisely, and they are cheap to write and maintain.

Their limitation is that they do not tell you how components behave together. A function can pass all its unit tests and still cause a bug when it is called from a view with real database data. That gap is what integration tests are for.

Integration tests

An integration test verifies that two or more components work correctly together. In Django this typically means testing a view end-to-end: make an HTTP request, let it hit the database, and assert on the response.

Integration tests are slower than unit tests because they touch real infrastructure. Django's test runner creates an actual test database and rolls it back between tests. But they catch an entire class of bugs that unit tests cannot: wiring problems.

Here are some real examples of bugs that only integration tests would catch:

  • The function was correct but the view called it with the wrong argument.
  • The serializer was correct but the URL conf pointed to the wrong view.
  • The permission check was correct but the middleware ran in the wrong order.
  • The model method worked in isolation but failed once select_related was involved.
orders/tests/test_views.py
from django.test import TestCase
from django.contrib.auth import get_user_model
from orders.models import Order

User = get_user_model()


class CreateOrderViewTest(TestCase):
    def setUp(self):
        self.user = User.objects.create_user(
            username="testuser",
            password="testpass123",
        )
        self.client.login(username="testuser", password="testpass123")

    def test_authenticated_user_can_create_order(self):
        response = self.client.post("/api/orders/", {"item": "Widget", "quantity": 2})
        self.assertEqual(response.status_code, 201)
        self.assertEqual(Order.objects.count(), 1)

    def test_unauthenticated_request_is_rejected(self):
        self.client.logout()
        response = self.client.post("/api/orders/", {"item": "Widget", "quantity": 2})
        self.assertEqual(response.status_code, 401)

This test exercises the full stack: URL routing, view logic, authentication middleware, serialization, and database writes. If any layer is broken, the test fails.

Most of your application-level confidence comes from integration tests. They are the sweet spot: slower than unit tests but dramatically faster than end-to-end tests, and they test the things that actually break in production.

End-to-end tests

An end-to-end (E2E) test exercises your entire system from the outside, exactly as a real user would. In a web application this means controlling a browser: navigating to a URL, filling in a form, clicking a button, and asserting that the right text appears on the page.

Tools like Playwright and Selenium do this. They are valuable for testing critical user journeys such as checkout, login, and account creation, where you need confidence that the entire stack is wired up correctly in a production-like environment.

The cost is high for three reasons:

  • Slow. Each test takes seconds because a real browser must load, render, and execute JavaScript.
  • Flaky. Timing, network, and browser state all introduce non-determinism. A test that passes nine times and fails once is harder to trust than one that always passes or always fails.
  • Expensive to maintain. Every UI change potentially breaks E2E tests, even when the underlying behaviour is correct.

Treat E2E tests as a supplement to a strong unit and integration test suite, not a replacement. This series focuses on unit and integration tests. E2E testing with Playwright is a topic for another day.

Django's testing toolkit

Django ships with a solid testing foundation out of the box. You do not need to install anything to get started.

django.test.TestCase

django.test.TestCase is Django's base test class. It extends Python's standard library unittest.TestCase and adds two important things.

First, it wraps every test in a database transaction and rolls it back after each test. This means tests never bleed database state into one another. You can create, modify, and delete records freely inside a test and none of it will affect the next test.

Second, it gives you self.client, a test HTTP client that can make requests to your views without a running web server.

python
from django.test import TestCase


class MyTest(TestCase):
    def test_something(self):
        self.assertEqual(1 + 1, 2)

✦ Tip

Use setUpTestData for expensive setup

setUp() runs before every test method, which means database records are created and rolled back for each test. setUpTestData() is a class method that runs once for the entire test class and wraps everything in a single transaction. Use it when creating database fixtures that all tests in the class share.

python
from django.test import TestCase
from orders.models import Order


class OrderTest(TestCase):
    @classmethod
    def setUpTestData(cls):
        # Runs once for the whole class. Much faster for large fixture sets.
        cls.order = Order.objects.create(subtotal="100.00")

    def test_one(self):
        self.assertEqual(self.order.subtotal, "100.00")

    def test_two(self):
        # Still sees cls.order — created once, shared across all tests
        self.assertIsNotNone(self.order.pk)

The test client

self.client is a test HTTP client that simulates browser requests to your Django application without a running web server. It supports GET, POST, PUT, PATCH, and DELETE. It handles sessions and cookies automatically and returns Django HttpResponse objects you can assert on.

Django provides several assertion helpers on TestCase specifically for working with responses:

python
response = self.client.get("/dashboard/")

# Assert on the HTTP status code
self.assertEqual(response.status_code, 200)

# Assert that a string appears in the response body
self.assertContains(response, "Welcome back")

# Assert that a specific template was used to render the response
self.assertTemplateUsed(response, "dashboard.html")

# Assert a redirect happened and where it went
self.assertRedirects(response, "/login/?next=/dashboard/")

The test runner

Django's test runner is the engine that finds and runs your tests. It does several things automatically:

  • Discovers all test*.py files in your project.
  • Creates a dedicated test database (named test_ followed by your database name).
  • Runs all discovered tests.
  • Reports results and destroys the test database when done.

Run your full suite with:

bash
python manage.py test

You can target a subset of tests to speed up your feedback loop during development:

bash
# Run all tests for one app
python manage.py test orders

# Run one test module
python manage.py test orders.tests.test_views

# Run one specific test class
python manage.py test orders.tests.test_views.CreateOrderViewTest

# Run one specific test method
python manage.py test orders.tests.test_views.CreateOrderViewTest.test_authenticated_user_can_create_order

✦ Tip

Keep the database between runs with --keepdb

By default Django destroys and recreates the test database on every run. For large schemas this adds seconds. The --keepdb flag reuses the existing test database and only applies migrations that changed. Use it when iterating quickly.

bash
python manage.py test --keepdb

coverage.py

coverage.py measures which lines of your application code were executed during the test run. It is the standard tool for tracking test coverage in Python projects.

Install it and run it alongside your test suite:

bash
pip install coverage

# Run tests and collect coverage data
coverage run manage.py test

# Print a summary report in the terminal
coverage report -m

# Generate a detailed HTML report you can open in a browser
coverage html
open htmlcov/index.html

The -m flag in coverage report -m shows the line numbers that were not executed, so you can see exactly which code paths have no test coverage yet.

⚠ Gotcha

100% coverage is not the goal

Coverage measures which lines ran, not whether they were tested correctly. A line is counted as covered if any test executed it, even if that test asserts nothing. You can have 100% coverage and still have major gaps in your test suite. Use coverage as a floor — if a critical path shows 0%, that is a problem — but do not treat high coverage as a proxy for good tests.

unittest vs pytest

Django's built-in test runner is based on Python's standard library unittest module. It is solid, well-documented, and requires nothing extra to install. For many projects it is exactly what you need.

pytest is a third-party testing framework that has become the de facto standard in the Python ecosystem. It offers several concrete improvements over unittest:

  • Plain assert statements instead of self.assertEqual, self.assertTrue, and so on. This means less to memorise and cleaner-reading tests.
  • Better failure output. When an assertion fails, pytest shows you the actual value and the expected value side by side in the terminal.
  • A more powerful fixture system that replaces setUp and tearDown with composable, reusable functions.
  • Markers that let you categorise tests and run subsets selectively.
  • A rich plugin ecosystem including pytest-django, which bridges pytest and Django so you get all of Django's testing infrastructure through pytest's runner.
The same test in both styles
# unittest style
from django.test import TestCase

class DiscountTest(TestCase):
    def test_loyalty_discount(self):
        self.assertEqual(calculate_discount(100, "loyalty"), 90)


# pytest style
def test_loyalty_discount():
    assert calculate_discount(100, "loyalty") == 90

The pytest version is shorter and reads like a natural English sentence. When it fails, pytest output looks like this:

bash
FAILED test_discount.py::test_loyalty_discount
AssertionError: assert 85 == 90
  where 85 = calculate_discount(100, 'loyalty')

Compare that to unittest's output for the same failure:

bash
FAIL: test_loyalty_discount (orders.tests.test_discount.DiscountTest)
AssertionError: 85 != 90

pytest tells you the expression that produced the wrong value. unittest just tells you the two values were not equal. At small scale the difference is minor. At large scale, with complex objects, the pytest output dramatically reduces the time from "test failed" to "I know what broke."

In this series we cover both. Article 2 uses unittest, the style you will encounter in most existing Django codebases. Article 3 migrates those same tests to pytest so you see exactly what changes and why.

What good tests look like

A test suite that nobody runs is useless. A test suite that takes 10 minutes to run will not be run locally. A test that passes when the code is broken gives you false confidence. Good tests share a set of properties that make them worth having.

Fast

Tests that run in seconds get run. Tests that run in minutes get skipped. Aim for a suite that completes in under 30 seconds locally. Anything making a real network call or hitting an external service in a unit test is a red flag. Mock external dependencies or use test doubles that return responses immediately.

Isolated

Each test must set up its own state and be unaffected by other tests. A test that passes when run alone but fails when run after another test is hiding a shared-state bug. These are among the hardest bugs to diagnose because they depend on execution order.

Django's TestCase handles database isolation automatically by wrapping each test in a transaction. Your job is to not share Python state between tests through module-level variables or global caches.

Deterministic

A test must produce the same result every time it runs, on any machine, in any order. Tests that depend on the current time, random data, network availability, or execution order are called flaky tests. They fail intermittently, erode trust in the suite, and eventually get ignored or deleted.

Strategies to keep tests deterministic:

  • Freeze time with freezegun when testing anything date or time dependent.
  • Mock external HTTP calls with responses or unittest.mock.
  • Control random data by using fixed seeds or hard-coded values in tests.
  • Never assert on the order of database results without an explicit order_by.

Readable

A test that fails should tell you exactly what broke and why, without you having to read the implementation. The structure that makes this easiest is Arrange, Act, Assert:

  • Arrange: Set up the state you need for the test.
  • Act: Perform the action under test.
  • Assert: Verify the outcome.

Keep each test focused on one behaviour. A test called test_order that tests five different things is hard to understand and hard to fix when it fails.

python
def test_expired_coupons_cannot_be_applied(self):
    # Arrange
    coupon = Coupon.objects.create(code="SAVE10", expires_at=yesterday())
    order = Order.objects.create(total=100)

    # Act
    result = apply_coupon(order, coupon)

    # Assert
    self.assertFalse(result.success)
    self.assertEqual(result.error, "coupon_expired")

Tests behaviour, not implementation

A test that breaks every time you rename a variable or extract a helper method is not testing your business logic. It is testing your implementation details. This creates a situation where refactoring the code safely, which is one of the main reasons to have tests, causes the tests to fail even though nothing broke for the user.

Good tests specify what the code should do (the contract) rather than how it does it. Test the public interface. Private methods are implementation details and can change freely as long as the observable behaviour is preserved.

✦ Tip

If renaming a private method breaks a test, that test is wrong

If you feel the need to test a private method directly, that is usually a signal that the method is doing too much work and should be extracted into its own class or module with a public interface.

Your first Django test

Enough theory. Here is the smallest useful test you can write in Django: a model method test.

Say you have an Order model with a total_with_tax method:

orders/models.py
from django.db import models
from decimal import Decimal


class Order(models.Model):
    subtotal = models.DecimalField(max_digits=10, decimal_places=2)
    tax_rate = models.DecimalField(max_digits=5, decimal_places=4, default=Decimal("0.16"))

    def total_with_tax(self):
        return self.subtotal * (1 + self.tax_rate)

Create a tests directory inside your app with the following structure:

bash
orders/
├── models.py
├── views.py
└── tests/
    ├── __init__.py
    └── test_models.py

Now write the tests. We will cover three scenarios: the normal case, a boundary case (zero subtotal), and the default argument case:

orders/tests/test_models.py
from django.test import TestCase
from decimal import Decimal
from orders.models import Order


class OrderTotalWithTaxTest(TestCase):
    def test_applies_tax_rate_to_subtotal(self):
        order = Order(subtotal=Decimal("100.00"), tax_rate=Decimal("0.16"))
        self.assertEqual(order.total_with_tax(), Decimal("116.00"))

    def test_zero_subtotal_produces_zero_total(self):
        order = Order(subtotal=Decimal("0.00"), tax_rate=Decimal("0.16"))
        self.assertEqual(order.total_with_tax(), Decimal("0.00"))

    def test_uses_default_tax_rate_when_not_specified(self):
        order = Order(subtotal=Decimal("100.00"))
        self.assertEqual(order.total_with_tax(), Decimal("116.00"))

Run the tests:

bash
python manage.py test orders.tests.test_models

Found 3 tests, starting the test runner...
...
----------------------------------------------------------------------
Ran 3 tests in 0.012s

OK

Three tests, 12 milliseconds.

Notice that we are not saving these orders to the database. Order() constructs an unsaved instance, which is all we need to test a pure calculation method. No database round-trip. No I/O. Just logic. This is precisely what makes it a unit test.

Now intentionally break the method and run the tests again to see what a test failure looks like:

python
def total_with_tax(self):
    return self.subtotal + self.tax_rate  # bug: should be multiplying, not adding
bash
FAIL: test_applies_tax_rate_to_subtotal (orders.tests.test_models.OrderTotalWithTaxTest)
----------------------------------------------------------------------
AssertionError: Decimal('100.16') != Decimal('116.00')

The test caught the bug immediately. It told you which test failed, what value was returned, and what was expected. That is the feedback loop you are building: write code, run tests, know instantly whether it is correct.

Once you are comfortable with that loop, writing tests stops feeling like extra work and starts feeling like the natural way to develop.

What's next

Here is where the series goes from here:

  • Article 2 — Unit Tests with unittest: Deep dive into Django's TestCase. Testing models, custom managers, utility functions, and validators. Fixtures, setUp, and setUpTestData.
  • Article 3 — Upgrading to pytest: We take the tests from Article 2 and migrate them to pytest. You see the difference side by side — better output, cleaner fixtures, less boilerplate.
  • Article 4 — Integration Tests: Testing views with TestClient and APIClient. Status codes, response bodies, URL routing, DRF serializers.
  • Article 5 — Auth and Permissions: Testing login flows, token authentication, permission classes, and role-based access scenarios.
  • Article 6 — TDD in Practice: The red/green/refactor cycle applied to a real Django feature, built test-first from scratch.
  • Article 7 — Test Data and Isolation: factory_boy, pytest fixture scoping, and strategies for keeping tests hermetically isolated as your suite grows.
  • Article 8 — Coverage, CI, and What's Next: Measuring and interpreting coverage, running tests in GitHub Actions, and making your suite fast enough that nobody avoids it.

Each article recaps what came before and adds one layer. By the end you will have a complete, professional testing approach that covers the whole stack.

Summary

  • Automated testing is the practice of verifying code behaviour programmatically so regressions are caught before they reach production.
  • The test pyramid describes three tiers: unit tests (many, fast, isolated), integration tests (some, moderate speed, test component wiring), and end-to-end tests (few, slow, test the full system).
  • Most application confidence comes from a strong unit and integration test base. Inverting the pyramid produces slow, flaky, expensive suites.
  • Django ships with django.test.TestCase, a test HTTP client, and a test runner. No extra dependencies needed to get started.
  • pytest is the ecosystem standard: more concise, better failure output, and a more powerful fixture system. This series covers both unittest and pytest.
  • Good tests are fast, isolated, deterministic, readable, and test behaviour rather than implementation details.
  • Structure every test as Arrange, Act, Assert. One test, one behaviour.
  • Coverage is a useful floor, not a ceiling. Lines executed is not the same as logic verified.