Skip to main content

πŸ› οΈ Django Testing Tools

Django ships with a full testing toolkit built on Python's unittest but tuned for web apps: test-case classes that manage a throwaway database, a Client that acts like a browser, and helpers for models, views, forms, URLs, and the admin. This lesson shows you how to test a Django project across every layer β€” and how to run it all under pytest.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Choose the right base class among SimpleTestCase, TestCase, and TransactionTestCase
  • Test models β€” CRUD, methods, validation, and relationships β€” against the test database
  • Use the test Client and reverse() to test views, status codes, templates, and context
  • Test forms and URLs, and verify authentication and permission behavior
  • Set up realistic data with factory_boy and run Django tests under pytest-django

Estimated Time: 50–60 minutes  β€’  Difficulty: Intermediate

Hands-on: Write a model-and-view test suite for a small blog application.

In This Lesson

Why Django Has Its Own Tools

A plain function test can't easily talk to a database, render a template, or simulate a logged-in user hitting a URL. Django's test framework wraps unittest and adds exactly those web-app conveniences: it creates a separate test database, wraps each test in a transaction that is rolled back afterward, and gives you a Client that issues fake HTTP requests through your real URL routing and views.

πŸ’‘ Analogy: Testing a Django app is like quality control in a car factory. You check individual parts (models β€” unit tests), how parts fit together (views hitting models β€” integration tests), and finally take the assembled car for a drive (a full request/response through the Client).
mindmap root((Django Testing)) What to test Models Views Forms URLs Admin Test classes SimpleTestCase TestCase TransactionTestCase Tools Client RequestFactory reverse Data setUp fixtures factory_boy

Setup & Running Tests

startapp creates a single tests.py, but for anything beyond a toy app, replace it with a tests/ package so each concern gets its own file:

my_project/
β”œβ”€β”€ manage.py
└── my_app/
    β”œβ”€β”€ models.py
    β”œβ”€β”€ views.py
    β”œβ”€β”€ forms.py
    └── tests/
        β”œβ”€β”€ __init__.py
        β”œβ”€β”€ test_models.py
        β”œβ”€β”€ test_views.py
        └── test_forms.py

Run tests with the test management command. Django discovers any test_* method on a TestCase subclass:

python manage.py test                 # everything
python manage.py test my_app          # one app
python manage.py test my_app.tests.test_models   # one module
python manage.py test --tag=fast      # tests tagged @tag("fast")
python manage.py test --parallel      # run across CPU cores

βœ… Django handles the test database for you

  • A dedicated test database is created before the run and destroyed after β€” your real data is never touched.
  • Each TestCase test runs in a transaction that is rolled back, so tests start from a clean slate and stay isolated.
  • Email is routed to an in-memory outbox, and a fast password hasher is used automatically.

You can speed things up further by using an in-memory database and a fast hasher in your settings while testing:

# settings.py (test-only tweaks)
import sys

if "test" in sys.argv:
    PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
    DATABASES["default"] = {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": ":memory:",
    }

The Test-Case Classes

Django layers several test-case classes on top of unittest.TestCase. Picking the right one keeps your suite fast:

flowchart TD A[unittest.TestCase] --> B[SimpleTestCase] B --> C[TransactionTestCase] C --> D[TestCase] D --> E[LiveServerTestCase]
ClassDatabase?Use when…
SimpleTestCaseNo (blocked)Testing pure logic, template rendering, or simple views with no DB
TestCaseYes, rolled back per testAlmost everything β€” the default choice
TransactionTestCaseYes, truncated per testTesting transaction.atomic(), savepoints, or select_for_update
LiveServerTestCaseYes, plus a live serverBrowser automation (Selenium/Playwright)

SimpleTestCase is fast because it refuses database access β€” reach for it when you don't need one:

from django.test import SimpleTestCase
from myapp.utils import normalize_email

class UtilityTests(SimpleTestCase):
    def test_normalize_email(self):
        self.assertEqual(normalize_email("User@EXAMPLE.com"), "user@example.com")

TestCase is what you'll use most. Its per-test transaction rollback is far faster than truncating tables, which is why it beats TransactionTestCase unless you specifically need to test transaction behavior:

from django.test import TestCase
from myapp.models import Product

