test(tables): Add reusable OrderableColumnsTestCase

Introduce `TableTestCases.OrderableColumnsTestCase`, a shared base class
that automatically discovers sortable columns from list-view querysets
and verifies each renders without exceptions in both ascending and
descending order.

Add per-table smoke tests across circuits, core, dcim, extras, ipam,
tenancy, users, virtualization, vpn, and wireless apps.

Fixes #21766
This commit is contained in:
Martin Hauser
2026-04-03 15:01:57 +02:00
parent f058ee3d60
commit 209c60ea6e
12 changed files with 689 additions and 103 deletions

View File

@@ -1,48 +1,46 @@
from django.test import RequestFactory, TestCase, tag from circuits.tables import *
from utilities.testing import TableTestCases
from circuits.models import CircuitGroupAssignment, CircuitTermination
from circuits.tables import CircuitGroupAssignmentTable, CircuitTerminationTable
@tag('regression') class CircuitTypeTableTest(TableTestCases.OrderableColumnsTestCase):
class CircuitTerminationTableTest(TestCase): table = CircuitTypeTable
def test_every_orderable_field_does_not_throw_exception(self):
terminations = CircuitTermination.objects.all()
disallowed = {
'actions',
}
orderable_columns = [
column.name
for column in CircuitTerminationTable(terminations).columns
if column.orderable and column.name not in disallowed
]
fake_request = RequestFactory().get('/')
for col in orderable_columns:
for direction in ('-', ''):
table = CircuitTerminationTable(terminations)
table.order_by = f'{direction}{col}'
table.as_html(fake_request)
@tag('regression') class CircuitTableTest(TableTestCases.OrderableColumnsTestCase):
class CircuitGroupAssignmentTableTest(TestCase): table = CircuitTable
def test_every_orderable_field_does_not_throw_exception(self):
assignment = CircuitGroupAssignment.objects.all()
disallowed = {
'actions',
}
orderable_columns = [
column.name
for column in CircuitGroupAssignmentTable(assignment).columns
if column.orderable and column.name not in disallowed
]
fake_request = RequestFactory().get('/')
for col in orderable_columns: class CircuitTerminationTableTest(TableTestCases.OrderableColumnsTestCase):
for direction in ('-', ''): table = CircuitTerminationTable
table = CircuitGroupAssignmentTable(assignment)
table.order_by = f'{direction}{col}'
table.as_html(fake_request) class CircuitGroupTableTest(TableTestCases.OrderableColumnsTestCase):
table = CircuitGroupTable
class CircuitGroupAssignmentTableTest(TableTestCases.OrderableColumnsTestCase):
table = CircuitGroupAssignmentTable
class ProviderTableTest(TableTestCases.OrderableColumnsTestCase):
table = ProviderTable
class ProviderAccountTableTest(TableTestCases.OrderableColumnsTestCase):
table = ProviderAccountTable
class ProviderNetworkTableTest(TableTestCases.OrderableColumnsTestCase):
table = ProviderNetworkTable
class VirtualCircuitTypeTableTest(TableTestCases.OrderableColumnsTestCase):
table = VirtualCircuitTypeTable
class VirtualCircuitTableTest(TableTestCases.OrderableColumnsTestCase):
table = VirtualCircuitTable
class VirtualCircuitTerminationTableTest(TableTestCases.OrderableColumnsTestCase):
table = VirtualCircuitTerminationTable

View File

@@ -0,0 +1,26 @@
from core.models import ObjectChange
from core.tables import *
from utilities.testing import TableTestCases
class DataSourceTableTest(TableTestCases.OrderableColumnsTestCase):
table = DataSourceTable
class DataFileTableTest(TableTestCases.OrderableColumnsTestCase):
table = DataFileTable
class JobTableTest(TableTestCases.OrderableColumnsTestCase):
table = JobTable
class ObjectChangeTableTest(TableTestCases.OrderableColumnsTestCase):
table = ObjectChangeTable
queryset_sources = [
('ObjectChangeListView', ObjectChange.objects.valid_models()),
]
class ConfigRevisionTableTest(TableTestCases.OrderableColumnsTestCase):
table = ConfigRevisionTable

