TDD of Django API

In Part 1 we dockerized our Django Todo api. In this tutorial we are going to use Test Driven Development to build out the Todo api.

Architecture of the API

Our objective is to build a Todo api that will allow us to create Todo tasks that are assigned to a project.

We also want to be able to add a category attribute to each Todo task which will allow us to bundle our tasks in each project by categories. For example we may have a number Todo tasks for the building out of the back end of our app and a number of other tasks for the front end of our app. It would be nice to be able to list our Todo tasks by:

  • Project
    • Category
      • Todo task

This also gives up the opportunity to use Django’s powerful MPTT, (Modified Preorder Tree Traversal) which allows us to store hierarchical data in our database. Very cool. You can read all about here.

We begin the build process of our mptt category model by first writing a test for the model that we will write.

TDD to Test the Model

Our test is doomed to fail because we have yet to write the category model but that’s what TDD is all about. First we write the tests which describes what we want the app to be able to do.

Begin by populating the file /server/todo_app/tests.py with the following:

# /server/todo_app/tests.py

from django.test import TestCase

from todo_app.models import Category

class CategoryModelTest(TestCase):
  def test_string_representation(self):
    category = Category(name="my category name")
    self.assertEqual(str(category), category.name)

Above we are using the base class TestCase for our Unit Test. TestCase class creates a clean database before tests are run and runs every test function in its own transaction.

First we create a data entry for our first category database and give it title, then using self.assertEqual(str(category), category.name) we test that our category entry’s string representation is equal to its name.

Run the test:

docker-compose run server ./manage.py test
Starting postgres_dj02 ... done
System check identified no issues (0 silenced).
E
======================================================================
ERROR: todo_app.tests (unittest.loader._FailedTest)
----------------------------------------------------------------------
ImportError: Failed to import test module: todo_app.tests
Traceback (most recent call last):
  File "/usr/local/lib/python3.8/unittest/loader.py", line 436, in _find_test_path
    module = self._get_module_from_name(name)
  File "/usr/local/lib/python3.8/unittest/loader.py", line 377, in _get_module_from_name
    __import__(name)
  File "/var/www/server/todo_app/tests.py", line 5, in <module>
    from .models import Category
ImportError: cannot import name 'Category' from 'todo_app.models' (/var/www/server/todo_app/models.py)


----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (errors=1)

Our test failed as expected since we don’t have category model yet to test so, lets write that model.

Category Model

# /server/todo_app/models.py

from django.db import models
from autoslug import AutoSlugField
from django.utils import timezone
from datetime import datetime
from django.conf import settings
from mptt.models import MPTTModel, TreeForeignKey

class Category(MPTTModel):
  name = models.CharField(max_length=50, unique=True)
  parent = TreeForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children')
  slug = AutoSlugField(populate_from='name', default=None)
  modified = models.DateTimeField(auto_now=True, null=True, editable=False) 
  created = models.DateField(default=timezone.now().strftime("%Y-%m-%d")) 

  def __str__(self): 
    return self.name 

  class MPTTMeta: 
    order_insertion_by = ['name'] 
    unique_together = ('slug', 'parent') 

  class Meta: 
    verbose_name_plural = 'categories'

Note the import statement for mptt. If you copied and pasted the code from the requirements.txt file in chapter one, the Dockerfile installed all the packages that we will need for this tutorial including MPTT.

Install Django MPTT

Add mptt to our /server/todo_proj/settings.py file:

# /server/todo_proj/settings.py

INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'todo_app',
'mptt',
]

Run django migrations:

docker-compose run server ./manage.py makemigrations
docker-compose run server ./manage.py migrate

# reload the django settings
docker-compose down
docker-compose up -d database
docker-compose up -d server

Run our test again:

docker-compose run server ./manage.py test

Starting postgres ... done
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.003s

OK
Destroying test database for alias 'default'...

Our test passed. Excellent!

Success! What’s next? Let’s write another test. This one for a Todo task using the same drill we used for the Category model.

Write the Todo Test

# todo-app-graphql/server/todo_app/tests.py

from django.test import TestCase

from todo_app.models import Category, Todo

class CategoryModelTest(TestCase):
  def test_string_representation(self):
    category = Category(name="my category name")
    self.assertEqual(str(category), category.name)

class TodoModelTest(TestCase):
  def test_string_representation(self):
    todo = Todo(title="my todo title")
    self.assertEqual(str(todo), todo.title)

