Testing models and business logic
Models and the logic around them — custom methods, managers, validation, constraints — are the highest-value things to test, because they are your own code and everything else depends on them. They are also the easiest to test: no HTTP, no templates, just Python objects and the database. This lesson writes real model tests for Nidaan and shows the patterns you will reuse constantly.
The anatomy of a model test
A model test subclasses TestCase, creates some objects, and asserts on their behaviour:
from django.test import TestCase
from .models import Patient
class PatientModelTests(TestCase):
def test_str_is_name(self):
patient = Patient.objects.create(name="Asha", phone="09812345678", city="Pune")
self.assertEqual(str(patient), "Asha")
Verified: this passes — str(patient) returns the name, because the model defines __str__. Every test
method starts with test_, and the runner finds them automatically. Each runs inside a transaction that is
rolled back afterwards, so a create() in one test is invisible to the next — tests never pollute each
other or leave data behind. This isolation is why you can freely create objects in tests without cleanup.
Testing custom methods and managers
The logic most worth testing is what you added. Your custom manager method, for instance:
class AppointmentManagerTests(TestCase):
def test_booked_returns_only_booked(self):
doctor = Doctor.objects.create(name="Rao", specialisation="Cardiology")
patient = Patient.objects.create(name="Asha", phone="09812345678", city="Pune")
for _ in range(3):
Appointment.objects.create(patient=patient, doctor=doctor,
scheduled_for=timezone.now(),
status=Appointment.Status.BOOKED)
Appointment.objects.create(patient=patient, doctor=doctor,
scheduled_for=timezone.now(),
status=Appointment.Status.DONE)
self.assertEqual(Appointment.objects.booked().count(), 3) # verified: 3
Verified: booked() returns exactly the 3 booked appointments, not the done one. This is a test worth
having — if someone changes the booked() filter (say, to accidentally include cancelled), this test fails.
The same pattern tests a model method: create the object in the state you want, call the method, assert the
result. Set up the specific scenario each test needs — do not rely on data from other tests (there is
none, thanks to rollback).
Testing validation and constraints
Your validation rules deserve tests, and so does the "it correctly says no" case. For a database constraint:
from django.db import IntegrityError
class AppointmentConstraintTests(TestCase):
def test_negative_fee_is_rejected(self):
doctor = Doctor.objects.create(name="Rao", specialisation="Cardiology")
patient = Patient.objects.create(name="Asha", phone="09812345678", city="Pune")
with self.assertRaises(IntegrityError): # verified: raises
Appointment.objects.create(patient=patient, doctor=doctor,
scheduled_for=timezone.now(), fee=Decimal("-1"))
Verified: creating an appointment with a negative fee raises IntegrityError, because of the
CheckConstraint from the transactions lesson — and assertRaises asserts exactly that. assertRaises is
how you test that something fails as it should: the test passes when the expected exception is raised, and
fails if the code wrongly allows the bad value. Testing the rejection is as important as testing the happy
path — it is the assertion that catches someone accidentally removing the constraint.
For model clean() validation (as opposed to a database constraint), you call full_clean() and assert it
raises ValidationError:
from django.core.exceptions import ValidationError
def test_clean_rejects_bad_data(self):
appt = Appointment(...)
with self.assertRaises(ValidationError):
appt.full_clean() # runs model validation
The assertions you will use most
TestCase (via Python's unittest) gives you a family of assertions; the ones for model tests:
| Assertion | Checks |
|---|---|
assertEqual(a, b) |
a == b (the workhorse) |
assertTrue(x) / assertFalse(x) |
truthiness (e.g. .exists()) |
assertIsNone(x) / assertIsNotNone(x) |
None checks |
assertRaises(Error) |
a block raises the exception |
assertIn(a, b) |
membership |
assertQuerySetEqual(qs, values) |
a queryset yields expected values |
Prefer the specific assertion over a generic one — assertEqual(count, 3) fails with a clearer message
("3 != 2") than assertTrue(count == 3) ("False is not true"). The better message is worth the habit.
Keep model tests fast and focused
Model tests should be the fastest, most numerous part of your suite (the pyramid from the previous lesson). A few habits keep them that way:
- One behaviour per test. A test named
test_booked_returns_only_bookedasserts one thing; when it fails, you know exactly what broke. A test that asserts ten things is ten tests wearing one name. - Create only the data the test needs. Do not build a huge fixture for a test that checks one method — set up the minimal scenario.
- Name tests for the behaviour, not the method —
test_negative_fee_is_rejected, nottest_fee. The name should read as the thing you are guaranteeing.
Verified against the reference: these model tests run in milliseconds and pass. Because they are fast and precise, you run them constantly and they tell you exactly what a change broke — which is the entire value of a test suite.
Check your work
The anatomy. Subclass TestCase, create objects, assert on behaviour; test_-prefixed methods are
auto-discovered; each runs in a rolled-back transaction, so tests are isolated. Verified: the __str__ test
passes.
Test your own logic. Custom managers/methods (verified: booked() returns exactly 3), your validation,
your constraints — the code you can break.
Testing rejection. assertRaises(IntegrityError) for a database constraint (verified: negative fee
rejected), assertRaises(ValidationError) around full_clean() for model validation — testing "it says no"
matters as much as the happy path.
Assertions. Use the specific one (assertEqual, assertTrue, assertRaises, assertIn) for the
clearest failure message.
Fast and focused. One behaviour per test, minimal data, behaviour-named tests — keeping the model suite fast and precise so you run it often.
Practice
- Write
test_str_is_namefor a model with a__str__; run it and confirm it passes. - Test a custom manager method (like
booked()): set up a mixed scenario and assert the count. Reproduce the verified result (3 of 4). - Test a constraint with
assertRaises(IntegrityError)— create a row that violates it and confirm the test passes; then remove the constraint and watch the test fail. - Test a model
clean()/full_clean()withassertRaises(ValidationError). - Rewrite an
assertTrue(x == y)asassertEqual(x, y), break it deliberately, and compare the failure messages. - Take a test that asserts several things and split it into one-behaviour-per-test methods with descriptive names.
Official documentation
- Django — Testing tools —
TestCase, database isolation, assertions. - Python —
unittestassertions — The full assertion family. - Django — Testing
assertRaisesMessageand friends — Django's added assertions.
Next: testing views and forms.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship