Dec. 9, 2024

Using a temporary storage backend inside a Django test

Using a temporary storage backend inside a Django test

Today I ran across an issue involving storages and staticfiles during tests. I was surprised not to find any quick solution online so here's my quick attempt at a solution. First the code, then some explanations:

import shutil
import tempfile


class TempDirMediaRootMixin:
    @classmethod
    def setUpClass(cls):
        cls.tmpdir = tempfile.mkdtemp()
        super().setUpClass()

    @classmethod
    def tearDownClass(cls):
        super().tearDownClass()
        shutil.rmtree(cls.tmpdir, ignore_errors=True)

    def run(self, result=None):
        with self.settings(MEDIA_ROOT=self.tmpdir):
            return super().run(result)

The problem

If you've ever worked with files in Django you're probably familiar with settings.MEDIA_ROOT which is the directory on your filesystem where Django will store uploaded files (assuming you're using the default settings.STORAGES and its FileSystemStorage backend).

However, unlike other settings like settings.DATABASES or settings.EMAIL_* which are treated specially during tests (Django will run tests in a special test database, and will also send emails to a special in-memory email backend during tests), Django will happily reuse your settings.MEDIA_ROOT when running your tests.

This created two problems for me:

  1. Existing files in MEDIA_ROOT could interfere with the tests;
  2. Saving files during tests would clutter the MEDIA_ROOT directory and were not cleaned up after the tests were finished.

My solution

Problem #1 can be solved by using a different MEDIA_ROOT when running tests. Maybe by using a value derived from an environment variable inside the settings file, or by using a different settings module for the tests. Either way, it's a bit annoying and requires modifications to the project's settings file, and maybe a bit of rearchitecturing of the infrastructure (like switching to multiple settings files).

Problem #2 can be solved by manually deleting our test MEDIA_ROOT directory, but that can get annoying fast (and it's easy to forget).

What if we could use a temporary directory for our tests, and have it be automatically cleaned once the test is over? Luckily Python has a nice builtin library for that: tempfile!

Calling tempfile.mkdtemp() returns the name of a directory on the filesystem with a random name (you can use the prefix and suffix arguments to customize the naming of the directory which can be useful when debugging). Once we have a name, all that's left is to tell Django to use that for settings.MEDIA_ROOT.

This is where unittest.TestCase's extension points come in handy. First, there's setUpClass(): this method will be called once per TestCase (sub)class at the beginning and so is the perfect place to initialize our temporary directory with mkdtemp().

class TempDirMediaRootMixin:
    @classmethod
    def setUpClass(cls):
        cls.tmpdir = tempfile.mkdtemp()
        super().setUpClass()

    ...

Then there's the corresponding tearDownClass(): this method is also called once per (sub)class but this time at the end and so is the perfect place to have cleanup logic, in this case using shutil.rmtree() to delete our temporary directory.

class TempDirMediaRootMixin:
    ...

    @classmethod
    def tearDownClass(cls):
        super().tearDownClass()
        shutil.rmtree(cls.tmpdir, ignore_errors=True)

    ...

Finally I needed a way to tell Django to use my temporary directory as a value for settings.MEDIA_ROOT. Normally you could decorate either a whole TestCase class or one of its methods with Django's override_settings(MEDIA_ROOT=...) but here we can't do that because the new value of MEDIA_ROOT is dynamic. That's why I opted to use the run() extension point since it allows me to wrap the execution of all the test methods of the class inside a context manager, conveniently using Django's self.settings().

class TempDirMediaRootMixin:
    ...

    def run(self, result=None):
        with self.settings(MEDIA_ROOT=self.tmpdir):
            return super().run(result)

Final thoughts

One thing I don't like so much with this approach is that there's still some state shared between individual tests on the same test class. That could be fixed by clearing the temporary directory either before every test is run (setUp()) or after (tearDown()), but I would worry about performance (I haven't done any benchmarks though, so maybe that's not actually an issue 🤷🏻).

I also wonder if this is something that could/should be fixed in Django itself. I found it surprising that Django reused the project's settings.STORAGES during the tests. This is different from what happens with settings.DATABASES or settings.EMAIL_BACKEND where Django swaps the real backends with a different one when running tests. Not sure if there's already a ticket for that. If I ever run out of things to do on my open-source TODO list maybe I'll tackle that one 😆.