Skip to content

CRUD Testing with BaseCRUDTestCase

Status: Implemented Package: djadmin.testing Related: Milestone 4 - Developer Experience

Overview

BaseCRUDTestCase is a base test class that automatically tests all CRUD operations for a ModelAdmin by introspecting registered actions and running appropriate tests. It reduces test boilerplate by 80%+ while ensuring comprehensive coverage.

Quick Start

from djadmin.testing import BaseCRUDTestCase
from examples.webshop.factories import ProductFactory
from examples.webshop.models import Product


class TestProductAdmin(BaseCRUDTestCase):
    """Test ProductAdmin CRUD operations."""

    model = Product
    model_factory_class = ProductFactory

    # Optional: Fields to update in update test
    to_update_fields = {
        'name': 'Updated Product',
        'price': Decimal('199.99'),
    }

That's it! This automatically tests: - ✅ List view GET requests - ✅ Create view GET/POST requests - ✅ Update view GET/POST requests - ✅ Delete view POST requests

How It Works

1. Action Introspection

BaseCRUDTestCase automatically discovers all actions from your ModelAdmin:

def test_actions(self):
    """Main test entry point."""
    actions = self._get_all_actions()  # Introspects general, bulk, record actions
    test_methods_mapping = self._get_test_methods_mapping()  # Gets test methods from plugins

    for action in actions:
        # Check if action is instance of any mapped base class
        for base_class, methods_dict in test_methods_mapping.items():
            if isinstance(action, base_class):
                # Run all test methods for this base class
                for test_method_name, test_method_callable in methods_dict.items():
                    test_method_callable(self, action)

2. Plugin-Provided Test Methods

Test methods are provided by plugins via the djadmin_get_test_methods() hook. This allows plugins to customize test behavior for actions they modify.

Core plugin (djadmin/plugins/core/djadmin_hooks.py) provides default test methods:

@hookimpl
def djadmin_get_test_methods():
    """Provide default test methods for core CRUD actions."""
    return {
        ListActionMixin: {
            '_test_list': test_list,
        },
        CreateViewActionMixin: {
            '_test_create_get': test_create_get,
            '_test_create_post': test_create_post,
        },
        UpdateViewActionMixin: {
            '_test_update_get': test_update_get,
            '_test_update_post': test_update_post,
        },
        DeleteViewActionMixin: {
            '_test_delete': test_delete,
        },
    }

djadmin-formset plugin can override POST methods to handle FormCollection data:

@hookimpl
def djadmin_get_test_methods():
    """Provide FormCollection-specific test methods."""
    def test_formset_create_post(test_case, action):
        url = test_case._get_action_url(action)
        # Build hierarchical JSON for FormCollection
        data = build_formset_post_data(test_case.get_create_data())
        response = test_case.client.post(url, data, content_type='application/json')
        # ... assertions

    return {
        CreateViewActionMixin: {
            '_test_create_post': test_formset_create_post,  # Override default
        },
        UpdateViewActionMixin: {
            '_test_update_post': test_formset_update_post,  # Override default
        },
    }

Configuration

Required Attributes

  • model: The Django model class to test
  • model_factory_class: The FactoryBoy factory class for creating test data

Optional Attributes

class TestProductAdmin(BaseCRUDTestCase):
    model = Product
    model_factory_class = ProductFactory

    # Custom factory kwargs for default object creation
    factory_default_kwargs = {'status': 'active'}

    # Custom factory kwargs for delete test (creates separate object)
    factory_delete_kwargs = {'status': 'draft'}

    # Fields to update in update test
    to_update_fields = {
        'name': 'Updated Product',
        'price': Decimal('199.99'),
    }

    # Custom admin site (defaults to djadmin.site)
    admin_site = custom_site

Customization Hooks

Override these methods to add custom assertions or behavior:

class TestProductAdmin(BaseCRUDTestCase):
    model = Product
    model_factory_class = ProductFactory

    def assert_create_successful(self, response, data):
        """Additional assertions after create."""
        created_product = Product.objects.latest('id')
        assert created_product.sku is not None
        assert created_product.category is not None

    def assert_update_successful(self, response, obj, data):
        """Additional assertions after update."""
        obj.refresh_from_db()
        assert obj.updated_at > obj.created_at

    def assert_delete_successful(self, response, obj):
        """Additional assertions after delete."""
        # Verify related objects were also deleted (cascade)
        assert not OrderItem.objects.filter(product=obj).exists()

