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 testmodel_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:
test_actions()calls_get_test_methods_mapping()_get_test_methods_mapping()callspm.hook.djadmin_get_test_methods()- Plugins return mappings of action base classes to test methods
- Modifiers (Remove, Replace) are processed to customize test methods
- Later plugins override earlier ones (plugin priority)
- 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:
"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.
Related Documentation¶
- djadmin_inspect Command - Introspection tool for debugging
- Plugin API - How to create plugins
- Testing with Factories - FactoryBoy examples