Testing Django With Managed And Unmanaged Models

written by Chris Goodwin on 2020-03-11


Django is fantastic at a lot of things - it's secure and has pretty much all the bells and whistles you can imagine. It's got a great community - but sometimes there are things that are a little more niche and hard to find information on.

Testing in Django (especially with unmanaged models) is something I've seen show up on StackOverflow enough to recognize I'm not alone. I'll guide you on how I prefer to setup testing in Django in a way that makes life a little easier for those of us with more niche setups.

Testing can be tricky in these niche situations - but this guide should get an application to the point of testability without too many hoops to jump through (and only a few libraries to add). This document tries to be exhaustive in explanation of what’s going on - but for those more interested in getting to it I’ve included a TLDR Summary.

I have had to deal with a lot of unmanaged tables and views - luckily there are libraries like factory_boy and pytest (with the pytest-django plugin) that make this much easier to manage. So let's get to it!


Required libraries:

Factory_boy

pip install factory_boy

Pytest

pip install pytest

Pytest-django

pip install pytest-django


Additional (optional) libraries:

Pytest-cov

pip install pytest-cov

Pytest-html

pip install pytest-html

Additional Note: You can also setup your tests to run (without migrations) with the default Django Test Suite! I’ve outlined the steps a little to help - although this whole document is a great primer and example of testing our projects.


We will need to perform a little setup ahead of time prior to running our tests:


Database Setup

Know your database setup!