class ProductModelTests(TestCase):
    def setUp(self):
        self.product = Product.objects.create(name="Test Product", price=19.99)

    def test_str(self):
        self.assertEqual(str(self.product), "Test Product")

Testing Models

Model tests focus on the behavior you added: custom methods, validation rules, relationships, and manager queries. Django's plumbing (saving a row, a plain field) rarely needs its own test β€” test the logic, not the framework.

from django.test import TestCase
from django.core.exceptions import ValidationError
from myapp.models import Product, Category

class ProductModelTests(TestCase):
    def setUp(self):
        self.category = Category.objects.create(name="Electronics")
        self.product = Product.objects.create(
            name="Smartphone", price=599.99, category=self.category, in_stock=True,
        )

    def test_created_and_counted(self):
        self.assertEqual(Product.objects.count(), 1)
        self.assertEqual(self.product.name, "Smartphone")

    def test_str_representation(self):
        self.assertEqual(str(self.product), "Smartphone")

    def test_relationships(self):
        self.assertEqual(self.product.category, self.category)
        # reverse relation via related_name="products"
        self.assertIn(self.product, self.category.products.all())

    def test_validation_rejects_negative_price(self):
        product = Product(name="Bad", price=-10, category=self.category)
        with self.assertRaises(ValidationError):
            product.full_clean()     # validators run on full_clean(), not save()

    def test_custom_manager_method(self):
        self.assertEqual(Product.objects.in_stock().count(), 1)
        self.product.in_stock = False
        self.product.save()
        self.assertEqual(Product.objects.in_stock().count(), 0)

⚠️ save() does not validate

Django does not run field validators on save(). To test validation you must call full_clean() explicitly (which is what ModelForm does under the hood). A test that expects save() to reject bad data will pass for the wrong reason.

Testing Views with the Client

The test Client simulates a browser: it sends a request through your URL config and views and hands back the response β€” without a running server. Always build URLs with reverse() so a change to a route doesn't silently break every test.

from django.test import TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model
from myapp.models import Product

User = get_user_model()

class ProductViewTests(TestCase):
    def setUp(self):
        self.user = User.objects.create_user("ray", "ray@example.com", "pw12345")
        self.product = Product.objects.create(name="Test Product", price=19.99)
        self.list_url = reverse("product-list")
        self.create_url = reverse("product-create")

    def test_list_view(self):
        response = self.client.get(self.list_url)
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, "myapp/product_list.html")
        self.assertContains(response, "Test Product")
        self.assertEqual(len(response.context["products"]), 1)

    def test_create_requires_login(self):
        # anonymous users are redirected to the login page
        response = self.client.get(self.create_url)
        self.assertEqual(response.status_code, 302)

    def test_create_when_logged_in(self):
        self.client.login(username="ray", password="pw12345")
        response = self.client.post(self.create_url, {
            "name": "New Product", "price": 29.99,
        })
        self.assertEqual(response.status_code, 302)          # redirect on success
        self.assertTrue(Product.objects.filter(name="New Product").exists())

πŸ“– Handy Client assertions

assertContains(response, text) β€” text appears in the rendered body (and status is 200).

assertTemplateUsed(response, name) β€” the response rendered that template.

assertRedirects(response, url) β€” the response redirected to url.

response.context["key"] β€” inspect the data the view passed to the template.

For a JSON API built with Django REST Framework, use its APITestCase and DRF status constants:

from rest_framework import status
from rest_framework.test import APITestCase
from django.urls import reverse
from myapp.models import Product

class ProductAPITests(APITestCase):
    def setUp(self):
        self.product = Product.objects.create(name="Test Product", price=19.99)

    def test_list(self):
        response = self.client.get(reverse("api:product-list"))
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(len(response.data), 1)

    def test_create(self):
        response = self.client.post(reverse("api:product-list"),
                                    {"name": "New", "price": 39.99})
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(Product.objects.count(), 2)

Forms, URLs & Auth

Forms

Form tests check validation and cleaning by instantiating the form with a data dict and asserting on is_valid() and errors:

from django.test import TestCase
from myapp.forms import ProductForm
from myapp.models import Category