View File

@@ -0,0 +1,204 @@
from dcim.models import ConsolePort, Interface, PowerPort
from dcim.tables import *
from utilities.testing import TableTestCases
#
# Sites
#
class RegionTableTest(TableTestCases.OrderableColumnsTestCase):
table = RegionTable
class SiteGroupTableTest(TableTestCases.OrderableColumnsTestCase):
table = SiteGroupTable
class SiteTableTest(TableTestCases.OrderableColumnsTestCase):
table = SiteTable
class LocationTableTest(TableTestCases.OrderableColumnsTestCase):
table = LocationTable
#
# Racks
#
class RackRoleTableTest(TableTestCases.OrderableColumnsTestCase):
table = RackRoleTable
class RackTypeTableTest(TableTestCases.OrderableColumnsTestCase):
table = RackTypeTable
class RackTableTest(TableTestCases.OrderableColumnsTestCase):
table = RackTable
class RackReservationTableTest(TableTestCases.OrderableColumnsTestCase):
table = RackReservationTable
#
# Device types
#
class ManufacturerTableTest(TableTestCases.OrderableColumnsTestCase):
table = ManufacturerTable
class DeviceTypeTableTest(TableTestCases.OrderableColumnsTestCase):
table = DeviceTypeTable
#
# Module types
#
class ModuleTypeProfileTableTest(TableTestCases.OrderableColumnsTestCase):
table = ModuleTypeProfileTable
class ModuleTypeTableTest(TableTestCases.OrderableColumnsTestCase):
table = ModuleTypeTable
class ModuleTableTest(TableTestCases.OrderableColumnsTestCase):
table = ModuleTable
#
# Devices
#
class DeviceRoleTableTest(TableTestCases.OrderableColumnsTestCase):
table = DeviceRoleTable
class PlatformTableTest(TableTestCases.OrderableColumnsTestCase):
table = PlatformTable
class DeviceTableTest(TableTestCases.OrderableColumnsTestCase):
table = DeviceTable
#
# Device components
#
class ConsolePortTableTest(TableTestCases.OrderableColumnsTestCase):
table = ConsolePortTable
class ConsoleServerPortTableTest(TableTestCases.OrderableColumnsTestCase):
table = ConsoleServerPortTable
class PowerPortTableTest(TableTestCases.OrderableColumnsTestCase):
table = PowerPortTable
class PowerOutletTableTest(TableTestCases.OrderableColumnsTestCase):
table = PowerOutletTable
class InterfaceTableTest(TableTestCases.OrderableColumnsTestCase):
table = InterfaceTable
class FrontPortTableTest(TableTestCases.OrderableColumnsTestCase):
table = FrontPortTable
class RearPortTableTest(TableTestCases.OrderableColumnsTestCase):
table = RearPortTable
class ModuleBayTableTest(TableTestCases.OrderableColumnsTestCase):
table = ModuleBayTable
class DeviceBayTableTest(TableTestCases.OrderableColumnsTestCase):
table = DeviceBayTable
class InventoryItemTableTest(TableTestCases.OrderableColumnsTestCase):
table = InventoryItemTable
class InventoryItemRoleTableTest(TableTestCases.OrderableColumnsTestCase):
table = InventoryItemRoleTable
#
# Connections
#
class ConsoleConnectionTableTest(TableTestCases.OrderableColumnsTestCase):
table = ConsoleConnectionTable
queryset_sources = [
('ConsoleConnectionsListView', ConsolePort.objects.filter(_path__is_complete=True)),
]
class PowerConnectionTableTest(TableTestCases.OrderableColumnsTestCase):
table = PowerConnectionTable
queryset_sources = [
('PowerConnectionsListView', PowerPort.objects.filter(_path__is_complete=True)),
]
class InterfaceConnectionTableTest(TableTestCases.OrderableColumnsTestCase):
table = InterfaceConnectionTable
queryset_sources = [
('InterfaceConnectionsListView', Interface.objects.filter(_path__is_complete=True)),
]
#
# Cables
#
class CableTableTest(TableTestCases.OrderableColumnsTestCase):
table = CableTable
#
# Power
#
class PowerPanelTableTest(TableTestCases.OrderableColumnsTestCase):
table = PowerPanelTable
class PowerFeedTableTest(TableTestCases.OrderableColumnsTestCase):
table = PowerFeedTable
#
# Virtual chassis
#
class VirtualChassisTableTest(TableTestCases.OrderableColumnsTestCase):
table = VirtualChassisTable
#
# Virtual device contexts
#
class VirtualDeviceContextTableTest(TableTestCases.OrderableColumnsTestCase):
table = VirtualDeviceContextTable
#
# MAC addresses
#
class MACAddressTableTest(TableTestCases.OrderableColumnsTestCase):
table = MACAddressTable