Some places have really locked-down databases and keep things locked down to a point where Django cannot create the test databases it normally would (which are just empty copies of the database(s) specified in settings.py) This is true especially when dealing with MSSQL (as it would require giving django rights to master tables which isn't an option in certain organizations).

We can bypass a lot of this drama by setting up the database to use SQLite - although there are times or apps which require things that might be database specific. In those cases, working with your DB admin to allow django appropriate access is more important. I have found for most basic testing though, SQLite fits the bill for testing. It is also nice, because it works quickly and only uses local data so we’re never touching anything on the outside of our machine. This also allows Django to manage the data and create/teardown the databases on an as-needed basis.

Luckily for us - pytest and pytest-django make this process pretty simple! In order to use SQLite databases, we will tell pytest to use a settings file we will create, which will explicitly tell django to use SQLite.

  • Create a file named unit_test_settings.py alongside our projects settings.py

Note: You can use this Github Gist as the guideline and replace ‘your_project’ where necessary

Multiple Databases: If your project uses multiple databases then you can add those in the unit_test_settings.py file. If your project uses models which require other databases - you’ll need to make sure you are creating the appropriate sqlite database(s) named accordingly.


Pytest Setup

Create a file named pytest.ini in the root of our project (alongside manage.py). This file will tell pytest which settings/options we want to use when testing.

This will allow you to setup commands in the .ini file to run when you run pytest instead of having to add all the flags individually in the command line.

Example Command/Useage:

pytest - this will run tests for your entire project and display results in terminal.

pytest --cov=. - this will run tests against the project, display results in terminal and create a report in the directory htmlcov documenting coverage relating to the entire project.

pytest --cov=appname - this will run tests against the app, display results in terminal and create a report in the directory htmlcov documenting coverage relating to that app.

In some cases we would end up with a lot of additional command line arguments - but we can simplify it by using that .ini file!

Example .ini file:
[pytest]
addopts =
    --nomigrations
    --cov-config=.coveragerc
    --cov=your_project
    --cov=your_app
    --cov=another_app_in_the_project
    --junitxml=./test-results/junit.xml
    --cov-report html:./test-results/htmlcov
    --html=./test-results/test_results.html
    --self-contained-html
DJANGO_SETTINGS_MODULE = your_project.unit_test_settings
python_files = tests.py test_*.py *_tests.py

That will:

  • Set it up so the migrations aren't ran (so our unmanaged models don't cause errors - if they don't have migrations)
  • Run coverage for the specified project/apps
  • Give us a junit.xml file which will be used by various CI/CD
  • Give us an HTML page that shows passing/failing/errored tests

Nice!

Note: We specify a .coveragerc file in this .ini file - you may have to create this and set it up how you need. I generally just use it to omit certain files from being tested (like migrations, or the unit-test-settings.py file). There is good information out there on setting this up which is beyond the scope of this article, but in order to be a little more thorough here is an example:

Example .coveragerc file:
[run]
omit =
    *tests*
    *migrations*
    */unit-test-settings.py


Allow Django To Manage Unmanaged Models

Note: With the unit-test-settings.py being specified, this may not be a requirement - testing is required to confirm this! Doing the following will ensure unmanaged models will be tested, but may not be necessary.

We can look at our models - as we want Django handling them for testing purposes even if they're unmanaged.

If a model is unmanaged but used in our application we need to adjust the Meta subclass of the unmanaged model.

Only adjust the models your application needs! For example, if your application uses classes from another project, only adjust the models of that project that you’re using in your current application.

Unmanaged Model Example:
class UnmanagedModel(Model):
    id      = models.IntegerField(db_column='ID', primary_key=True)
    year    = models.IntegerField(db_column='year')
    grade   = models.CharField(db_column='grade', max_length=2)
    things  = models.IntegerField(db_column='things')

    class Meta(object):
        managed  = False
        db_table = '[database].[databaseTable]'

We need to change the managed attribute if we are under test! We can do that by changing it to this:

managed = getattr(settings, 'UNDER_TEST', False)

This will set managed to False, unless we’re UNDER_TEST, in which case it will return True and Django / Pytest can manage the model as it sees fit (so tables for this model will be created in the local SQLite, which we can then fill with data).

Note: If your model pulls data from a pre-populated table, or a view - this step is the same!


Factory Setup

This is where we make creating dummy data easy! We create factory versions of our classes, which allow us to create testable data that we are in total control of. Absolutely no randomness (unless we specifically desire that).

Factory_Boy is the library that will help us achieve this.

There is some great documentation with factory_boy:

In a nutshell? Any model that you create should have a corresponding Factory class.

Even if your model is unmanaged - create the Factory for it. It gives you a local version of that model in your test environment with the criteria that you’ve setup - making it controlled and testable.

Setup for factory_boy is pretty simple:

pip install factory_boy

Create a factories.py file in the directory of your app (not project level)

Begin building your factory models inside of there (remember to import your application classes from any models you wish to test against - your_other_app, another_app, etc.)

Managed Models:

Managed models are super simple to create a factory for. They look identical - except in the factory you’re filling in information you want your dummy object to have.

Standard Django Model(models.py):
class Year(models.Model):
    start_year = models.IntegerField()
    end_year = models.IntegerField(primary_key=True)
    label = models.CharField(max_length=6)
    active = models.BooleanField(default=False)
Factory_boy Model (factories.py):
class YearFactory(factory.django.DjangoModelFactory):
    start_year = 2018
    end_year = 2019
    label = 18-19
    active = True
Example Test:
class YearTest(unittest.TestCase):
    def test_active_year_exists(self):
        # Create a new Year model by calling the Factory
        year = YearFactory()
        # Our Factory sets active to True - we can test that it is!
        self.assertTrue(year.active == True)

This test would pass!

Unmanaged Models

If a model is unmanaged? No worries, we can still get it under test! Since the managed flag in the Meta subclass will be set to ‘True’ in testing - pytest will automatically handle the table creation when we run our tests (as long as we’re using the --nomigrations flag which is setup in the pytest.ini so it does it automatically).

Data From A Table:

Data coming from a table or view is easy to create factories for. You can simply inherit from factory.django.DjangoModelFactory for the factories relating to those models.

Factory Example:
class ThingFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = other_app.Thing

    attribute = True
   ...more class attributes setting up default data...


Notes Regarding Factories:

Classes that inherit from django.DjangoModelFactory are created and saved upon instantiation. If you call MyClassFactory() in the test? You now have an instance of MyClass as well as instances of any other classes that MyClass is tied to via foreign key.

Classes that inherit from factory.Factory must be created and then saved by using .save() - it’s an extra step but is required for data that is read from a MSSQL view. Since our test models are inheriting from DjangoModelFactory this shouldn’t ever be an issue but could be a ‘gotcha’ and is worth note.

Data provided by any MSSQL view (or table that is read from) that we want to have dummy data for will most likely have to be built ahead of time / populated in advance duringdata_setup or you may run into issues.

Foreign Keys:

A SubFactory allows you to make Foreign Keys for objects easily. Using the ThingFactory example above (from Unmanaged Models) you could make a foreign key to Year like this:

year = factory.SubFactory(YearFactory)

Now if you create a Thing model (ThingFactory()), factory_boy will automatically create a Year model as well, each filled with the dummy data you setup in the factory.


Data Setup

Some models are tied to databases that are pre-populated with data. How do we get around that and create similar data in a testable way in our test environment? Create a data_setup.py file where can create functions to set up chunks of required data!

Create a data_setup.py file in the tests directory of your application ( if you don't have a tests directory - create one!)

Instantiate all of your required classes to setup all the dummy data in a function that we can call from our tests setUp or setUpTestData methods.

Example Data_Setup.py:

from your_app import models as app_models

import your_app.factories as f
import factory


def basic_setup(self):
    # setup your required pre-populated data here
    f.ThingFactory()

There may be cases where you need more data than just one instance of a particular model. Maybe (for example) you needed multiple Year objects created. Just create an additional function in data_setup.py and call it in your basic_setup function.

Example build_years Function:
def build_years(self):
    # Create 4 years back from current year
    active_year = pcm.Year.get_active_end_year()
    for i in range(0, 4):
        end_year = active_year
        end_year = end_year - i
        start_year = end_year - 1
        f.YearFactory(
                  start_year=start_year,
                  end_year=end_year,
                  label=str(start_year)[-2:]+'-'+str(end_year)[-2:],
                  active=False
                  )
data_setup Using build_years:
def basic_setup(self):
    self.user = f.UserFactory()
    self.year = f.YearFactory()
    # Create the rest of our years with build_years
    build_years(self)


Using Data_Setup

In your test file - import your test factories from your application

import app_name.factories as f

In your tests setUp method - call the basic_setup function and your dummy data is loaded and ready for testing!

class EmployeeStaffingGroupTest(TestCase):

    def setUp(self):
        data_setup.basic_setup(self)

    def test_employee_staffing_group(self):
        employee_staffing_group = f.StaffingGroupFactory()
        expected_employee_staffing_group = 'Staffmember, Test'
        self.assertEquals(
            expected_employee_staffing_group, str(employee_staffing_group))

That test would pass!

You can use that setUp method on any TestCase where you need dummy data populated for testing purposes.

NOTE: Make sure that your tests start with test_ or it will not run!


Running Our Tests

Since we’re using pytest, running our tests is pretty simple.

From the project directory (same directory where you run any manage.py commands) just run the following command:

pytest

It will run the commands from the pytest.ini file that we setup earlier. If you wish to run pytest against a particular app? Just run:

pytest yourappname --cov=appname

You will see results with how many tests have passed, failed, or errored out. You’ll also be shown a number of warnings if you’re using deprecated verbiage or other such items.

Note: If you have pytest installed in your root Python install (and also in your virtualenv) it will try to use your root install by default! This will cause errors.


Standard Django Testing (without Migrations)

Our unit-test-settings.py would look like the Github Gist

Then you can run the tests in standard Django fashion:

python manage.py test --settings=”your_project.unit-test-settings”

The tests should all run and pass the way you would expect. It is the same as with pytest at this point - although pytest we see the generated coverage!


TL;DR : Summary

This summary is meant to be short and sweet. If you need clarification on any of these steps - please see the appropriately linked sections.

Install factory_boy, pytest, pytest-django, pytest-cov and pytest-html:

  • pip install factory_boy
  • pip install pytest
  • pip install pytest-django
  • pip install pytest-cov
  • pip install pytest-html

Create and setup unit-test-settings.py and pytest.ini

Set databases to use local SQLite databases for testing

Change unmanaged models to be managed when UNDER_TEST variable is True:

managed = getattr(settings, 'UNDER_TEST', False)

Create factories.py in the directory of your app (not project level)

Build your Model Factories (inheriting from factory.Factory or factory.django.DjangoModelFactory where appropriate - depending if the data is coming from a table or a view)

Create a data_setup.py file in the tests directory of your application if needed

If using data_setup.py:

  • In data_setup.py instantiate any classes needed to generate groups of objects where/when/if multiple objects are required

Test your application!

Note: You should be able to test it with either/both of the following commands:

python manage.py test --settings=”your_project.unit-test-settings”

or

pytest

Did you find this article helpful?

Feel free to help me keep this blog going (with coffee)!