Testing abstract models - django 2.2.4 / sqlite3 2.6.0
Asked Answered
P

2

3

I'm trying to test some simple abstract mixins using django 2.2.4/sqlite3 2.6.0/python 3.6.8.

Currently I'm having issues deleting a model from the test database using the schema editor.

I have the following test case:

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


class ModelMixinTestCase(TestCase):
    """
    Test Case for abstract mixin models.
    """
    mixin = None
    model = None

    @classmethod
    def setUpClass(cls) -> None:
        # Create a real model from the mixin
        cls.model = ModelBase(
            "__Test" + cls.mixin.__name__,
            (cls.mixin,),
            {'__module__': cls.mixin.__module__}
        )

        # Use schema_editor to create schema
        with connection.schema_editor() as editor:
            editor.create_model(cls.model)

        super().setUpClass()

    @classmethod
    def tearDownClass(cls) -> None:
        # Use schema_editor to delete schema
        with connection.schema_editor() as editor:
            editor.delete_model(cls.model)

        super().tearDownClass()

Which can be used like this:

class MyMixinTestCase(ModelMixinTestCase):
    mixin = MyMixin

    def test_true(self):
        self.assertTrue(True)

This does allow for a model to be created and tested. The problem is that within ModelMixinTestCase.tearDownClass, connection.schema_editor() is unable to disable constraint checking which is done in django.db.backends.sqlite3.base using:

    def disable_constraint_checking(self):
        with self.cursor() as cursor:
            cursor.execute('PRAGMA foreign_keys = OFF')
            # Foreign key constraints cannot be turned off while in a multi-
            # statement transaction. Fetch the current state of the pragma
            # to determine if constraints are effectively disabled.
            enabled = cursor.execute('PRAGMA foreign_keys').fetchone()[0]
        return not bool(enabled)

this leads to an exception in __enter__ of the DatabaseSchemaEditor in django.db.backends.sqlite3.schema:

    def __enter__(self):
        # Some SQLite schema alterations need foreign key constraints to be
        # disabled. Enforce it here for the duration of the schema edition.
        if not self.connection.disable_constraint_checking():
            raise NotSupportedError(
                'SQLite schema editor cannot be used while foreign key '
                'constraint checks are enabled. Make sure to disable them '
                'before entering a transaction.atomic() context because '
                'SQLite does not support disabling them in the middle of '
                'a multi-statement transaction.'
            )
        return super().__enter__()

So based on all this I'm assuming we are in an atomic context but I'm currently not sure what the cleanest way is to exit that context and delete the model.

Palmer answered 21/8, 2019 at 3:13 Comment(0)
P
3

Simple Solution

So after a little digging and testing it seems that it's best to let django's TestCase close the transaction normally and then remove the model from the test database. Essentially we just call the super().tearDownClass() first instead of last.

ModelMixinTestCase

Since it's a useful class I'll post the full class for others to copy/paste.

class ModelMixinTestCase(TestCase):
    """
    Test Case for abstract mixin models.
    Subclass and set cls.mixin to your desired mixin.
    access your model using cls.model.
    """
    mixin = None
    model = None

    @classmethod
    def setUpClass(cls) -> None:
        # Create a real model from the mixin
        cls.model = ModelBase(
            "__Test" + cls.mixin.__name__,
            (cls.mixin,),
            {'__module__': cls.mixin.__module__}
        )

        # Use schema_editor to create schema
        with connection.schema_editor() as editor:
            editor.create_model(cls.model)

        super().setUpClass()

    @classmethod
    def tearDownClass(cls) -> None:
        # allow the transaction to exit
        super().tearDownClass()

        # Use schema_editor to delete schema
        with connection.schema_editor() as editor:
            editor.delete_model(cls.model)

        # close the connection
        connection.close()

Example Usage 1

class MyMixinTestCase(ModelMixinTestCase):
    mixin = MyMixin

    def test_true(self):
        self.assertTrue(True)

Example Usage 2

class SortableModelMixinTestCase(ModelMixinTestCase):
    mixin = SortableModelMixin

    def setUp(self) -> None:
        self.objects = [self.model.objects.create(pk=i) for i in range(10)]

    def test_order_linear(self) -> None:
        i = 1
        for item in self.objects:
            self.assertEqual(i, item.sortable_order)
            i += 1
Palmer answered 21/8, 2019 at 7:52 Comment(2)
You should also link to the original post from which this class came from.Succinct
I just want to clarify that the solution was based on reviewing the relevant source code and its possible for different solutions to converge based on that.Palmer
E
0

@bob Thanks for the good solution, this worked great.

But when reusing the mixin on a different TestCase with the same model I ran into a warning:

RuntimeWarning: Model '<model_name>' was already registered. Reloading models is not advised as it can lead to inconsistencies, most notably with related models.

I solved this by checking if the model was already registered in the app:

@classmethod
def setUpClass(cls) -> None:
    # Create a real model from the mixin or
    # get it from the reqistered models if it already exist
    model_name = "__Test" + cls.mixin.__name__
    for app, models in apps.all_models.items():
        if model_name.lower() in models:
            cls.model = models[model_name.lower()]
    if not hasattr(cls, "model"):
        cls.model = ModelBase(
            model_name, (cls.mixin,),
            {'__module__': cls.mixin.__module__}
        )
Eudy answered 14/9, 2023 at 11:48 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.