Data Conversion

BaseCRUDTestCase automatically converts model instances to form-compatible data:

def obj_to_dict(self, obj):
    """Convert model instance to POST data."""
    data = model_to_dict(obj, exclude=['id'])

    # Automatic conversions:
    # - None → '' (empty string)
    # - Decimal → str
    # - ManyToMany → list of PKs

    return data

Override get_create_data() or get_update_data() for custom data generation:

class TestProductAdmin(BaseCRUDTestCase):
    model = Product
    model_factory_class = ProductFactory

    def get_create_data(self):
        """Custom create data with computed fields."""
        data = super().get_create_data()
        data['sku'] = f"SKU-{random.randint(1000, 9999)}"
        return data

Testing with Custom Admin Sites

from djadmin import AdminSite

custom_site = AdminSite(name='custom_admin')
custom_site.register(Product, ProductAdmin)


class TestCustomSiteAdmin(BaseCRUDTestCase):
    model = Product
    model_factory_class = ProductFactory
    admin_site = custom_site  # Use custom site

CRITICAL: Never hardcode namespaces! Always use admin_site.reverse():

# ❌ BAD - Hardcoded namespace
url = reverse('djadmin:webshop_product_list')

# ✅ GOOD - Uses admin site's namespace
url = self.admin_site.reverse('webshop_product_list')

Architecture

Test Discovery

BaseCRUDTestCase uses __init_subclass__ to automatically enable test discovery for concrete implementations:

def __init_subclass__(cls, **kwargs):
    """Enable test discovery for subclasses with model and factory set."""
    super().__init_subclass__(**kwargs)
    cls.__test__ = cls.model is not None and cls.model_factory_class is not None

The base class has __test__ = False, so pytest doesn't try to run it. Subclasses automatically get __test__ = True when they set model and model_factory_class.

Plugin Integration

Test methods are retrieved from plugins at test time:

  1. test_actions() calls _get_test_methods_mapping()
  2. _get_test_methods_mapping() calls pm.hook.djadmin_get_test_methods()
  3. Plugins return mappings of action base classes to test methods
  4. Modifiers (Remove, Replace) are processed to customize test methods
  5. Later plugins override earlier ones (plugin priority)
  6. Test methods are called with (test_case, action) signature

Test Method Modifiers

Plugins can use modifiers to customize test methods from other plugins:

Remove Modifier

Remove a test method that doesn't apply to your plugin:

from djadmin.plugins import hookimpl
from djadmin.plugins.modifiers import Remove
from djadmin.actions.view_mixins import UpdateViewActionMixin


@hookimpl
def djadmin_get_test_methods():
    """Remove a test that doesn't work with our custom forms."""
    return {
        UpdateViewActionMixin: {
            # Remove the standard POST test
            '_test_update_post': Remove(UpdateViewActionMixin, '_test_update_post')
        }
    }

Replace Modifier

Replace a test method with a custom implementation:

from djadmin.plugins import hookimpl
from djadmin.plugins.modifiers import Replace
from djadmin.actions.view_mixins import CreateViewActionMixin


@hookimpl
def djadmin_get_test_methods():
    """Replace core's POST test with FormCollection-specific version."""

    def test_formset_create_post(test_case, action):
        """Custom test for FormCollection POST data."""
        # Build hierarchical JSON instead of form data
        data = build_formset_post_data(test_case.get_create_data())
        url = test_case._get_action_url(action)
        response = test_case.client.post(url, data, content_type='application/json')

        # Verify success
        assert response.status_code in [200, 302]
        assert test_case.model.objects.count() > 0

    return {
        CreateViewActionMixin: {
            # Replace core's test method
            '_test_create_post': Replace(
                CreateViewActionMixin,
                '_test_create_post',
                test_formset_create_post
            )
        }
    }

When to Use Modifiers

  • Remove: When a test method doesn't apply to your plugin's behavior
  • Example: Standard form POST tests don't work with django-formset
  • Replace: When you need custom test logic for your plugin
  • Example: FormCollection requires JSON POST data instead of form data
  • Direct assignment: When adding new test methods (no modifier needed)

Benefits

80%+ Boilerplate Reduction

Without BaseCRUDTestCase (~200 lines):

