Test Driven Development of Django Models

My testing environment for Django most likely consists of the dependencies below:

This is for some obvious reasons. Firstly, dependency injection makes the testing a lot easier and pytest has fixtures to easily implement them. And pytest-django has tons of common helpers and fixtures for testing in Django development environment such as db for testing database migrations, client for testing http requests, admin_client which is already logged in as an admin user, admin_user for grabbing an admin user, settings in order to get and mock settings for Django. Yes, you can achieve these with standard testing library of Python and Django, but creating an abstact base class for a special test with standard library takes much more effort than pytest and pytest-django stack.

And these days I've realized I've been implementing some kind of pattern which I wanted to share. However, first, let's make some ground here, which actually refers to the fundamentals of the test driven development:

  1. We need to create the test first. Yes, that's obvious.
  2. However, if the unit we are going to create will be a common pattern in the project, we need to create an abstract base testing class. That's because to make a common problem quick to implement. However, it is not the only reason.
  3. These abstract base testing classes are going to be a contract for a developer to implement the same common pattern in the project. If any developer will implement something related to the pattern A, then they need to create a test first and implement the abstract base class. It is a contract between the codebase and the developer.

So, if we fill the gaps above in terms of Django's models, we need to have an abstract base class for testing a Django Model and Django Field. That's the goal of this post.

Creating An Abstract Base Class for Testing Models

Our first step is to create that abstract base class for Django Model. What do we ask for from a Model implementation anyway? In our overly simplified case, we want a model implementation:

  • to exist under app.models module.
  • to inherit Django's Model base class.

    Tip

    This is, again, overly simplified. Your project might need more such as ordering to have a common element.

So, this is what I came up with:

class BaseModelTest:
    model: models.Model = None  # model class

    def test_issubclass_model(self):  # is it a subclass of model?
        assert issubclass(self.model, models.Model)

The abstract base testing classes in my project usually live under project.testing.models module (not under any app package).

So, if I ever wanted to create a model, what I would do is...

# in "app/tests/test_models/test_post.py"
from app import models

class TestPostModel(BaseModelTest):
    model = models.Post

# now I run tests
# and they fail, it is a good thing

# in "app/models.py"
class Post(models.Model):
    pass

# now I run tests
# and they pass, I successfully created "Post" model

Above is what I do in order to create model. So, if I ever wanted to create a new model named Comment,

  1. I would first create TestCommentModel that extends BaseModelTest
  2. I would fill model field with the class.
  3. I would run the test but it would fail because it simply does not exist yet.
  4. I would create Comment model with only pass.
  5. I would run the test again and it would pass since it does exist right now.

The great thing about this method is it does not require you to makemigrations or migrate, which makes it easier to do changes before write models and pushing it alongside the migrations to the project repository.

Creating An Abstract Base Class for Testing Model Fields

We did not do much. We simply created a contract for creating a model. However, as you can guess, models naturally have fields and we need to cover them as well.

Let's ask the same question again. What do we ask for from a Field? In our overly simplified case, again, we want:

  • to know the model of the field so that we can look under that particular model to search the field in question
  • to know its name so that we can find the field in the model
  • to ensure its type
  • to ensure if it is nullable
  • to ensure if it can be left blank in forms
  • to know if it has a default value and if so, what the default value is

This list is greater than the list of model's since the fields are detailed beasts. So we can now create a contract, an abstract base testing class, for fields. This is what I've come up with.

class BaseModelFieldTest:
    model: models.Model = None
    field_name: str = None

    field_type: models.Field = None
    is_null: bool = False
    is_blank: bool = False
    default_value = models.fields.NOT_PROVIDED

Note

Each property after field_type is the default value of field when you do not specify them in the models. So, if you create a models.TextField() with no kwargs under a model, the tests should pass.

However, it has not finished yet. As you can see, we did not write tests for our contract. Yet, I want to ask the reader a question: For testing a particular field of a particular model, we need to get that field from that model, but how do we actually do that?

If the reader is a bit experienced about Python, probably he would do something like below in a test:

field = self.model.__dict__[self.field_name]

Because Python holds the data of a class/object in __dict__ right? Well, that's not how it works in Django. You can access a field in a test as such:

field = self.model._meta.get_field(self.field_name)

The fields are under the get_field method of a model's _meta property. So, why is it you might ask? Wasn't it just a proper, intuitive and convenient way to hold fields directly under __dict__ of a Model implementation?

