Recap
In the previous article we mapped out the testing landscape. We covered the test pyramid, the difference between unit tests, integration tests, and end-to-end tests, and the properties that make tests worth having.
The key takeaway was this: unit tests sit at the base of the pyramid. They are fast, isolated, and precise. They verify one function or method at a time, without touching the database or the network, and when they fail they point directly at the broken logic.
In this article we get hands-on. We will build a small Django application and write a full suite of unit tests using Django's built-in TestCase. We will cover model methods, model properties, custom validators, custom managers, utility functions, and service layer logic. By the end you will have a repeatable pattern you can apply to any Django codebase.
Project setup
We will use a small e-commerce domain as our working example throughout this series. It is simple enough to understand quickly but rich enough to produce realistic test scenarios.
The application has a single app called orders with these models:
from django.db import models
from django.core.exceptions import ValidationError
from django.utils import timezone
from decimal import Decimal
class Customer(models.Model):
name = models.CharField(max_length=255)
email = models.EmailField(unique=True)
is_loyalty_member = models.BooleanField(default=False)
def __str__(self):
return self.name
class Product(models.Model):
name = models.CharField(max_length=255)
price = models.DecimalField(max_digits=10, decimal_places=2)
stock = models.PositiveIntegerField(default=0)
def is_in_stock(self):
return self.stock > 0
def __str__(self):
return self.name
class Coupon(models.Model):
code = models.CharField(max_length=50, unique=True)
discount_percent = models.PositiveIntegerField()
expires_at = models.DateTimeField()
def is_valid(self):
return timezone.now() < self.expires_at
def clean(self):
if self.discount_percent > 100:
raise ValidationError("Discount cannot exceed 100 percent.")
def __str__(self):
return self.code
class OrderManager(models.Manager):
def pending(self):
return self.filter(status="pending")
def for_customer(self, customer):
return self.filter(customer=customer)
class Order(models.Model):
STATUS_CHOICES = [
("pending", "Pending"),
("confirmed", "Confirmed"),
("shipped", "Shipped"),
("cancelled", "Cancelled"),
]
customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name="orders")
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending")
subtotal = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal("0.00"))
tax_rate = models.DecimalField(max_digits=5, decimal_places=4, default=Decimal("0.16"))
created_at = models.DateTimeField(auto_now_add=True)
objects = OrderManager()
@property
def total(self):
return self.subtotal * (1 + self.tax_rate)
@property
def is_cancellable(self):
return self.status in ("pending", "confirmed")
def apply_coupon(self, coupon):
if not coupon.is_valid():
raise ValueError("Coupon has expired.")
discount = self.subtotal * (Decimal(coupon.discount_percent) / 100)
self.subtotal = self.subtotal - discount
def __str__(self):
return f"Order #{self.pk}"And a utility module with some standalone business logic:
from decimal import Decimal
LOYALTY_DISCOUNT_RATE = Decimal("0.10")
def calculate_loyalty_discount(subtotal: Decimal, is_loyalty_member: bool) -> Decimal:
if not is_loyalty_member:
return subtotal
return subtotal * (1 - LOYALTY_DISCOUNT_RATE)
def format_currency(amount: Decimal, currency: str = "KES") -> str:
return f"{currency} {amount:,.2f}"
def is_bulk_order(quantity: int, threshold: int = 10) -> bool:
return quantity >= thresholdAnd a service module that coordinates multiple models:
from django.core.exceptions import ValidationError
from .models import Order, Product
def add_item_to_order(order: Order, product: Product, quantity: int) -> None:
if quantity <= 0:
raise ValueError("Quantity must be greater than zero.")
if product.stock < quantity:
raise ValidationError(
f"Insufficient stock. Requested {quantity}, available {product.stock}."
)
order.subtotal += product.price * quantity
product.stock -= quantity
product.save()
order.save()
def cancel_order(order: Order) -> None:
if not order.is_cancellable:
raise ValueError(f"Orders with status '{order.status}' cannot be cancelled.")
order.status = "cancelled"
order.save()Set up the test directory structure:
orders/
├── models.py
├── utils.py
├── services.py
└── tests/
├── __init__.py
├── test_models.py
├── test_utils.py
└── test_services.pyAnatomy of a test
Before writing anything, it helps to understand the structure of a Django unit test at a mechanical level.
Every test file imports TestCase from django.test. Tests are grouped into classes that extend TestCase. Each method whose name starts with test_ is a test. The Django test runner discovers and runs them automatically.
from django.test import TestCase
class SomeModelTest(TestCase):
def test_something_specific(self):
# Arrange: set up the objects and state the test needs
# Act: call the method or function under test
# Assert: verify the result is what you expected
passThe Arrange, Act, Assert structure is not a rule enforced by any framework. It is a discipline that keeps tests readable. When a test fails, a reader who has never seen it before should be able to understand what was being tested, what state was set up, and what went wrong, in under 30 seconds.
Name your test methods descriptively. test_expired_coupon_raises_value_error is useful. test_coupon is not. The test method name is the first thing you see when a test fails.
Testing model methods
Model methods are often where business logic lives in Django applications. They are also the easiest things to unit test because they are typically pure functions: given some state on the model, they compute and return a result.
Let us start with Product.is_in_stock() and Coupon.is_valid():
from django.test import TestCase
from django.utils import timezone
from datetime import timedelta
from decimal import Decimal
from orders.models import Customer, Product, Coupon, Order
class ProductIsInStockTest(TestCase):
def test_returns_true_when_stock_is_positive(self):
product = Product(name="Widget", price=Decimal("10.00"), stock=5)
self.assertTrue(product.is_in_stock())
def test_returns_false_when_stock_is_zero(self):
product = Product(name="Widget", price=Decimal("10.00"), stock=0)
self.assertFalse(product.is_in_stock())
def test_returns_false_when_stock_is_not_set(self):
product = Product(name="Widget", price=Decimal("10.00"))
self.assertFalse(product.is_in_stock())Notice that we are using Product(...) rather than Product.objects.create(...). Constructing an unsaved instance is faster and is all we need here. The method reads from self.stock and returns a boolean. No database required.
Use Product.objects.create() only when you need the object to actually be saved, for example when testing a query or a foreign key relationship.
Now test Coupon.is_valid(), which depends on the current time:
class CouponIsValidTest(TestCase):
def test_returns_true_before_expiry(self):
coupon = Coupon(
code="SAVE10",
discount_percent=10,
expires_at=timezone.now() + timedelta(days=7),
)
self.assertTrue(coupon.is_valid())
def test_returns_false_after_expiry(self):
coupon = Coupon(
code="SAVE10",
discount_percent=10,
expires_at=timezone.now() - timedelta(seconds=1),
)
self.assertFalse(coupon.is_valid())⚠ Gotcha
Tests that depend on the current time can become flaky
The tests above use timezone.now() with small offsets. In almost all cases this is fine. If your logic involves time windows of minutes rather than days, freeze time with the freezegun library to make the tests deterministic regardless of when they run.
pip install freezegunfrom freezegun import freeze_time
from django.utils import timezone
from datetime import timedelta
class CouponIsValidFrozenTest(TestCase):
@freeze_time("2026-06-01 12:00:00")
def test_returns_true_before_expiry(self):
coupon = Coupon(
code="SAVE10",
discount_percent=10,
expires_at=timezone.now() + timedelta(hours=1),
)
self.assertTrue(coupon.is_valid())
@freeze_time("2026-06-01 12:00:00")
def test_returns_false_after_expiry(self):
coupon = Coupon(
code="SAVE10",
discount_percent=10,
expires_at=timezone.now() - timedelta(hours=1),
)
self.assertFalse(coupon.is_valid())With @freeze_time, timezone.now() always returns 2026-06-01 12:00:00 inside that test, no matter when the test runs. The test is now fully deterministic.
Testing properties
Python properties look like attribute access but run code. They are model methods with a different calling convention and they deserve the same testing attention.
Let us test Order.total and Order.is_cancellable:
class OrderTotalTest(TestCase):
def test_calculates_total_from_subtotal_and_tax_rate(self):
order = Order(subtotal=Decimal("100.00"), tax_rate=Decimal("0.16"))
self.assertEqual(order.total, Decimal("116.00"))
def test_total_is_zero_when_subtotal_is_zero(self):
order = Order(subtotal=Decimal("0.00"), tax_rate=Decimal("0.16"))
self.assertEqual(order.total, Decimal("0.00"))
def test_total_uses_actual_tax_rate_not_default(self):
order = Order(subtotal=Decimal("100.00"), tax_rate=Decimal("0.20"))
self.assertEqual(order.total, Decimal("120.00"))
class OrderIsCancellableTest(TestCase):
def test_pending_order_is_cancellable(self):
order = Order(status="pending")
self.assertTrue(order.is_cancellable)
def test_confirmed_order_is_cancellable(self):
order = Order(status="confirmed")
self.assertTrue(order.is_cancellable)
def test_shipped_order_is_not_cancellable(self):
order = Order(status="shipped")
self.assertFalse(order.is_cancellable)
def test_cancelled_order_is_not_cancellable(self):
order = Order(status="cancelled")
self.assertFalse(order.is_cancellable)The is_cancellable tests are a good example of covering all branches. The property returns True for two statuses and False for two others. One test per case means that when the logic changes, you know exactly which scenario broke.
Testing clean() and validators
Django's clean() method is where model-level validation lives. It is called by forms and serializers before saving, and it is supposed to raise ValidationError when the data violates a business rule.
Testing it requires calling clean() directly and asserting that it raises or does not raise an error. Use assertRaises as a context manager:
from django.core.exceptions import ValidationError
class CouponCleanTest(TestCase):
def test_discount_above_100_raises_validation_error(self):
coupon = Coupon(
code="INVALID",
discount_percent=150,
expires_at=timezone.now() + timedelta(days=7),
)
with self.assertRaises(ValidationError):
coupon.clean()
def test_discount_of_100_is_valid(self):
coupon = Coupon(
code="ALLFREE",
discount_percent=100,
expires_at=timezone.now() + timedelta(days=7),
)
coupon.clean() # should not raise
def test_discount_of_zero_is_valid(self):
coupon = Coupon(
code="NODISCOUNT",
discount_percent=0,
expires_at=timezone.now() + timedelta(days=7),
)
coupon.clean() # should not raiseThe tests for the valid cases are just as important as the test for the invalid case. Asserting that something does not raise is documentation that those inputs are intentionally accepted. Without it, you do not know whether discount_percent=0 was a considered decision or an oversight.
If you want to assert on the specific error message, use assertRaisesMessage:
def test_error_message_mentions_100_percent(self):
coupon = Coupon(
code="INVALID",
discount_percent=150,
expires_at=timezone.now() + timedelta(days=7),
)
with self.assertRaisesMessage(ValidationError, "100 percent"):
coupon.clean()Testing custom managers
Custom managers encapsulate query logic that would otherwise be scattered across views and services. Testing them requires saving objects to the database so the queries have data to work with. This is one of the few cases where unit tests need objects.create().
Let us test OrderManager.pending() and OrderManager.for_customer():
class OrderManagerTest(TestCase):
def setUp(self):
self.customer_a = Customer.objects.create(
name="Alice", email="alice@example.com"
)
self.customer_b = Customer.objects.create(
name="Bob", email="bob@example.com"
)
def test_pending_returns_only_pending_orders(self):
Order.objects.create(customer=self.customer_a, status="pending")
Order.objects.create(customer=self.customer_a, status="confirmed")
Order.objects.create(customer=self.customer_a, status="shipped")
pending = Order.objects.pending()
self.assertEqual(pending.count(), 1)
self.assertEqual(pending.first().status, "pending")
def test_pending_returns_empty_when_no_pending_orders(self):
Order.objects.create(customer=self.customer_a, status="confirmed")
self.assertEqual(Order.objects.pending().count(), 0)
def test_for_customer_returns_only_that_customers_orders(self):
Order.objects.create(customer=self.customer_a, status="pending")
Order.objects.create(customer=self.customer_a, status="confirmed")
Order.objects.create(customer=self.customer_b, status="pending")
orders = Order.objects.for_customer(self.customer_a)
self.assertEqual(orders.count(), 2)
for order in orders:
self.assertEqual(order.customer, self.customer_a)
def test_for_customer_returns_empty_when_customer_has_no_orders(self):
self.assertEqual(
Order.objects.for_customer(self.customer_a).count(), 0
)A few things worth noting here.
We use setUp to create the customers once per test method. Every test in this class gets fresh customer objects with a clean database state because TestCase wraps each method in a transaction that is rolled back after the method completes.
We test the empty case explicitly. An empty queryset is a valid return value and an easy thing to accidentally break. Asserting on it costs one line and saves a future debugging session.
We assert on both the count and the contents of the queryset. Count alone would not catch a query that returned the right number of wrong records.
Testing utility functions
Utility functions are the purest things to test. They take arguments and return values with no side effects and no database access. Writing tests for them is the fastest, cheapest way to build coverage.
from django.test import TestCase
from decimal import Decimal
from orders.utils import calculate_loyalty_discount, format_currency, is_bulk_order
class CalculateLoyaltyDiscountTest(TestCase):
def test_loyalty_member_gets_ten_percent_off(self):
result = calculate_loyalty_discount(Decimal("100.00"), is_loyalty_member=True)
self.assertEqual(result, Decimal("90.00"))
def test_non_member_gets_no_discount(self):
result = calculate_loyalty_discount(Decimal("100.00"), is_loyalty_member=False)
self.assertEqual(result, Decimal("100.00"))
def test_discount_applies_to_any_amount(self):
result = calculate_loyalty_discount(Decimal("250.00"), is_loyalty_member=True)
self.assertEqual(result, Decimal("225.00"))
def test_zero_subtotal_stays_zero(self):
result = calculate_loyalty_discount(Decimal("0.00"), is_loyalty_member=True)
self.assertEqual(result, Decimal("0.00"))
class FormatCurrencyTest(TestCase):
def test_formats_with_default_kes_currency(self):
result = format_currency(Decimal("1234.50"))
self.assertEqual(result, "KES 1,234.50")
def test_formats_with_custom_currency(self):
result = format_currency(Decimal("1234.50"), currency="USD")
self.assertEqual(result, "USD 1,234.50")
def test_formats_zero(self):
result = format_currency(Decimal("0.00"))
self.assertEqual(result, "KES 0.00")
class IsBulkOrderTest(TestCase):
def test_returns_true_at_default_threshold(self):
self.assertTrue(is_bulk_order(10))
def test_returns_true_above_threshold(self):
self.assertTrue(is_bulk_order(50))
def test_returns_false_below_threshold(self):
self.assertFalse(is_bulk_order(9))
def test_returns_false_at_zero(self):
self.assertFalse(is_bulk_order(0))
def test_custom_threshold_is_respected(self):
self.assertTrue(is_bulk_order(5, threshold=5))
self.assertFalse(is_bulk_order(4, threshold=5))These tests run without touching the database at all. Django spins up its test infrastructure but none of these tests use it. If you wanted to make them even faster you could use plain unittest.TestCase from the standard library instead of django.test.TestCase. The trade-off is that standard library TestCase does not have access to Django's assertion helpers or the test client, so reserve it only for pure logic that has absolutely no Django dependency.
Testing service layer logic
Service functions coordinate multiple models and enforce business rules. They often have side effects: they save things to the database, raise exceptions on invalid input, and change the state of related objects. Testing them requires the database and careful attention to what state is being asserted.
from django.test import TestCase
from django.core.exceptions import ValidationError
from decimal import Decimal
from orders.models import Customer, Product, Order
from orders.services import add_item_to_order, cancel_order
class AddItemToOrderTest(TestCase):
def setUp(self):
self.customer = Customer.objects.create(
name="Alice", email="alice@example.com"
)
self.product = Product.objects.create(
name="Widget", price=Decimal("25.00"), stock=10
)
self.order = Order.objects.create(customer=self.customer)
def test_adds_item_cost_to_order_subtotal(self):
add_item_to_order(self.order, self.product, quantity=2)
self.order.refresh_from_db()
self.assertEqual(self.order.subtotal, Decimal("50.00"))
def test_reduces_product_stock_by_requested_quantity(self):
add_item_to_order(self.order, self.product, quantity=3)
self.product.refresh_from_db()
self.assertEqual(self.product.stock, 7)
def test_raises_when_quantity_is_zero(self):
with self.assertRaises(ValueError):
add_item_to_order(self.order, self.product, quantity=0)
def test_raises_when_quantity_is_negative(self):
with self.assertRaises(ValueError):
add_item_to_order(self.order, self.product, quantity=-1)
def test_raises_when_requested_quantity_exceeds_stock(self):
with self.assertRaises(ValidationError):
add_item_to_order(self.order, self.product, quantity=11)
def test_stock_is_not_changed_when_request_exceeds_stock(self):
try:
add_item_to_order(self.order, self.product, quantity=11)
except ValidationError:
pass
self.product.refresh_from_db()
self.assertEqual(self.product.stock, 10)✦ Tip
Always call refresh_from_db() before asserting on database state
Service functions save objects to the database. The in-memory object you hold a reference to may be stale after a save. Call refresh_from_db() on any object before asserting on its database-persisted fields to ensure you are reading the actual saved state.
class CancelOrderTest(TestCase):
def setUp(self):
self.customer = Customer.objects.create(
name="Alice", email="alice@example.com"
)
def test_sets_status_to_cancelled_for_pending_order(self):
order = Order.objects.create(customer=self.customer, status="pending")
cancel_order(order)
order.refresh_from_db()
self.assertEqual(order.status, "cancelled")
def test_sets_status_to_cancelled_for_confirmed_order(self):
order = Order.objects.create(customer=self.customer, status="confirmed")
cancel_order(order)
order.refresh_from_db()
self.assertEqual(order.status, "cancelled")
def test_raises_for_shipped_order(self):
order = Order.objects.create(customer=self.customer, status="shipped")
with self.assertRaises(ValueError):
cancel_order(order)
def test_shipped_order_status_unchanged_after_failed_cancel(self):
order = Order.objects.create(customer=self.customer, status="shipped")
try:
cancel_order(order)
except ValueError:
pass
order.refresh_from_db()
self.assertEqual(order.status, "shipped")The last test in each group, the one that verifies state is unchanged after a failed operation, is called a negative side-effect test. It confirms that an exception not only propagates correctly but also does not leave the system in a partially modified state. This class of bug is common in service functions and easy to miss without explicit assertions.
setUp and tearDown
setUp runs before every test method in the class. Use it to create objects and state that every test in the class needs. Anything created in setUp is rolled back by TestCase's transaction wrapping after each test, so each test starts fresh.
tearDown runs after every test method. Django's TestCase handles database cleanup automatically, so you rarely need tearDown. The cases where you do need it are when your test creates resources outside the database: temporary files, patched imports, or external connections that must be explicitly closed.
from unittest.mock import patch
from django.test import TestCase
class ExternalServiceTest(TestCase):
def setUp(self):
self.patcher = patch("orders.services.payment_gateway.charge")
self.mock_charge = self.patcher.start()
self.mock_charge.return_value = {"status": "success", "charge_id": "ch_123"}
def tearDown(self):
self.patcher.stop()
def test_charge_is_called_with_correct_amount(self):
# self.mock_charge is available and pre-configured
from orders.services import charge_customer
charge_customer(order_id=1, amount="100.00")
self.mock_charge.assert_called_once_with("100.00")✦ Tip
Prefer patch as a decorator over manual patcher.start() and stop()
The patcher.start() and patcher.stop() pattern requires a tearDown to avoid leaking the mock into other tests. Using @patch as a decorator handles cleanup automatically and keeps the setup closer to the test that needs it.
from unittest.mock import patch
class ExternalServiceTest(TestCase):
@patch("orders.services.payment_gateway.charge")
def test_charge_is_called_with_correct_amount(self, mock_charge):
mock_charge.return_value = {"status": "success", "charge_id": "ch_123"}
from orders.services import charge_customer
charge_customer(order_id=1, amount="100.00")
mock_charge.assert_called_once_with("100.00")setUpTestData
setUp runs before every test method, which means if your class has 20 tests and each one needs a customer, a product, and an order, you create those three objects 20 times. For most test suites this is fast enough. For test classes with expensive setup (large fixture graphs, many related objects), it adds up.
setUpTestData is a class method that runs once for the entire test class. Django wraps the entire thing in a transaction and rolls it back only when all tests in the class have finished. Each test gets a copy of the class-level objects, not the originals, so mutations in one test do not affect another.
from django.test import TestCase
from decimal import Decimal
from orders.models import Customer, Product, Order
class OrderQueryTest(TestCase):
@classmethod
def setUpTestData(cls):
cls.customer = Customer.objects.create(
name="Alice", email="alice@example.com"
)
cls.product = Product.objects.create(
name="Widget", price=Decimal("10.00"), stock=100
)
# These are created once and shared across all 4 tests below
cls.pending = Order.objects.create(customer=cls.customer, status="pending")
cls.confirmed = Order.objects.create(customer=cls.customer, status="confirmed")
cls.shipped = Order.objects.create(customer=cls.customer, status="shipped")
def test_all_orders_exist(self):
self.assertEqual(Order.objects.count(), 3)
def test_pending_filter_returns_one(self):
self.assertEqual(Order.objects.pending().count(), 1)
def test_for_customer_returns_all_three(self):
self.assertEqual(Order.objects.for_customer(self.customer).count(), 3)
def test_shipped_order_is_not_cancellable(self):
self.assertFalse(self.shipped.is_cancellable)⚠ Gotcha
Do not mutate setUpTestData objects in your tests
Django gives each test a copy of setUpTestData objects, but that copy is shallow. If you modify a field and save, the in-memory state leaks into the next test. For tests that need to modify an object, fetch a fresh copy from the database with Order.objects.get(pk=self.order.pk) instead of using self.order directly.
Useful assertions
Django's TestCase inherits all of Python's standard unittest assertions and adds several Django-specific ones. Here are the ones you will use most often in unit tests:
# Equality
self.assertEqual(a, b)
self.assertNotEqual(a, b)
# Boolean
self.assertTrue(expr)
self.assertFalse(expr)
# None
self.assertIsNone(value)
self.assertIsNotNone(value)
# Membership
self.assertIn(item, container)
self.assertNotIn(item, container)
# Numeric comparisons
self.assertGreater(a, b)
self.assertGreaterEqual(a, b)
self.assertLess(a, b)
self.assertLessEqual(a, b)
# Exceptions
with self.assertRaises(ValueError):
some_function()
with self.assertRaisesMessage(ValidationError, "expected message"):
some_function()
# Approximate equality (useful for floats)
self.assertAlmostEqual(0.1 + 0.2, 0.3, places=10)A few that are easy to overlook:
# Assert a queryset contains exactly these objects, in any order
self.assertQuerySetEqual(
Order.objects.pending(),
[order_a, order_b],
ordered=False,
)
# Assert that a function was called (requires mock)
mock_send_email.assert_called_once()
mock_send_email.assert_called_once_with(recipient="alice@example.com")
mock_send_email.assert_not_called()What not to test
Knowing what not to test is just as valuable as knowing what to test. Writing tests for things that cannot break wastes time and creates maintenance overhead without adding confidence.
Do not test Django itself
Django is already tested by the Django project. You do not need to test that CharField stores strings, that ForeignKey enforces referential integrity, or that auto_now_add=True sets the timestamp on creation. Test your code, not the framework's code.
Do not test trivial getters
A method that returns self.name does not need a test. If it breaks, the cause is a typo that would be obvious immediately. Save your tests for logic that can fail silently.
# Not worth testing
def get_name(self):
return self.name
# Worth testing — there is logic that can be wrong
def display_name(self):
if self.is_loyalty_member:
return f"{self.name} (Member)"
return self.nameDo not test third-party libraries
If you use Pillow to resize images, you do not need to test that Pillow resizes images correctly. Test that your code calls Pillow with the right arguments and handles the return value correctly.
Do not duplicate integration test coverage at the unit level
If a view calls calculate_loyalty_discount and you have a unit test for calculate_loyalty_discount, you do not also need a unit test that verifies the view calls it. That is wiring, and wiring is what integration tests cover.
Summary
- Unit tests verify one function or method at a time, in isolation, without touching the database or the network unless the code under test specifically requires it.
- Use
Model()for unsaved instances when testing methods that do not require a database row. UseModel.objects.create()only when the test genuinely needs a persisted record. - Test every branch. If a method has two code paths, write a test for each. Test the happy path and the error path.
- Call
refresh_from_db()before asserting on database state after a service function has saved something. - Use
setUpfor per-test object creation. UsesetUpTestDatawhen that creation is expensive and the objects are only read (not mutated) by the tests. - Freeze time with
freezegunwhenever a method's behaviour depends on the current time. - Use
@patchas a decorator rather than manually starting and stopping patchers. - Do not test Django itself, trivial getters, or third-party libraries. Test your logic.
In the next article we take every test written here and migrate them to pytest. You will see the same coverage with less boilerplate, better output, and a fixture system that composes more naturally as the suite grows.