Tests#
Tests live under netbox_aci_plugin/tests/, organized by layer:
tests/
models/ base.py + <domain>/test_<model>.py
forms/ base.py + <domain>/test_<model>.py
views/ base.py + <domain>/test_<model>.py
filtersets/ <domain>/test_<model>.py
tables/ base.py + <domain>/test_<model>.py
api/ <domain>/test_<model>.py
graphql/ base.py + test_<topic>.py
search/ <domain>/test_<model>.py
ui/ base.py + test_actions.py + test_conventions.py
+ <domain>/test_<concern>.py
Run the suite with NetBox's Django test runner (the plugin must be installed editable into NetBox's virtualenv first):
cd "$NETBOX_ROOT/netbox"
python manage.py test netbox_aci_plugin --keepdb
Drop --keepdb when model fields, migrations, or schema changed. See
the contributing guide for the full setup. This doc
covers conventions for writing tests, not running them.
setUpTestData, not setUp#
Shared fixtures use setUpTestData() (a classmethod) so they're
created once per class rather than once per test method. Reserve
setUp() for per-test state (e.g. logging in a client):
@classmethod
def setUpTestData(cls):
cls.aci_fabric = ACIFabric.objects.create(
name="TestFabric",
fabric_id=101,
infra_vlan_vid=3900,
)
Per-layer base classes#
Each layer has a base class holding its shared fixtures and helpers. Every test module subclasses the base for its layer.
tests/models/base.py:ACIBaseTestCase#
Builds the full ACI hierarchy through ACIAppProfile (Fabric, Pod,
Node, Tenant, VRF, BD, AppProfile), ACI VLAN pools and ranges, plus
the universal NetBox objects (Tenant, Site, Manufacturer, DeviceType,
DeviceRole, VRF, IPAddress, MACAddress, Prefix, among others). Use it
for model and manager tests.
tests/forms/base.py:ACIBaseFormTestCase#
Same hierarchy as the model base, plus two class attributes carrying the exact validator error strings, so form tests can assert against them without duplicating literals:
class ACIBaseFormTestCase(TestCase):
name_error_message: str = (
"Only alphanumeric characters, periods, underscores, colons and "
"hyphens are allowed."
)
description_error_message: str = (
"Only alphanumeric characters and !#$%()*,-./:;@ _{|}~?&+ are allowed."
)
tests/views/base.py:ACIModelViewTestCase#
Extends NetBox's ModelViewTestCase - authentication is inherited
with no user creation, setUp, or force_login needed. It overrides
_get_base_url() to prefix the plugins: namespace required by
plugin views, then seeds a NetBox nb_tenant plus the shared ACI
Fabric / Tenant / VRF / BD / AppProfile chain in setUpTestData:
class ACIModelViewTestCase(ModelViewTestCase):
def _get_base_url(self):
return "plugins:{}:{}_{{}}".format(
self.model._meta.app_label,
self.model._meta.model_name,
)
@classmethod
def setUpTestData(cls) -> None:
cls.nb_tenant = Tenant.objects.create(
name="ACIBaseViewTestNBTenant",
slug="acibaseviewtestnbtenant",
)
cls.aci_fabric = ACIFabric.objects.create(
name="ACIBaseViewTestFabric",
fabric_id=150,
infra_vlan_vid=3900,
)
cls.aci_tenant = ACITenant.objects.create(
name="ACIBaseViewTestTenant",
aci_fabric=cls.aci_fabric,
)
cls.aci_vrf = ACIVRF.objects.create(
name="ACIBaseViewTestVRF",
aci_tenant=cls.aci_tenant,
)
cls.aci_bd = ACIBridgeDomain.objects.create(
name="ACIBaseViewTestBD",
aci_tenant=cls.aci_tenant,
aci_vrf=cls.aci_vrf,
)
cls.aci_app_profile = ACIAppProfile.objects.create(
name="ACIBaseViewTestAppProfile",
aci_tenant=cls.aci_tenant,
)
tests/graphql/base.py:ACIBaseGraphQLTestCase#
Extends APITestCase (with LOGIN_REQUIRED=True) and seeds shared
Fabric / Tenant / VRF objects. Provides a query() helper that POSTs
to the GraphQL endpoint and asserts HTTP 200:
def query(self, query_str: str) -> dict:
"""POST a GraphQL query and return the parsed JSON body."""
response = self.client.post(
reverse("graphql"),
data={"query": query_str},
format="json",
**self.header,
)
self.assertEqual(response.status_code, 200, response.content)
return response.json()
tests/ui/base.py:ACIBaseUITestCase#
Builds a {request, object, perms} context dict through
get_context(), so a panel or action can be rendered without a full
view round trip. It also exports the registry walks the per-domain
pinning tests assert completeness against, all_object_views(),
layout_views() and layout_panels().
Each domain reproduces the same module shape under tests/ui/<domain>/:
| Module | Pins |
|---|---|
test_layouts.py |
which panels a view renders, in which column and order |
test_panel_order.py |
each panel's attribute rows, by name and accessor |
test_panels.py |
the non-trivial attrs, such as GFK rows and should_render |
test_breadcrumbs.py |
the list view and filter parameter each crumb links to |
test_child_tables.py |
ObjectsTablePanel model, title and filter keys |
test_context_tables.py |
ContextTablePanel context key and heading |
The last two are per-domain by construction: a domain declares only the table panel kind its detail pages use. Every pinning dictionary carries a completeness test deriving the live set, because a dictionary that iterates its own keys cannot notice something it never listed.
tests/ui/test_actions.py and test_conventions.py sit above the
domains, covering ACIObjectLinkAction and the rules that hold for
every ported view.
tests/tables/base.py aliases TableTestCases.StandardTableTestCase
for use as a smoke-test base.
Runtime convention tests#
Some rules hold across a whole layer rather than for one model, and
grepping the source cannot enforce them: a rule about Meta.fields
ordering, or about which columns carry an explicit header, is a
statement about resolved classes, not about text. Those live in
dedicated modules that walk the layer at import time and assert the
rule once for every class they find.
The layer walk is the point. A per-model test only guards the model whose author remembered to write it, whereas a walk fails the moment a new class breaks the rule, including one added months later by someone who never read this page.
Four of these exist today:
tests/api/test_conventions.py: every API viewset'sselect_relatedmatches its serializer's closure and stays under the join cap.tests/forms/test_conventions.py: bulk-edit fieldsets omit the auto-rendered sections, every fieldset entry names a real field, anddescriptionfollows the ACI foreign keys.tests/forms/test_choice_field_conventions.py: edit forms cover everyChoiceSet-backed field, and those fields stay required with no blank option.tests/tables/test_name_column_headers.py: name columns carry an explicit short header, and no column carries an unwarrantedACIprefix unless it collides with a NetBox core name.
Write them as SimpleTestCase, since they touch no database. Collect
the classes with pkgutil.walk_packages over the layer package rather
than importing a hand-written list, which would reintroduce exactly the
gap the walk closes. Accumulate every offender and assert once at the
end, so a failure names all of them instead of stopping at the first.
Where a known violation is not yet resolved, park it in a module-level
frozenset with a comment saying what has to be decided, as
PENDING_FIELDSET_ENTRIES does. That keeps the guard green without
losing the finding.
Module docstrings#
Most layers carry a short top-level docstring naming the domain and
layer, e.g. """API tests for access-policy VLAN pool models.""" or
"""Table tests for tenant L3Out models.""". FilterSet and view test
modules do so without exception, and API and table modules very nearly
so.
Model and form test modules mostly omit one, but the split is not clean: a handful carry a docstring anyway, generally because they were anchored on a module from another layer. Follow the layer you are writing in, and prefer adding a docstring to removing one when the surrounding modules disagree.
Model-test method names#
Use this naming pattern so test output stays scannable across the plugin:
Use these method names:
test_<model>_instance: object created with the expected type.test_<model>_str:__str__()returns the expected representation.test_<model>_<field>: field value is what was assigned.test_<model>_<related>_instance: FK / reverse-FK target is the right type.test_invalid_<model>_<field>: invalid value raisesValidationError.test_invalid_<model>_<field>_length: length-constraint violation raisesValidationError.
Example slice from test_app_profiles.py:
def test_aci_app_profile_instance(self) -> None: ...
def test_aci_app_profile_str(self) -> None: ...
def test_aci_app_profile_alias(self) -> None: ...
def test_aci_app_profile_description(self) -> None: ...
def test_aci_app_profile_aci_tenant_instance(self) -> None: ...
def test_invalid_aci_app_profile_name(self) -> None: ...
def test_invalid_aci_app_profile_name_length(self) -> None: ...
Form-test assertions#
Form tests instantiate the form, then assert on form.errors:
- Valid data:
self.assertEqual(form.errors.get("<field>"), None). - Invalid data: assert the error message matches the class-level
name_error_message/description_error_messageconstants. Don't duplicate the error string literal in each test.
FilterSet tests#
Every FilterSet test class declares queryset and filterset class
attributes, then exercises one method per filter plus two
search-related methods that everyone must include:
test_q(): search-term hits expected objects.test_search_with_whitespace_only_returns_all(): guards the whitespace-only no-op branch ofsearch()(see FilterSets - search()).
Assertion pattern:
self.assertIn(obj, self.filterset(params, self.queryset).qs)
API tests#
API tests use NetBox's utilities.testing.APIViewTestCases mixin
suite, which delivers get/list/create/update/delete coverage for free.
Each <Model>APIViewTestCase declares:
Required attributes:
model: concrete model class under test.view_namespace:f"plugins-api:{app_name}".brief_fields: fields expected in the?brief=1response. This must match the serializer'sMeta.brief_fields.user_permissions: extra permissions beyond the auto-granted model permissions.
setUpTestData() uses bulk_create() for fixtures. Two class-level
dicts drive the create/update assertions:
cls.create_data: list of payloads forPOST /<resource>/.cls.bulk_update_data: payload forPATCH /<resource>/(the fields you're updating, not the IDs).
class ACIAppProfileAPIViewTestCase(APIViewTestCases.APIViewTestCase):
model = ACIAppProfile
view_namespace: str = f"plugins-api:{app_name}"
brief_fields: list[str] = [
"aci_tenant",
"description",
"display",
"id",
"name",
"name_alias",
"nb_tenant",
"url",
]
user_permissions = ("netbox_aci_plugin.view_acitenant",)
NetBox test mixins
Prefer NetBox's testing mixins whenever they fit: use
utilities.testing.ViewTestCases.* for UI view permission checks,
ChangeLoggedFilterSetTestMixin / BaseFilterSetTestMixin for
filtersets,
and TableTestCases.StandardTableTestCase for table smoke tests.
The plugin already uses these patterns in several layers; new tests
should extend that coverage rather than adding hand-rolled helpers.