Testing: Unit Tests

Every lesson up to this point has asked you to trust that a piece of code behaves the way it's described. Tests are how you stop trusting and start checking — a small script that creates some data, runs your code against it, and asserts the result is what you expect. Django doesn't ask you to learn a separate testing tool for this: it builds directly on Python's own unittest module, and adds a few Django-specific conveniences on top that make testing models, views, and forms far less painful than testing them by hand.

Django's TestCase, Not Plain unittest.TestCase

You could write TaskFlow tests using Python's plain unittest.TestCase directly, and technically they'd run. But Django gives you django.test.TestCase instead, and it's worth understanding exactly what that swap buys you: every test method runs inside its own database transaction, and that transaction is automatically rolled back the moment the test finishes — whether it passed or failed. Any Task or Project a test created never actually lands permanently in the database.

That matters for two very practical reasons. First, tests never leave leftover rows behind for the next test run to trip over. Second, tests can't interfere with each other — one test creating a Task titled "Test task" can't accidentally cause a different, unrelated test to see it. Each test method starts from a clean slate.

A First Test: Testing the Task Model's __str__()

Back in Custom Managers & Meta Options, TaskFlow's Task model was given a __str__() method that returns the task's title instead of Django's default, unhelpful object representation. That's a small piece of behavior, but it's exactly the kind of thing worth locking down with a test — if someone changes __str__() later without realizing what depends on it, the test catches the regression immediately:

# taskflow/tests.py
from django.test import TestCase
from .models import Task

class TaskModelTests(TestCase):
    def test_str_representation(self):
        task = Task.objects.create(title='Test task')
        self.assertEqual(str(task), 'Test task')

Every test method's name must start with test_ — that's how Django's test runner finds it and knows to run it. self.assertEqual() is one of many assertion methods inherited from unittest.TestCase; it fails the test loudly if the two values it's given don't match, and passes silently if they do.

Testing a Custom Manager Method

The same lesson also defined Task.objects.incomplete(), a custom manager method that filters out completed tasks. That filter condition is exactly the kind of logic worth verifying directly, rather than just trusting it reads correctly:

class TaskManagerTests(TestCase):
    def test_incomplete_excludes_completed_tasks(self):
        Task.objects.create(title='Write report', completed=False)
        Task.objects.create(title='Send invoice', completed=True)

        open_tasks = Task.objects.incomplete()

        self.assertEqual(open_tasks.count(), 1)
        self.assertEqual(open_tasks.first().title, 'Write report')

This test creates one completed and one incomplete task, then checks two things at once: that incomplete() returns exactly one task, and that it's the right one. Both Task rows disappear automatically once the test method ends, courtesy of the transaction rollback described above.

Sharing Setup Across Tests with setUp()

Real tests often need the same starting data — a Project to attach tasks to, a User to act as its owner — repeated across several test methods in the same class. Rather than recreating that data by hand inside every single method, Django's TestCase (inherited from unittest.TestCase) calls a method named setUp() automatically before every test method runs:

from django.contrib.auth.models import User
from .models import Project, Task

class ProjectTaskTests(TestCase):
    def setUp(self):
        self.owner = User.objects.create_user(username='sonu', password='testpass123')
        self.project = Project.objects.create(
            name='Website Redesign', owner=self.owner
        )

    def test_new_project_has_no_tasks(self):
        self.assertEqual(self.project.tasks.count(), 0)

    def test_task_links_back_to_its_project(self):
        task = Task.objects.create(title='Design homepage', project=self.project)
        self.assertEqual(task.project, self.project)

Notice neither test method creates the User or Project itself — both rely on self.owner and self.project, set up fresh by setUp() right before each one runs. Because of the transaction rollback, the second test doesn't see any task left behind by the first — setUp() genuinely runs again, from scratch, for every method.

Running the Test Suite

With tests written, Django's own management command discovers and runs them:

python manage.py test

This walks every app in the project looking for a tests.py file (or a tests/ package), collects every class inheriting from TestCase, and runs every method starting with test_. Django even spins up a completely separate test database for the run and destroys it afterward, so none of this touches real data — not even outside the per-method transaction rollback.

A Note on factory_boy

Manually calling Task.objects.create(...) with every field spelled out gets tedious fast once a model has many fields, or once tests need dozens of realistic-looking objects. A popular third-party package called factory_boy addresses this by letting you define a "factory" for a model once, with sensible defaults for every field, and then generate as many test objects as you need with a single line — optionally overriding just the fields a specific test cares about. It's not something you need to master to write solid Django tests, but it's worth knowing it exists once .objects.create() starts feeling repetitive across a growing test suite.

Note: Every test above is correct, runnable Django code — but this website's own project doesn't execute a live test suite, so you won't see these pass or fail on this page. Try them against a real TaskFlow project to watch the rollback behavior yourself.
Next up: Testing: Test Client & Views extends this to full HTTP requests — simulating real visits to TaskFlow's views without ever running an actual server.