The Hidden Cost of Test Inheritance
I'm subscribed to Adam Johnson's blog and usually really enjoy his writing - it's practical, deep, and no-bullshit. But one recent post, Python: sharing common tests in unittest, caught me off guard.
It describes a "neat" pattern: write reusable test logic in a base class, subclass it to test multiple objects, hiding the base class from unittest
discovery. While the intent is fine - DRYing out duplicated test code - the result is fragile, confusing, and just not worth it.
Here's why.
The Pattern: DRY Tests via Subclassing
# Sample units to test
class Armadillo:
def speak(self) -> str:
return "Hrrr!"
class Okapi:
def speak(self) -> str:
return "Gronk!"
# Test module
class BaseAnimalTests(TestCase):
animal_class: type
def test_speak(self):
sound = self.animal_class().speak()
self.assertIsInstance(sound, str)
self.assertGreater(len(sound), 0)
class ArmadilloTests(BaseAnimalTests):
animal_class = Armadillo
class OkapiTests(BaseAnimalTests):
animal_class = Okapi
del BaseAnimalTests
Yes, it works and it reduces duplication. But it comes at the cost of everything else that makes tests maintainable.
The Problems
IDE and DX Pain
When a test fails, I want to jump to it in my IDE, set a breakpoint, and debug. With this pattern - good luck.
The method doesn't exist in ArmadilloTests
, it's buried in a deleted parent class. You have to manually hunt it down, re-declare the test method just to put a breakpoint and debug it, and pray the animal_class
setup matches what failed:
class ArmadilloTests(TestCase):
animal_class = Armadillo
def test_speak(self):
super().test_speak()
It's tedious and wastes time. All this to avoid writing a 3-line test twice?
class ArmadilloTests(TestCase):
def test_speak(self):
sound = Armadillo().speak()
self.assertIsInstance(sound, str)
self.assertGreater(len(sound), 0)
Clear, simple, debug-friendly. Worth the few extra lines.
CI Failures Are Confusing
If a shared test fails in CI, you get something like:
test_speak (tests.ArmadilloTests.test_speak) ... FAIL
...
Traceback (most recent call last):
File ".../tests.py", line 20, in test_speak
self.assertGreater(len(sound), 0)
AssertionError: 0 not greater than 0
But the method isn't defined in ArmadilloTests
, and Search everywhere won't help at all:
So now you have to reverse-engineer which base class it came from and how to recreate it locally.
This isn't clever. It's just fragile.
When It Kinda Makes Sense
There are rare cases:
- dozens of classes implementing the same interface
- you're the only one maintaining the codebase
- you run everything headless in CI
But even then, you're building test framework plumbing to save what, a hundred lines?
The Clean Alternative: Parametrize It
Pytest Style
@pytest.mark.parametrize('animal_class', [Armadillo, Okapi])
def test_speak(animal_class):
sound = animal_class().speak()
assert isinstance(sound, str)
assert len(sound) > 0
You see all the parameters. You see where the test lives. Failures are explicit:
test_speak[Armadillo] FAILED
test_speak[Okapi] PASSED
You can re-run just the failing test. You can debug with a conditional breakpoint. You don't need to explain how the tests are wired together - because they're not.
unittest Style (Optional, Not Ideal)
from parameterized import parameterized_class
@parameterized_class([
{'animal_class': Armadillo},
{'animal_class': Okapi},
], class_name_func=get_class_name)
class AnimalTests(TestCase):
def test_speak(self):
sound = self.animal_class().speak()
self.assertIsInstance(sound, str)
self.assertGreater(len(sound), 0)
Using parameterized_class
from parameterized is still better than inheritance, but clunkier. Output is readable if you customize class_name_func
. IDE support isn't great. Pytest remains the better option for anything dynamic.
Final Verdict
Tests should fail clearly, debug easily, and be readable years later. This pattern fails all three.
DRY is good. But in tests, visible duplication beats invisible abstraction.
Adam's trick technically works, but in practice, it makes tests harder to navigate, harder to trust, and harder to work with.
Stick to the boring version - you'll thank yourself later.
Why Django's override_settings Sometimes Fails (and How reload + patch Saved Me)
Sometimes @override_settings
just doesn’t cut it.
I ran into a nasty issue while testing a Django module that relies on global state initialized during import. The usual test approach didn’t work. Here’s what happened and how I solved it.
The Setup
We had a module that builds a global dictionary from Django settings at import time. Let’s call it dragon.py
, which takes settings.PUT_EGGS
, which is False
by default:
from django.conf import settings
DRAGON = {}
...
if settings.PUT_EGGS:
DRAGON["eggs"] = "spam"
Another module uses DRAGON
for core logic, e.g. mario.py
:
from myproject.dragon import DRAGON
def find_eggs():
if "eggs" in DRAGON:
return "Found eggs!"
return "Eggs not found"
Now I wanted to write a test that tweaks DRAGON
and expects the logic to behave differently. Easy, right?
@override_settings(PUT_EGGS=True)
def test_find_eggs():
assert find_eggs() == "Found eggs!"
Wrong. The test failed.
The Problem
override_settings
works, but only for code that reads settings at runtime.
In my case, DRAGON
was already built at import time , before the override kicked in. So it used the old value of PUT_EGGS
, no matter what I did in the test.
This is the classic trap of global state baked during import. Welcome to pain town.
The Fix: reload + patch
Here's how I got out:
import importlib
from django.test import override_settings
from unittest.mock import patch
from myproject.mario import find_eggs
@override_settings(PUT_EGGS=True)
def test_find_eggs():
# Reload the dragon module so DRAGON is rebuilt
# with updated settings
from myproject import dragon
new_dragon = importlib.reload(dragon)
# Patch the logic module to use the reloaded DRAGON
with patch('myproject.mario.DRAGON', new_dragon.DRAGON):
result = find_eggs()
assert result == "Found eggs!"
Why This Works
importlib.reload(dragon)
forces a fresh import ofdragon
, rebuildingDRAGON
with the overridden settings;dragon.DRAGON
is updated in the scope of the test only, i.e.mario
module still has the stale version ofDRAGON;
patch(...)
solves this problem by swapping the oldDRAGON
inmario
with the freshly rebuilt one.
This is surgical. Ugly, but effective.
Lessons Learned
- Avoid putting non-trivial logic at module scope, especially if it depends on Django settings. Wrap it in a function or lazy loader.
- If you're stuck with global state,
reload()
andpatch()
give you a way out - just be careful about cascading dependencies.
If you’ve ever had a test mysteriously fail after overriding settings, this might be why.