View File

@@ -1,24 +1,84 @@
from django.test import RequestFactory, TestCase, tag from extras.models import Bookmark, Notification, Subscription
from extras.tables import *
from extras.models import EventRule from utilities.testing import TableTestCases
from extras.tables import EventRuleTable
@tag('regression') class CustomFieldTableTest(TableTestCases.OrderableColumnsTestCase):
class EventRuleTableTest(TestCase): table = CustomFieldTable
def test_every_orderable_field_does_not_throw_exception(self):
rule = EventRule.objects.all()
disallowed = {
'actions',
}
orderable_columns = [
column.name for column in EventRuleTable(rule).columns if column.orderable and column.name not in disallowed
]
fake_request = RequestFactory().get('/')
for col in orderable_columns: class CustomFieldChoiceSetTableTest(TableTestCases.OrderableColumnsTestCase):
for direction in ('-', ''): table = CustomFieldChoiceSetTable
table = EventRuleTable(rule)
table.order_by = f'{direction}{col}'
table.as_html(fake_request) class CustomLinkTableTest(TableTestCases.OrderableColumnsTestCase):
table = CustomLinkTable
class ExportTemplateTableTest(TableTestCases.OrderableColumnsTestCase):
table = ExportTemplateTable
class SavedFilterTableTest(TableTestCases.OrderableColumnsTestCase):
table = SavedFilterTable
class TableConfigTableTest(TableTestCases.OrderableColumnsTestCase):
table = TableConfigTable
class BookmarkTableTest(TableTestCases.OrderableColumnsTestCase):
table = BookmarkTable
queryset_sources = [
('BookmarkListView', Bookmark.objects.all()),
]
class NotificationGroupTableTest(TableTestCases.OrderableColumnsTestCase):
table = NotificationGroupTable
class NotificationTableTest(TableTestCases.OrderableColumnsTestCase):
table = NotificationTable
queryset_sources = [
('NotificationListView', Notification.objects.all()),
]
class SubscriptionTableTest(TableTestCases.OrderableColumnsTestCase):
table = SubscriptionTable
queryset_sources = [
('SubscriptionListView', Subscription.objects.all()),
]
class WebhookTableTest(TableTestCases.OrderableColumnsTestCase):
table = WebhookTable
class EventRuleTableTest(TableTestCases.OrderableColumnsTestCase):
table = EventRuleTable
class TagTableTest(TableTestCases.OrderableColumnsTestCase):
table = TagTable
class ConfigContextProfileTableTest(TableTestCases.OrderableColumnsTestCase):
table = ConfigContextProfileTable
class ConfigContextTableTest(TableTestCases.OrderableColumnsTestCase):
table = ConfigContextTable
class ConfigTemplateTableTest(TableTestCases.OrderableColumnsTestCase):
table = ConfigTemplateTable
class ImageAttachmentTableTest(TableTestCases.OrderableColumnsTestCase):
table = ImageAttachmentTable
class JournalEntryTableTest(TableTestCases.OrderableColumnsTestCase):
table = JournalEntryTable