class ProductFormTests(TestCase):
    def setUp(self):
        self.category = Category.objects.create(name="Electronics")
        self.valid_data = {"name": "Widget", "price": 29.99, "category": self.category.id}

    def test_valid(self):
        self.assertTrue(ProductForm(data=self.valid_data).is_valid())

    def test_missing_required_fields(self):
        form = ProductForm(data={})
        self.assertFalse(form.is_valid())
        self.assertIn("name", form.errors)
        self.assertIn("price", form.errors)

    def test_negative_price_rejected(self):
        form = ProductForm(data={**self.valid_data, "price": -5})
        self.assertFalse(form.is_valid())
        self.assertIn("price", form.errors)

URLs

reverse() turns a URL name into a path; resolve() turns a path back into the view. Testing both directions guards against accidental route changes:

from django.test import SimpleTestCase
from django.urls import reverse, resolve
from myapp.views import ProductListView

class UrlTests(SimpleTestCase):
    def test_list_url_resolves(self):
        url = reverse("product-list")
        self.assertEqual(url, "/products/")
        self.assertEqual(resolve(url).func.__name__, ProductListView.as_view().__name__)

Authentication & permissions

Log a user in with self.client.login(...) and assert that protected views redirect anonymous users and admin-only pages block regular ones:

from django.test import TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model

User = get_user_model()

class AuthTests(TestCase):
    def setUp(self):
        self.user = User.objects.create_user("ray", password="pw12345")
        self.protected_url = reverse("profile")

    def test_redirects_anonymous(self):
        response = self.client.get(self.protected_url)
        self.assertEqual(response.status_code, 302)

    def test_allows_logged_in(self):
        self.client.login(username="ray", password="pw12345")
        response = self.client.get(self.protected_url)
        self.assertEqual(response.status_code, 200)

βœ… Security is testable too

Django auto-escapes template output, so a value like <script> renders as harmless &lt;script&gt;. You can assert that the raw tag is not present in a response to lock in XSS protection, and enable enforce_csrf_checks=True on the Client to verify CSRF handling.

Test Data with factory_boy

setUp works, but hand-writing every object gets tedious and couples tests to unrelated field details. factory_boy defines a factory once and generates fresh, valid objects on demand β€” filling in sensible defaults so each test only states what it actually cares about.

# factories.py
import factory
from django.contrib.auth import get_user_model
from myapp.models import Category, Product

class UserFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = get_user_model()

    username = factory.Sequence(lambda n: f"user{n}")
    email = factory.LazyAttribute(lambda o: f"{o.username}@example.com")

class CategoryFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Category
    name = factory.Sequence(lambda n: f"Category {n}")

class ProductFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Product
    name = factory.Sequence(lambda n: f"Product {n}")
    price = factory.Faker("pydecimal", left_digits=3, right_digits=2, positive=True)
    category = factory.SubFactory(CategoryFactory)   # builds a Category automatically
    in_stock = True
# in a test
from myapp.factories import ProductFactory

class ProductListTests(TestCase):
    def test_lists_all_products(self):
        ProductFactory.create_batch(10)              # ten valid products, one line
        response = self.client.get(reverse("product-list"))
        self.assertEqual(len(response.context["products"]), 10)

Sequence guarantees unique values, SubFactory builds related objects for you, and Faker produces realistic fake data β€” together they keep test setup short and intention-revealing.

Running Under pytest-django

Many teams keep Django's test-case classes but run the whole suite with pytest to get its fixtures, parametrization, and cleaner output. The pytest-django plugin bridges the two:

pip install pytest-django
# pyproject.toml
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "myproject.settings"
python_files = ["test_*.py", "*_test.py"]

Now you can write plain-function tests. The plugin provides a client fixture (a Django test Client) and an admin_client (already logged in as a superuser); mark any test that touches the database with @pytest.mark.django_db:

# conftest.py
import pytest
from myapp.models import Category, Product

@pytest.fixture
def category(db):
    return Category.objects.create(name="Electronics")

@pytest.fixture
def product(category):
    return Product.objects.create(name="Test Product", price=19.99, category=category)

# test_views.py
import pytest
from django.urls import reverse

@pytest.mark.django_db
def test_product_detail(client, product):
    response = client.get(reverse("product-detail", args=[product.id]))
    assert response.status_code == 200
    assert "Test Product" in response.content.decode()

@pytest.mark.django_db
def test_edit_requires_login(client, admin_client, product):
    url = reverse("product-edit", args=[product.id])
    assert client.get(url).status_code == 302        # anonymous -> redirect
    assert admin_client.get(url).status_code == 200  # superuser -> allowed

