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
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#
Four base classes hold the shared fixtures and helpers for their layer. Each test module subclasses the layer's base.
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/tables/base.py aliases TableTestCases.StandardTableTestCase
for use as a smoke-test base.
Module docstrings#
API and table test modules 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.""". Model
and form test modules omit one.
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,
ChangeLoggedFilterSetTests / BaseFilterSetTests 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.