View File

@@ -1,9 +1,10 @@
from django.test import RequestFactory, TestCase from django.test import RequestFactory, TestCase
from netaddr import IPNetwork from netaddr import IPNetwork
from ipam.models import IPAddress, IPRange, Prefix from ipam.models import FHRPGroupAssignment, IPAddress, IPRange, Prefix
from ipam.tables import AnnotatedIPAddressTable from ipam.tables import *
from ipam.utils import annotate_ip_space from ipam.utils import annotate_ip_space
from utilities.testing import TableTestCases
class AnnotatedIPAddressTableTest(TestCase): class AnnotatedIPAddressTableTest(TestCase):
@@ -168,3 +169,82 @@ class AnnotatedIPAddressTableTest(TestCase):
# Pools are fully usable # Pools are fully usable
self.assertEqual(available.first_ip, '2001:db8:1::/126') self.assertEqual(available.first_ip, '2001:db8:1::/126')
self.assertEqual(available.size, 4) self.assertEqual(available.size, 4)
#
# Table ordering tests
#
class VRFTableTest(TableTestCases.OrderableColumnsTestCase):
table = VRFTable
class RouteTargetTableTest(TableTestCases.OrderableColumnsTestCase):
table = RouteTargetTable
class RIRTableTest(TableTestCases.OrderableColumnsTestCase):
table = RIRTable
class AggregateTableTest(TableTestCases.OrderableColumnsTestCase):
table = AggregateTable
class RoleTableTest(TableTestCases.OrderableColumnsTestCase):
table = RoleTable
class PrefixTableTest(TableTestCases.OrderableColumnsTestCase):
table = PrefixTable
class IPRangeTableTest(TableTestCases.OrderableColumnsTestCase):
table = IPRangeTable
class IPAddressTableTest(TableTestCases.OrderableColumnsTestCase):
table = IPAddressTable
class FHRPGroupTableTest(TableTestCases.OrderableColumnsTestCase):
table = FHRPGroupTable
class FHRPGroupAssignmentTableTest(TableTestCases.OrderableColumnsTestCase):
table = FHRPGroupAssignmentTable
queryset_sources = [
('FHRPGroupAssignmentTable', FHRPGroupAssignment.objects.all()),
]
class VLANGroupTableTest(TableTestCases.OrderableColumnsTestCase):
table = VLANGroupTable
class VLANTableTest(TableTestCases.OrderableColumnsTestCase):
table = VLANTable
class VLANTranslationPolicyTableTest(TableTestCases.OrderableColumnsTestCase):
table = VLANTranslationPolicyTable
class VLANTranslationRuleTableTest(TableTestCases.OrderableColumnsTestCase):
table = VLANTranslationRuleTable
class ASNRangeTableTest(TableTestCases.OrderableColumnsTestCase):
table = ASNRangeTable
class ASNTableTest(TableTestCases.OrderableColumnsTestCase):
table = ASNTable
class ServiceTemplateTableTest(TableTestCases.OrderableColumnsTestCase):
table = ServiceTemplateTable
class ServiceTableTest(TableTestCases.OrderableColumnsTestCase):
table = ServiceTable

View File

@@ -0,0 +1,26 @@
from tenancy.tables import *
from utilities.testing import TableTestCases
class TenantGroupTableTest(TableTestCases.OrderableColumnsTestCase):
table = TenantGroupTable
class TenantTableTest(TableTestCases.OrderableColumnsTestCase):
table = TenantTable
class ContactGroupTableTest(TableTestCases.OrderableColumnsTestCase):
table = ContactGroupTable
class ContactRoleTableTest(TableTestCases.OrderableColumnsTestCase):
table = ContactRoleTable
class ContactTableTest(TableTestCases.OrderableColumnsTestCase):
table = ContactTable
class ContactAssignmentTableTest(TableTestCases.OrderableColumnsTestCase):
table = ContactAssignmentTable

View File