class TestProductAdmin(TestCase):
    def setUp(self):
        self.product = ProductFactory.create()

    def test_list_view(self):
        url = reverse('djadmin:webshop_product_list')
        response = self.client.get(url)
        self.assertEqual(response.status_code, 200)
        self.assertIn(self.product, response.context['object_list'])

    def test_create_view_get(self):
        url = reverse('djadmin:webshop_product_create')
        response = self.client.get(url)
        self.assertEqual(response.status_code, 200)
        self.assertIn('form', response.context)

    # ... 20+ more lines for create POST
    # ... 20+ more lines for update GET
    # ... 20+ more lines for update POST
    # ... 20+ more lines for delete

With BaseCRUDTestCase (~10 lines):

class TestProductAdmin(BaseCRUDTestCase):
    model = Product
    model_factory_class = ProductFactory
    to_update_fields = {'name': 'Updated', 'price': Decimal('99.99')}

Comprehensive Coverage

  • Tests all CRUD operations automatically
  • Tests all actions (not just hardcoded URLs)
  • Works with custom admin sites
  • Handles complex form data conversion

Plugin-Aware

  • Plugins can provide custom test methods
  • djadmin-formset can test FormCollection forms correctly
  • Future plugins can add their own test behavior

Examples

Basic Usage

from djadmin.testing import BaseCRUDTestCase
from examples.webshop.factories import CategoryFactory
from examples.webshop.models import Category


class TestCategoryAdmin(BaseCRUDTestCase):
    model = Category
    model_factory_class = CategoryFactory
    to_update_fields = {'name': 'Updated Category'}

With Custom Assertions

class TestProductAdmin(BaseCRUDTestCase):
    model = Product
    model_factory_class = ProductFactory
    to_update_fields = {'name': 'Updated', 'price': Decimal('99.99')}

    def assert_create_successful(self, response, data):
        """Verify SKU was auto-generated."""
        product = Product.objects.latest('id')
        assert product.sku is not None
        assert product.sku.startswith('SKU-')

With Dynamic URLConf (for testing with multiple admin registrations)

from django.test import override_settings
from django.urls import clear_url_caches


class DynamicURLConf:
    """URLconf that regenerates admin URLs on each access."""
    @property
    def urlpatterns(self):
        from django.urls import path, include
        return [path('djadmin/', include(site.urls))]


@override_settings(ROOT_URLCONF=DynamicURLConf())
class TestProductAdmin(BaseCRUDTestCase):
    model = Product
    model_factory_class = ProductFactory

    def setUp(self):
        # Clear registry before each test
        if Product in site._registry:
            site.unregister(Product)

        # Register with specific configuration
        class ProductAdmin(ModelAdmin):
            list_display = ['name', 'sku', 'price']

        site.register(Product, ProductAdmin, override=True)
        super().setUp()

    def tearDown(self):
        # Clean up
        if Product in site._registry:
            site.unregister(Product)
        clear_url_caches()
        if hasattr(self.client, '_cached_urlconf'):
            delattr(self.client, '_cached_urlconf')

Troubleshooting

"model attribute must be set"

Set both model and model_factory_class on your test class:

class TestProductAdmin(BaseCRUDTestCase):
    model = Product  # Add this
    model_factory_class = ProductFactory  # Add this

"No reverse match"

Make sure your model is registered with the admin site before tests run:

def setUp(self):
    site.register(Product, ProductAdmin, override=True)
    super().setUp()

"422 Unprocessable Entity" for create/update POST

This occurs when using django-formset because it requires hierarchical JSON POST data instead of standard form data.

Solution: The djadmin-formset plugin provides custom test methods using Replace() modifiers:

# See: djadmin-formset/djadmin_formset/djadmin_hooks.py
@hookimpl
def djadmin_get_test_methods():
    """Override core test methods for FormCollection."""
    return {
        CreateViewActionMixin: {
            '_test_create_post': Replace(
                CreateViewActionMixin,
                '_test_create_post',
                test_formset_create_post  # Handles JSON POST data
            ),
        },
        UpdateViewActionMixin: {
            '_test_update_get': Replace(...),  # Checks form_collection in context
            '_test_update_post': Replace(...),  # Handles JSON POST data
        },
    }

The formset plugin uses utility functions to build hierarchical JSON: - build_create_post_data(model_admin, **field_values) - For CREATE operations - build_update_post_data(model_admin, instance, **field_updates) - For UPDATE operations

Both wrap data in {'formset_data': {...}} as expected by FormCollection.