Well, there's not a clear answer for that. The only answer that could be given is "Because that's the design choice that Django core developers has chosen.".

Tip

You can also use Model._meta to get the properties of Meta class under a model implementation, such as Post._meta.ordering returns the ordering property of Post.Meta.

Instead of writing field = ... in each test, I can write a property function for BaseModelFieldTest called field. The end result is:

class BaseModelFieldTest:
    model: models.Model = None
    field_name: str = None

    field_type: models.Field = None
    is_null: bool = False
    is_blank: bool = False
    default_value = models.fields.NOT_PROVIDED

    @property
    def field(self):
        return self.model._meta.get_field(self.field_name)

And now, I can simply call self.field in each test to simply get the field. And now, I create tests for each property after field_name. The end result is:

class BaseModelFieldTest:
    model: models.Model = None
    field_name: str = None

    field_type: models.Field = None
    is_null: bool = False
    is_blank: bool = False
    default_value = models.fields.NOT_PROVIDED

    @property
    def field(self):
        return self.model._meta.get_field(self.field_name)

    def test_field_type(self):
        assert isinstance(self.field, self.field_type)

    def test_is_null(self):
        assert self.field.null == self.is_null

    def test_is_blank(self):
        assert self.field.blank == self.is_blank

    def test_default_value(self):
        assert self.field.default == self.default_value

Now it is time to do our thing:

# in "app/tests/test_models/test_post.py"
class TestPostContentField(BaseModelFieldTest):
    model = models.Post
    field_name = "content"
    field_type = models.models.TextField

    default_value = "No Content"

# now I run tests
# and they fail, it is a good thing

# in "app/models.py"
class Post(models.Model):
    content = models.TextField(default="No Content")

# now I run tests
# and they pass, I successfully created "content" field for "Post" model

Creating Tests for Field Specific Kwargs

Some fields in Django receive special initialization argument. I want the reader to consider CharField as an example since CharField requires max_length kwarg to be set.

Since this is specific to CharField, it would not be great to add it to BaseModelFieldTest. However, we can add it to the implemented test class as test if we would like to.

Let's consider an example. Our Post model will definitely need a title field and titles are meant to be short and limited. Let's see the example below:

# in "app/tests/test_models/test_post.py"
class TestPostTitleField(BaseModelFieldTest):
    model = models.Post
    field_name = "title"
    field_type = models.models.CharField

    is_null = True
    is_blank = True

    def test_max_length(self):  # we test max length in implemented "TestPostTitleField" class
        assert self.field.max_length == 64

# now I run tests
# and they fail, it is a good thing

# in "app/models.py"
class Post(models.Model):
    title = models.CharField(null=True, blank=True, max_length=64)
    content = models.TextField(default="No Content")

# now I run tests
# and they pass, I successfully created "title" field for "Post" model

Testing date and time related fields are a bit different because auto_now and auto_now_add kwargs have side effects on null, blank and default kwargs, so they should be treated differently. So, how are we going to achieve that?

Of course, we need to check if the field we are testing is date and time related field and do some skip operation if so. Where do we do that? We will need to edit BaseModelFieldTest class a bit.

However, at first, we need to create a tuple of all date and time related field classes so that we can check it easily. Somewhere before BaseModelFieldTest, add this tuple:

DATETIME_FIELDS = (models.DateTimeField, models.DateField, models.TimeField)

This is going to be a reference.

Now, my end result is below:

class BaseModelFieldTest:
    model: models.Model = None
    field_name: str = None

    field_type: models.Field = None
    is_null: bool = False
    is_blank: bool = False
    default_value = models.fields.NOT_PROVIDED

    is_auto_now: bool = True  # a property for auto_now
    is_auto_now_add: bool = True  # a property for auto_now_add

    @property
    def field(self):
        return self.model._meta.get_field(self.field_name)

    def test_field_type(self):
        assert isinstance(self.field, self.field_type)

    def test_is_null(self):
        assert self.field.null == self.is_null

    def test_is_blank(self):
        assert self.field.blank == self.is_blank

    def test_default_value(self):
        assert self.field.default == self.default_value

    def test_auto_now(self):  # testing auto_now
        if self.field.__class__ not in DATETIME_FIELDS:
            pytest.skip(
                f"{self.model.__name__}->{self.field_name} is not a date/time model type."
            )  # skip if not date and time related field

        assert self.field.auto_now == self.is_auto_now

    def test_auto_now_add(self):  # testing auto_now_add
        if self.field.__class__ not in DATETIME_FIELDS:
            pytest.skip(
                f"{self.model.__name__}->{self.field_name} is not a date/time model type."
            )  # skip if not date and time related field

        assert self.field.auto_now_add == self.is_auto_now_add