@@ -1,24 +1,26 @@
from django.test import RequestFactory, TestCase, tag from users.tables import *
from utilities.testing import TableTestCases
from users.models import Token
from users.tables import TokenTable
class TokenTableTest(TestCase): class TokenTableTest(TableTestCases.OrderableColumnsTestCase):
@tag('regression') table = TokenTable
def test_every_orderable_field_does_not_throw_exception(self):
tokens = Token.objects.all()
disallowed = {'actions'}
orderable_columns = [
column.name for column in TokenTable(tokens).columns
if column.orderable and column.name not in disallowed
]
fake_request = RequestFactory().get("/")
for col in orderable_columns: class UserTableTest(TableTestCases.OrderableColumnsTestCase):
for direction in ('-', ''): table = UserTable
with self.subTest(col=col, direction=direction):
table = TokenTable(tokens)
table.order_by = f'{direction}{col}' class GroupTableTest(TableTestCases.OrderableColumnsTestCase):
table.as_html(fake_request) table = GroupTable
class ObjectPermissionTableTest(TableTestCases.OrderableColumnsTestCase):
table = ObjectPermissionTable
class OwnerGroupTableTest(TableTestCases.OrderableColumnsTestCase):
table = OwnerGroupTable
class OwnerTableTest(TableTestCases.OrderableColumnsTestCase):
table = OwnerTable

View File

@@ -1,5 +1,6 @@
from .api import * from .api import *
from .base import * from .base import *
from .filtersets import * from .filtersets import *
from .tables import *
from .utils import * from .utils import *
from .views import * from .views import *

View File

