Mohammed Hammoud

Mohammed Hammoud

Senior full-stack developer building scalable web and mobile products with a strong UX focus. TypeScript, React, SolidJS, Python, Django. Based in Stockholm.

2 min read

Testing Django abstract models without cluttering your migrations

Django abstract models are useful for shared behavior: soft delete, timestamps, audit fields. The problem: they don’t have tables, so you can’t test them with the ORM directly. You need a concrete model.

When I needed tests for a soft-deletion mixin, I solved it with a small custom TestCase that dynamically creates a temporary concrete model, creates its table with SchemaEditor, and drops it after the test class.

The pattern: a custom TestCase

from django.db import connection
from django.db.models.base import ModelBase
from django.test import TestCase


class AbstractModelMixinTestCase(TestCase):
    mixin = None
    model = None

    @classmethod
    def setUpClass(cls) -> None:
        cls.model = ModelBase(
            "TestModel" + cls.mixin.__name__,
            (cls.mixin,),
            {"__module__": cls.mixin.__module__},
        )

        with connection.schema_editor() as editor:
            editor.create_model(cls.model)

        super().setUpClass()

    @classmethod
    def tearDownClass(cls) -> None:
        super().tearDownClass()

        with connection.schema_editor() as editor:
            editor.delete_model(cls.model)

        connection.close()

Using it for a soft-deletion mixin

Point the base class at your abstract model (or mixin), then test behavior through the dynamically created model:

from common.tests.base import AbstractModelMixinTestCase

from myapp.models import SoftDeletionModel


class SoftDeletionModelTestCase(AbstractModelMixinTestCase):
    mixin = SoftDeletionModel

    def test_soft_delete_instance(self):
        instance = self.model.objects.create()
        instance.delete()
        self.assertIsNotNone(instance.deleted_at)

Notes

  • This relies on Django being able to infer the app label from __module__, so the mixin should live in an installed app.
  • If you hit doesn't declare an explicit app_label and isn't in an application in INSTALLED_APPS, set it explicitly (it must be the app label, not the module path):
from django.apps import apps

app_config = apps.get_containing_app_config(cls.mixin.__module__)
if app_config is None:
    raise RuntimeError("Mixin module is not in an installed app")


class Meta:
    app_label = app_config.label


cls.model = ModelBase(
    "TestModel" + cls.mixin.__name__,
    (cls.mixin,),
    {"__module__": cls.mixin.__module__, "Meta": Meta},
)

What to test

Test behavior, not internals. If your abstract model provides a manager that hides soft-deleted rows, verify that deleted rows don’t show up in .all().

The result is a test suite that treats your abstract model as a real dependency: realistic enough to catch real bugs, and no test-only migrations cluttering your history.