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:
- Existing files in
MEDIA_ROOTcould interfere with the tests; -
Saving files during tests would clutter the
MEDIA_ROOTdirectory 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 😆.