@@ -0,0 +1,130 @@
import inspect
from importlib import import_module
from django.test import RequestFactory
from netbox.views import generic
from .base import TestCase
__all__ = (
"ModelTableTestCase",
"TableTestCases",
)
class ModelTableTestCase(TestCase):
"""
Shared helpers for model-backed table tests.
Concrete subclasses should set `table` and may override `get_queryset()`
or `excluded_orderable_columns` as needed.
"""
table = None
excluded_orderable_columns = frozenset({"actions"})
# Optional explicit override for odd cases
queryset_sources = None
# Only these view types are considered sortable queryset sources by default
queryset_source_view_classes = (generic.ObjectListView,)
@classmethod
def validate_table_test_case(cls):
if cls.table is None:
raise AssertionError(f"{cls.__name__} must define `table`")
if getattr(cls.table._meta, "model", None) is None:
raise AssertionError(f"{cls.__name__}.table must be model-backed")
def get_request(self):
request = RequestFactory().get("/")
request.user = self.user
return request
def get_table(self, queryset):
return self.table(queryset)
@classmethod
def is_queryset_source_view(cls, view):
model = cls.table._meta.model
app_label = model._meta.app_label
return (
inspect.isclass(view)
and view.__module__.startswith(f"{app_label}.views")
and getattr(view, "table", None) is cls.table
and getattr(view, "queryset", None) is not None
and issubclass(view, cls.queryset_source_view_classes)
)
@classmethod
def get_queryset_sources(cls):
"""
Return iterable of (label, queryset) pairs to test.
By default, only discover list-style views that declare this table.
That keeps bulk edit/delete confirmation tables out of the ordering
smoke test.
"""
if cls.queryset_sources is not None:
return tuple(cls.queryset_sources)
model = cls.table._meta.model
app_label = model._meta.app_label
module = import_module(f"{app_label}.views")
sources = []
for _, view in inspect.getmembers(module, inspect.isclass):
if not cls.is_queryset_source_view(view):
continue
queryset = view.queryset
if hasattr(queryset, "all"):
queryset = queryset.all()
sources.append((view.__name__, queryset))
if not sources:
raise AssertionError(
f"{cls.__name__} could not find any list-style queryset source for "
f"{cls.table.__module__}.{cls.table.__name__}; "
"set `queryset_sources` explicitly if needed."
)
return tuple(sources)
def iter_orderable_columns(self, queryset):
for column in self.get_table(queryset).columns:
if not column.orderable:
continue
if column.name in self.excluded_orderable_columns:
continue
yield column.name
class TableTestCases:
"""
Keep test_* methods nested to avoid unittest auto-discovering the reusable
base classes directly.
"""
class OrderableColumnsTestCase(ModelTableTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.validate_table_test_case()
def test_every_orderable_column_renders(self):
request = self.get_request()
for source_name, queryset in self.get_queryset_sources():
for column_name in self.iter_orderable_columns(queryset):
for direction, prefix in (("asc", ""), ("desc", "-")):
with self.cleanupSubTest(
source=source_name,
column=column_name,
direction=direction,
):
table = self.get_table(queryset)
table.order_by = f"{prefix}{column_name}"
table.as_html(request)

View File

@@ -0,0 +1,26 @@
from utilities.testing import TableTestCases
from virtualization.tables import *
class ClusterTypeTableTest(TableTestCases.OrderableColumnsTestCase):
table = ClusterTypeTable
class ClusterGroupTableTest(TableTestCases.OrderableColumnsTestCase):
table = ClusterGroupTable
class ClusterTableTest(TableTestCases.OrderableColumnsTestCase):
table = ClusterTable
class VirtualMachineTableTest(TableTestCases.OrderableColumnsTestCase):
table = VirtualMachineTable
class VMInterfaceTableTest(TableTestCases.OrderableColumnsTestCase):
table = VMInterfaceTable
class VirtualDiskTableTest(TableTestCases.OrderableColumnsTestCase):
table = VirtualDiskTable

View File

@@ -1,23 +1,42 @@
from django.test import RequestFactory, TestCase, tag from utilities.testing import TableTestCases
from vpn.tables import *
from vpn.models import TunnelTermination
from vpn.tables import TunnelTerminationTable
@tag('regression') class TunnelGroupTableTest(TableTestCases.OrderableColumnsTestCase):
class TunnelTerminationTableTest(TestCase): table = TunnelGroupTable
def test_every_orderable_field_does_not_throw_exception(self):
terminations = TunnelTermination.objects.all()
fake_request = RequestFactory().get("/")
disallowed = {'actions'}
orderable_columns = [
column.name for column in TunnelTerminationTable(terminations).columns
if column.orderable and column.name not in disallowed
]
for col in orderable_columns: class TunnelTableTest(TableTestCases.OrderableColumnsTestCase):
for dir in ('-', ''): table = TunnelTable
table = TunnelTerminationTable(terminations)
table.order_by = f'{dir}{col}'
table.as_html(fake_request) class TunnelTerminationTableTest(TableTestCases.OrderableColumnsTestCase):
table = TunnelTerminationTable
class IKEProposalTableTest(TableTestCases.OrderableColumnsTestCase):
table = IKEProposalTable
class IKEPolicyTableTest(TableTestCases.OrderableColumnsTestCase):
table = IKEPolicyTable
class IPSecProposalTableTest(TableTestCases.OrderableColumnsTestCase):
table = IPSecProposalTable
class IPSecPolicyTableTest(TableTestCases.OrderableColumnsTestCase):
table = IPSecPolicyTable
class IPSecProfileTableTest(TableTestCases.OrderableColumnsTestCase):
table = IPSecProfileTable
class L2VPNTableTest(TableTestCases.OrderableColumnsTestCase):
table = L2VPNTable
class L2VPNTerminationTableTest(TableTestCases.OrderableColumnsTestCase):
table = L2VPNTerminationTable

View File

@@ -0,0 +1,14 @@
from utilities.testing import TableTestCases
from wireless.tables import *
class WirelessLANGroupTableTest(TableTestCases.OrderableColumnsTestCase):
table = WirelessLANGroupTable
class WirelessLANTableTest(TableTestCases.OrderableColumnsTestCase):
table = WirelessLANTable
class WirelessLinkTableTest(TableTestCases.OrderableColumnsTestCase):
table = WirelessLinkTable