RizTech Academy logo
RizTech Academy
Testing and QualityLesson 2 of 430 min

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_booked asserts 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, not test_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

  1. Write test_str_is_name for a model with a __str__; run it and confirm it passes.
  2. Test a custom manager method (like booked()): set up a mixed scenario and assert the count. Reproduce the verified result (3 of 4).
  3. 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.
  4. Test a model clean()/full_clean() with assertRaises(ValidationError).
  5. Rewrite an assertTrue(x == y) as assertEqual(x, y), break it deliberately, and compare the failure messages.
  6. Take a test that asserts several things and split it into one-behaviour-per-test methods with descriptive names.

Official documentation

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