Now let's assume that we are going to put a created_at timestamp to our Post. The implementation would be:

# in "app/tests/test_models/test_post.py"
class TestPostCreatedAtField(BaseModelFieldTest):
    model = models.Post
    field_name = "created_at"
    field_type = models.models.CharField

    is_auto_now_add = True

# now I run tests
# and they fail, it is a good thing

# in "app/models.py"
class Post(models.Model):
    title = models.CharField(null=True, blank=True, max_length=64)
    content = models.TextField(default="No Content")
    created_at = models.DateTimeField(auto_now_add=True)

# now I run tests
# and they pass, I successfully created "created_at" field for "Post" model

Final Words

TDD is not some kind of magic or a religion to me. I personally cannot even figure out how I would implement this approach to another parts of Django such as views, templates or management commands. However, I usually do what feels consistent enough. This pattern might not be the best approach to write models and probably has downsides as well.

I can even come up with one as I write this. For instance, what about ForeignKey and other relationship-based fields? Well, I do not know.

If the reader wants to get the full code of this stuff, they can find it here.

Eray Erdin

Believing one day we will carry mobile oxygen tubes along and an umbrella which protects us from acidic rain under the cancer-distributing green sky.

Write your comment…

Eray, your intentions are good, but you seem quite confused both about what you should test and what pytest provides you over the standard Django-provided UnitTest derivative test class.

First, you don't need to test model fields by themselves. That's already done by Django. When you declare a Field in a model you're giving it some properties in a declarative way and it is Django's job to ensure they are honored, not yours. So you don't have to test if the maximum field lenght is enforced, or its nullability. Nor it seems a good use of your time to check that you've declared the field with the correct parameters, unless you want to have a warning by means of a test failure of changes in the model declaration.

Your tests should focus on what functionality you are adding to the model, that is, model methods. It does not make sense to test what is already tested. Remember, you are using a framework that is supposed to save you time when you use its parts. Testing again what the framework provides is not a profitable use of your time.

Second, you seem to be unaware of the complexities and potential problems of using pytest while not realizing that the value that it adds over the Django-provided TestCase/SimpleTestCase classes is very little, if any. Django TestCase already provides you an HTTP client instance. True, Django TestCase it does not provide the admin_user, but that's because non trivial Django apps do not use an allmighty admin user for everything but instead have individual rights over individual models. And because creating a user and logging in the client with that user is a one-liner that can be added to your testing base class.

Speaking of the testing base class, you also seem to ignore the benfits of inheriting rather than recreating all the TestCase niceties, such as setUp/tearDown, multi_db support and others that you'll have to recreate by hand using pytest, often either arriving at the same result or creating your own, slightly worse, versions of all those things. Again, you're using a battle tested framework that should save you a lot of time, not one that makes you rebuild parts of it and waste time chasing bugs in other packages.

Because pytest is not free. It has its drawbacks, some of them generic, some of them Django specific Such as its obscure Django initialization that sometimes makes model attributes disappear. Or its discovery stage that will scan your whole source tree instead of only the app folders (watch it when it finds a .py file inside a JS source module for example...) even if you specify that you want to execute a single test. Or its inability to cope with multi-DB Django apps unless very weirdly configured. And its dependence on Django TestRunner to work.... and so on. In practice, I've wasted enough time ironing out pytest quirks and working around its bugs and idiosyncrasies to conclude that is not worth the effort.

In the end, I think it is a matter of experience. You'll need to face a moderately complex application to become aware of all those things. Just hoping that you can save some time by heeding to this advice.

You know what Alfonso Garcia. It's been a while and I also started to think I do not like this article and probably will take this out of public. I would not like to feed other people with a flawed mindset.

I am now much more aware of "not testing the dependency". At the time, locking the state of models seemed pretty reasonable because what's immutable is also safe, that was probably what I thought, but now I am aware of it. That's why I no longer write test for models (at least for the most primitive parts), but rather plan it beforehand with a schema on draw.io or something.

I will remove this article in 24 hours.

Reply to this…