πŸ’‘ Best of both worlds

You don't have to rewrite existing TestCase classes β€” pytest runs them as-is. Adopt pytest-django gradually and write new tests as plain functions where fixtures and parametrize make them clearer.

Hands-on Exercise

πŸ‹οΈ Test a blog application

Objective: Write model and view tests for a small blog. Here is the code under test:

# blog/models.py
from django.db import models
from django.conf import settings
from django.urls import reverse

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

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse("post-detail", args=[self.id])

# blog/views.py β€” PostListView hides unpublished posts from non-staff
class PostListView(ListView):
    model = Post
    context_object_name = "posts"

    def get_queryset(self):
        qs = super().get_queryset()
        return qs if self.request.user.is_staff else qs.filter(published=True)

Your tasks

  1. Write a TestCase that creates a user and a Post, and assert str(post) and get_absolute_url().
  2. Create one published and one unpublished post; assert an anonymous visit to the list shows only the published one.
  3. Assert that a staff user sees both posts.
πŸ’‘ Hint

Create the staff user with User.objects.create_user("boss", password="pw", is_staff=True). Use reverse("post-list") for the URL and check response.context["posts"] counts, or use assertContains / assertNotContains on the titles.

βœ… Sample solution
# blog/tests/test_blog.py
from django.test import TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model
from blog.models import Post

User = get_user_model()

class PostModelTests(TestCase):
    def setUp(self):
        self.author = User.objects.create_user("ray", password="pw12345")
        self.post = Post.objects.create(
            title="Hello", content="Body", author=self.author, published=True,
        )

    def test_str(self):
        self.assertEqual(str(self.post), "Hello")

    def test_absolute_url(self):
        self.assertEqual(self.post.get_absolute_url(), f"/posts/{self.post.id}/")


class PostListViewTests(TestCase):
    def setUp(self):
        self.author = User.objects.create_user("ray", password="pw12345")
        self.boss = User.objects.create_user("boss", password="pw12345", is_staff=True)
        Post.objects.create(title="Live", content="x", author=self.author, published=True)
        Post.objects.create(title="Draft", content="x", author=self.author, published=False)
        self.url = reverse("post-list")

    def test_anonymous_sees_only_published(self):
        response = self.client.get(self.url)
        self.assertEqual(len(response.context["posts"]), 1)
        self.assertContains(response, "Live")
        self.assertNotContains(response, "Draft")

    def test_staff_sees_all(self):
        self.client.login(username="boss", password="pw12345")
        response = self.client.get(self.url)
        self.assertEqual(len(response.context["posts"]), 2)

Best Practices

βœ… Do🚫 Avoid
Use TestCase by default; drop to SimpleTestCase when no DB is neededReaching for TransactionTestCase everywhere (it's much slower)
Build URLs with reverse()Hard-coding paths like "/products/1/" in tests
Test your own methods, validation, and permissionsTesting that Django itself saves a row
Use factories for concise, valid dataCopying huge create() blocks into every setUp
Call full_clean() to test validationExpecting save() to reject invalid data
πŸ’‘ Watch your query counts. Wrap a view call in self.assertNumQueries(n) to catch N+1 query regressions early β€” a passing feature test can still hide a page that fires hundreds of queries.

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Django creates and tears down a separate test database, and TestCase rolls back each test for isolation.
  • Pick SimpleTestCase (no DB), TestCase (default), or TransactionTestCase (transaction behavior) deliberately.
  • The test Client plus reverse() lets you assert status codes, templates, and context; use full_clean() to test model validation.
  • factory_boy generates concise, valid test data with sequences, sub-factories, and Faker.
  • pytest-django runs your existing tests and adds fixtures, parametrization, and cleaner output.

🎯 Quick Quiz

Question 1: Which base class should you use for a test that renders a template but needs no database?

Question 2: Why build URLs with reverse("product-list") instead of writing "/products/"?

Question 3: You want to assert a model rejects a negative price. Which call actually runs the validators?

πŸ“š Further Reading

πŸš€ What's Next?

Several tests above quietly mocked external services (payments, third-party APIs). Next we'll go deep on that skill β€” creating mocks, patching in the right place, and verifying calls β€” in Mocking and Patching in Python Tests.

πŸŽ‰ Nice work!

You can now test a Django app from its models all the way up to a full request/response.