Run the test:

docker-compose run server ./manage.py test

… and the test fails as expected:

Starting postgres_dj02 ... done
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.E
======================================================================
ERROR: test_string_representation (todo_app.tests.TodoModelTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/var/www/server/todo_app/tests.py", line 15, in test_string_representation
    todo = Todo(title="my todo title")
NameError: name 'Todo' is not defined

----------------------------------------------------------------------
Ran 2 tests in 0.021s

FAILED (errors=1)
Destroying test database for alias 'default'...

Now let’s create a model for Todo with a title. Also we need to add the __str__ method so that our model returns the todo title. Our models.py file should look something like this:

from django.db import models

from django.db import models
from autoslug import AutoSlugField
from django.utils import timezone
from datetime import datetime
from django.conf import settings
from mptt.models import MPTTModel, TreeForeignKey

class Category(MPTTModel):
  name = models.CharField(max_length=50, unique=True)
  parent = TreeForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children')
  slug = AutoSlugField(populate_from='name', default=None)

  modified = models.DateTimeField(auto_now=True, null=True, editable=False)
  created = models.DateField(default=timezone.now().strftime("%Y-%m-%d"))

  def __str__(self):
    return self.name

  class MPTTMeta:
    order_insertion_by = ['name']
    unique_together = ('slug', 'parent')

  class Meta:
    verbose_name_plural = 'categories'

class Todo(models.Model):
  title = (models.CharField(max_length=500))

  def __str__(self):
    return self.title

With the Todo class defined we make and run migrations:

docker-compose run server ./manage.py makemigrations
docker-compose run server ./manage.py migrate

Now our test should be successful:

docker-compose run server ./manage.py test
Starting postgres_dj02 ... done
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.012s

OK
Destroying test database for alias 'default'...

Continuing on the same track we add additional Todo model fields to our tests.

# todo-app-graphql/server/todo_app/tests.py

from django.test import TestCase

from todo_app.models import Category, Todo

class CategoryModelTest(TestCase):
  def test_string_representation(self):
    category = Category(name="my category name")
    self.assertEqual(str(category), category.name)

class TodoModelTest(TestCase):
  def test_string_representation(self):
    todo = Todo(title="my todo title")
    self.assertEqual(str(todo), todo.title)

  def test_todo_field(self):
    todo = Todo(
      title = "my todo title",
      task = "foo",
      is_completed = False,
      due_date = "2020-06-24",
    )
    self.assertEqual(todo.title, "my todo title")
    self.assertEqual(todo.task, "foo")
    self.assertEqual(todo.is_completed, False)
    self.assertEqual(todo.due_date, "2020-06-24")

If we were to run tests again they would fail because these fields haven’t been defined in the model so, let’s define them:

from django.db import models

from django.db import models
from autoslug import AutoSlugField
from django.utils import timezone
from datetime import datetime
from django.conf import settings
from mptt.models import MPTTModel, TreeForeignKey

class Category(MPTTModel):
  name = models.CharField(max_length=50, unique=True)
  parent = TreeForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children')
  slug = AutoSlugField(populate_from='name', default=None)

  modified = models.DateTimeField(auto_now=True, null=True, editable=False)
  created = models.DateField(default=timezone.now().strftime("%Y-%m-%d"))

  def __str__(self):
    return self.name

  class MPTTMeta:
    order_insertion_by = ['name']
    unique_together = ('slug', 'parent')

  class Meta:
    verbose_name_plural = 'categories'

class Todo(models.Model):
  title = (models.CharField(max_length=500))
  task = models.TextField(blank=True)
  slug = AutoSlugField(populate_from='title', default=None)
  is_completed = models.BooleanField(default=False)

  modified = models.DateTimeField(auto_now=True, null=True, editable=False)
  created = models.DateField(default=timezone.now().strftime("%Y-%m-%d"))
  due_date = models.DateField(default=timezone.now().strftime("%Y-%m-%d"))

  def __str__(self):
    return self.title

Run migrations again:

# we can combine the makemigrations and migrate docker-compose into one command
docker-compose run server ./manage.py makemigrations && docker-compose run server ./manage.py migrate

Run our tests again and…

docker-compose run server ./manage.py test

… and voila! The tests run with 0 issues. Success!

Starting postgres_dj02 ... done
Creating test database for alias 'default'Creating network "todo_mptt_2_default" with the default driver
Creating postgres_dj02 ... done
Creating dj02          ... done
web:todo_mptt_2 ronleeson$ docker-compose run server ./manage.py test
Starting postgres_dj02 ... done
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...
----------------------------------------------------------------------
Ran 3 tests in 0.014s

OK
Destroying test database for alias 'default'......
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.014s

OK
Destroying test database for alias 'default'...

Full speed ahead we add the relationship between the Todo and the Category model:

# /server/todo_app/models.py

from django.db import models
 
from django.db import models
from autoslug import AutoSlugField
from django.utils import timezone
from datetime import datetime
from django.conf import settings
from mptt.models import MPTTModel, TreeForeignKey
 
class Category(MPTTModel):
  name = models.CharField(max_length=50, unique=True)
  parent = TreeForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children')
  slug = AutoSlugField(populate_from='name', default=None)
 
  modified = models.DateTimeField(auto_now=True, null=True, editable=False)
  created = models.DateField(default=timezone.now().strftime("%Y-%m-%d"))
 
  def __str__(self):
    return self.name
 
  class MPTTMeta:
    order_insertion_by = ['name']
    unique_together = ('slug', 'parent')
 
  class Meta:
    verbose_name_plural = 'categories'
 
class Todo(models.Model):
  title = (models.CharField(max_length=500))
  task = models.TextField(blank=True)
  slug = AutoSlugField(populate_from='title', default=None)
  is_completed = models.BooleanField(default=False)

  category = TreeForeignKey('Category', on_delete=models.CASCADE, null=True, blank=True, related_name='todos')
 
  modified = models.DateTimeField(auto_now=True, null=True, editable=False)
  created = models.DateField(default=timezone.now().strftime("%Y-%m-%d"))
  due_date = models.DateField(default=timezone.now().strftime("%Y-%m-%d"))
 
  def __str__(self):
    return self.title

… and update the TodoModelTest in the tests.py file by defining a category object and assigning the object to our new Todo object

# /server/todo_app/tests.py

from django.test import TestCase

from todo_app.models import Category, Todo

# ... code

class TodoModelTest(TestCase):
  def test_string_representation(self):
    todo = Todo(title="my todo title")
    self.assertEqual(str(todo), todo.title)

  def test_todo_field(self):

    # create a category object
    cat = Category(name="Frontend")

    todo = Todo(
      title = "my todo title",
      task = "foo",
      category = cat,
      is_completed = False,
      due_date = "2020-06-24",
    )
    self.assertEqual(todo.title, "my todo title")
    self.assertEqual(todo.task, "foo")
    self.assertEqual(todo.category.name, "Frontend")
    self.assertEqual(todo.is_completed, False)
    self.assertEqual(todo.due_date, "2020-06-24")

Run migrations:

docker-compose run server ./manage.py makemigrations && docker-compose run server ./manage.py migrate

# run tests again
docker-compose run server ./manage.py test
docker-compose run server ./manage.py test
Starting postgres_dj02 ... done
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...
----------------------------------------------------------------------
Ran 3 tests in 0.019s

OK
Destroying test database for alias 'default'...

Success once again! Pushing on to our final model to test, Project. Update the tests.py file like so:

# /server/todo_app/tests.py

from django.test import TestCase

from todo_app.models import Category, Todo, Project

class CategoryModelTest(TestCase):
  def test_string_representation(self):
    category = Category(name="my category name")
    self.assertEqual(str(category), category.name)

class TodoModelTest(TestCase):
  def test_string_representation(self):
    todo = Todo(title="my todo title")
    self.assertEqual(str(todo), todo.title)

  def test_todo_field(self):
    # create category object
    cat = Category(name="Frontend")

    todo = Todo(
      title = "my todo title",
      task = "foo",
      category = cat,
      is_completed = False,
      due_date = "2021-07-30",
    )
    self.assertEqual(todo.title, "my todo title")
    self.assertEqual(todo.task, "foo")
    self.assertEqual(todo.category.name, "Frontend")
    self.assertEqual(todo.is_completed, False)
    self.assertEqual(todo.due_date, "2021-07-30")

class ProjectModelTest(TestCase):
  def test_string_representation(self):
    project = Project(name = "my project name")
    self.assertEqual(str(project), project.name)

  def test_project_field(self):
    proj = Project(
      name = "my project name",
      description = "my project description",
    )
    self.assertEqual(proj.name, "my project name")
    self.assertEqual(proj.description, "my project description")


… and this test will fail until we add our Project model which we can do now:

# /server/todo_app/models.py

from django.db import models

from django.db import models
from autoslug import AutoSlugField
from django.utils import timezone
from datetime import datetime
from django.conf import settings
from mptt.models import MPTTModel, TreeForeignKey

class Category(MPTTModel):
  name = models.CharField(max_length=50, unique=True)
  parent = TreeForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children')
  slug = AutoSlugField(populate_from='name', default=None)

  modified = models.DateTimeField(auto_now=True, null=True, editable=False)
  created = models.DateField(default=timezone.now().strftime("%Y-%m-%d"))

  def __str__(self):
    return self.name

  class MPTTMeta:
    order_insertion_by = ['name']
    unique_together = ('slug', 'parent')

  class Meta:
    verbose_name_plural = 'categories'

class Todo(models.Model):
  title = (models.CharField(max_length=500))
  task = models.TextField(blank=True)
  slug = AutoSlugField(populate_from='title', default=None)
  is_completed = models.BooleanField(default=False)

  category = TreeForeignKey('Category', on_delete=models.CASCADE, null=True, blank=True, related_name='todos')

  modified = models.DateTimeField(auto_now=True, null=True, editable=False)
  created = models.DateField(default=timezone.now().strftime("%Y-%m-%d"))
  due_date = models.DateField(default=timezone.now().strftime("%Y-%m-%d"))

  def __str__(self):
    return self.title

class Project(models.Model):
  name = models.CharField(max_length=500)
  description = models.TextField(max_length=2500, blank=True, null=True, help_text="Description of your project.")
  slug = AutoSlugField(populate_from='name', default=None)
  modified = models.DateTimeField(auto_now=True, null=True, editable=False) 
  created = models.DateField(default=timezone.now().strftime("%Y-%m-%d")) 

  def __str__(self): 
    return self.name 

  class Meta: 
    verbose_name_plural = 'projects'

Now that we have the Project model we can associate a foreign key relationship between Todo and Project.

# /server/todo_app/models.py

from django.db import models

from django.db import models
from autoslug import AutoSlugField
from django.utils import timezone
from datetime import datetime
from django.conf import settings
from mptt.models import MPTTModel, TreeForeignKey

class Category(MPTTModel):
  name = models.CharField(max_length=50, unique=True)
  parent = TreeForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children')
  slug = AutoSlugField(populate_from='name', default=None)

  modified = models.DateTimeField(auto_now=True, null=True, editable=False)
  created = models.DateField(default=timezone.now().strftime("%Y-%m-%d"))

  def __str__(self):
    return self.name

  class MPTTMeta:
    order_insertion_by = ['name']
    unique_together = ('slug', 'parent')

  class Meta:
    verbose_name_plural = 'categories'

class Todo(models.Model):
  title = (models.CharField(max_length=500))
  task = models.TextField(blank=True)
  slug = AutoSlugField(populate_from='title', default=None)
  is_completed = models.BooleanField(default=False)

  category = TreeForeignKey('Category', on_delete=models.CASCADE, null=True, blank=True, related_name='todos')

  project = models.ForeignKey('Project', related_name='todo_project', on_delete=models.CASCADE, null=True, blank=True)

  modified = models.DateTimeField(auto_now=True, null=True, editable=False)
  created = models.DateField(default=timezone.now().strftime("%Y-%m-%d"))
  due_date = models.DateField(default=timezone.now().strftime("%Y-%m-%d"))

  def __str__(self):
    return self.title

class Project(models.Model):
  name = models.CharField(max_length=500)
  description = models.TextField(max_length=2500, blank=True, null=True, help_text="Description of your project.")
  slug = AutoSlugField(populate_from='name', default=None)
  modified = models.DateTimeField(auto_now=True, null=True, editable=False) 
  created = models.DateField(default=timezone.now().strftime("%Y-%m-%d")) 

  def __str__(self): 
    return self.name 

  class Meta: 
    verbose_name_plural = 'projects'

Update tests.py to test the Todo model for the foreign key relationship with Project.

# /server/todo_app/tests.py
 
from django.test import TestCase
 
from todo_app.models import Category, Todo, Project
 
class CategoryModelTest(TestCase):
  def test_string_representation(self):
    category = Category(name="my category name")
    self.assertEqual(str(category), category.name)
 
class TodoModelTest(TestCase):
  def test_string_representation(self):
    todo = Todo(title="my todo title")
    self.assertEqual(str(todo), todo.title)
 
  def test_todo_field(self):
    # create category object
    cat = Category(name="Frontend")

    # create project object
    proj = Project(name="My Project")
 
    todo = Todo(
      title = "my todo title",
      task = "foo",
      category = cat,
      project = proj,
      is_completed = False,
      due_date = "2021-07-30",
    )
    self.assertEqual(todo.title, "my todo title")
    self.assertEqual(todo.task, "foo")
    self.assertEqual(todo.category.name, "Frontend")
    self.assertEqual(todo.project.name, "My Project")
    self.assertEqual(todo.is_completed, False)
    self.assertEqual(todo.due_date, "2021-07-30")
 
class ProjectModelTest(TestCase):
  def test_string_representation(self):
    project = Project(name = "my project name")
    self.assertEqual(str(project), project.name)
 
  def test_project_field(self):
    proj = Project(
      name = "my project name",
      description = "my project description",
    )
    self.assertEqual(proj.name, "my project name")
    self.assertEqual(proj.description, "my project description")

… run migrations…

docker-compose run server ./manage.py makemigrations && docker-compose run server ./manage.py migrate

… run tests…

docker-compose run server ./manage.py test
Starting postgres_dj02 ... done
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.....
----------------------------------------------------------------------
Ran 5 tests in 0.024s

OK
Destroying test database for alias 'default'...

… once again, success!

Organize Our Tests

If we start writing tests for a complex project the tests.py file could get messy so, we will breakup our test file into separate files. In this case we create a file just for model tests. In order to do this we will need to reorganize the todo_app directory.

# create a tests folder
mkdir server/todo_app/tests

# move the tests.py file into the new tests folder
mv server/todo_app/tests.py server/todo_app/tests/test_models.py

# create the __init__.py so python treats directories containing it /
# as modules
touch server/todo_app/tests/__init__.py
  • todo_app
    • tests [folder]
      • __init__.py
      • test_models.py

Now we should be able to run our tests again and django will look in our tests directory for all our tests:

docker-compose run server ./manage.py test
Starting postgres_dj02 ... done
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.....
----------------------------------------------------------------------
Ran 5 tests in 0.025s

OK
Destroying test database for alias 'default'...

And our tests run successfully!

Loading Data from the Django Shell

Let’s enter the Django shell via docker-compose. Open a new terminal tab in our Todo App GraphQL project and enter the following:

docker-compose run server ./manage.py shell

This should kick us into Django shell. From here we can import our todo_app model and enter data for Project, Category, and Todo:

>>> from todo_app.models import Category, Project, Todo

>>> proj = Project(name='Project 1', description='Project 1 description')
>>> proj.save()

>>> cat = Category(name='Category 1')
>>> cat.save()

>>> todo = Todo(title='My First Todo', task='Do it', category=cat, project= proj)
>>> todo.save()

>>> quit()

Voila! The data is there.

Lets see if the data is really there by using the Django admin UI.

Configure Django Admin

Add the following code to our todo-app-graphql/server/todo_app/admin.py file:

# todo-app-graphql/server/todo_app/admin.py

from django.contrib import admin
from mptt.admin import MPTTModelAdmin
from mptt.admin import DraggableMPTTAdmin
from mptt.admin import TreeRelatedFieldListFilter

from todo_app.models import( 
        Category, 
        Todo,
        Project,
    )

admin.site.register(
    Category, 
    DraggableMPTTAdmin,
    list_display=(
        'tree_actions',
        'indented_title',
    ),
    list_display_links=(
        'indented_title',
    )
)

class ProjectAdmin(admin.ModelAdmin):
    list_display = ('name', 'created', 'modified')

admin.site.register(Project, ProjectAdmin)

class TodoAdmin(admin.ModelAdmin):
    model = Todo 
    list_filter = (
        ('category', TreeRelatedFieldListFilter),
    )
    list_display = ('title', 'is_completed', 'created', 'modified')

admin.site.register(Todo, TodoAdmin)

Reboot the app:

docker-compose down && docker-compose up -d

Now we can add data for Category, Project and Todo from http://localhost:5555/admin/todo_app/todo/

Coming Up Part 3: TDD Django Graphene