Repository: aristanetworks/anta (python)

Issue:
Add test case to verify SNMP counters
Trap PDUs counter should be a non-zero value

sample output:

leaf1-dc1#show snmp | json
{
    "counters": {
        "serviceEnabled": true,
        "inPkts": 0,
        "inVersionErrs": 0,
        "inBadCommunityNames": 0,
        "inBadCommunityUses": 0,
        "inParseErrs": 0,
        "inRequestVars": 0,
        "inSetVars": 0,
        "inGetPdus": 0,
        "inGetNextPdus": 0,
        "inSetPdus": 0,
        "outPkts": 26,
        "outTooBigErrs": 0,
        "outNoSuchNameErrs": 0,
        "outBadValueErrs": 0,
        "outGeneralErrs": 0,
        "outGetResponsePdus": 0,
        "outTrapPdus": 26,
        "outTrapDrops": 0,
        "groupLength": 0,
        "numberOfGroups": 0,
        "numberOfViews": 0
    },

Pull request:
feat(anta): Added test case to verify SNMP Trap PDUs counters
# Description

Verifies the number of SNMP PDU(s) (Protocol Data Units) processed.

    By default, all SNMP PDU counters will be checked for any non-zero values.
    An optional list of specific SNMP PDU(s) can be provided for granular testing.

    Expected Results
    ----------------
    * Success: The test will pass if the SNMP PDU counter(s) are non-zero/greater than zero.
    * Failure: The test will fail if the SNMP PDU counter(s) are zero/None/Not Found.

Fixes #820 

# Checklist:

- [X] My code follows the style guidelines of this project
- [X] I have performed a self-review of my code
- [X] I have run pre-commit for code linting and typing (`pre-commit run`)
- [ ] I have made corresponding changes to the documentation
- [X] I have added tests that prove my fix is effective or that my feature works
- [X] New and existing unit tests pass locally with my changes (`tox -e testenv`)


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 24427037..0d6cd186 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,11 +1,14 @@
 ---
 # See https://pre-commit.com for more information
 # See https://pre-commit.com/hooks.html for more hooks
+ci:
+  autoupdate_commit_msg: "ci: pre-commit autoupdate"
+
 files: ^(anta|docs|scripts|tests|asynceapi)/
 
 repos:
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.6.0
+    rev: v5.0.0
     hooks:
       - id: trailing-whitespace
         exclude: docs/.*.svg
@@ -43,7 +46,7 @@ repos:
           - '<!--|  ~|  -->'
 
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.6.8
+    rev: v0.6.9
     hooks:
     - id: ruff
       name: Run Ruff linter
@@ -97,7 +100,7 @@ repos:
         files: ^(anta|tests)/
 
   - repo: https://github.com/igorshubovych/markdownlint-cli
-    rev: v0.41.0
+    rev: v0.42.0
     hooks:
       - id: markdownlint
         name: Check Markdown files style.
diff --git a/.vscode/settings.json b/.vscode/settings.json
index ac8ba0b0..d50caa1d 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,15 +1,14 @@
 {
   "ruff.enable": true,
   "ruff.configuration": "pyproject.toml",
-  "python.testing.unittestEnabled": false,
   "python.testing.pytestEnabled": true,
   "python.testing.pytestArgs": [
     "tests"
   ],
-  "python.languageServer": "Pylance",
   "githubIssues.issueBranchTitle": "issues/${issueNumber}-${issueTitle}",
-  "editor.formatOnPaste": true,
-  "files.trimTrailingWhitespace": true,
-  "workbench.remoteIndicator.showExtensionRecommendations": true,
+  "pylint.importStrategy": "fromEnvironment",
+  "pylint.args": [
+    "--rcfile=pyproject.toml"
+  ],
 
 }
\ No newline at end of file
diff --git a/anta/catalog.py b/anta/catalog.py
index 9b752fa0..bc95104f 100644
--- a/anta/catalog.py
+++ b/anta/catalog.py
@@ -298,13 +298,11 @@ class AntaCatalog:
 
         self.indexes_built: bool
         self.tag_to_tests: defaultdict[str | None, set[AntaTestDefinition]]
-        self._tests_without_tags: set[AntaTestDefinition]
         self._init_indexes()
 
     def _init_indexes(self) -> None:
         """Init indexes related variables."""
         self.tag_to_tests = defaultdict(set)
-        self._tests_without_tags = set()
         self.indexes_built = False
 
     @property
@@ -486,11 +484,7 @@ class AntaCatalog:
 
         If a `filtered_tests` set is provided, only the tests in this set will be indexed.
 
-        This method populates two attributes:
-
-        - tag_to_tests: A dictionary mapping each tag to a set of tests that contain it.
-
-        - _tests_without_tags: A set of tests that do not have any tags.
+        This method populates the tag_to_tests attribute, which is a dictionary mapping tags to sets of tests.
 
         Once the indexes are built, the `indexes_built` attribute is set to True.
         """
@@ -504,9 +498,8 @@ class AntaCatalog:
                 for tag in test_tags:
                     self.tag_to_tests[tag].add(test)
             else:
-                self._tests_without_tags.add(test)
+                self.tag_to_tests[None].add(test)
 
-        self.tag_to_tests[None] = self._tests_without_tags
         self.indexes_built = True
 
     def clear_indexes(self) -> None:
diff --git a/anta/cli/_main.py b/anta/cli/_main.py
index d70f1cf5..ae4e050f 100644
--- a/anta/cli/_main.py
+++ b/anta/cli/_main.py
@@ -25,7 +25,8 @@ logger = logging.getLogger(__name__)
 
 @click.group(cls=AliasedGroup)
 @click.pass_context
-@click.version_option(__version__)
+@click.help_option(allow_from_autoenv=False)
+@click.version_option(__version__, allow_from_autoenv=False)
 @click.option(
     "--log-file",
     help="Send the logs to a file. If logging level is DEBUG, only INFO or higher will be sent to stdout.",
diff --git a/anta/custom_types.py b/anta/custom_types.py
index c1e1f642..e0bcdd3e 100644
--- a/anta/custom_types.py
+++ b/anta/custom_types.py
@@ -204,3 +204,4 @@ BgpDropStats = Literal[
 ]
 BgpUpdateError = Literal["inUpdErrWithdraw", "inUpdErrIgnore", "inUpdErrDisableAfiSafi", "disabledAfiSafi", "lastUpdErrTime"]
 BfdProtocol = Literal["bgp", "isis", "lag", "ospf", "ospfv3", "pim", "route-input", "static-bfd", "static-route", "vrrp", "vxlan"]
+SnmpPdu = Literal["inGetPdus", "inGetNextPdus", "inSetPdus", "outGetResponsePdus", "outTrapPdus"]
diff --git a/anta/device.py b/anta/device.py
index 35b7f4c0..d7d2b0de 100644
--- a/anta/device.py
+++ b/anta/device.py
@@ -465,6 +465,10 @@ class AsyncEOSDevice(AntaDevice):
                 self.hw_model = show_version.json_output.get("modelName", None)
                 if self.hw_model is None:
                     logger.critical("Cannot parse 'show version' returned by device %s", self.name)
+                # in some cases it is possible that 'modelName' comes back empty
+                # and it is nice to get a meaninfule error message
+                elif self.hw_model == "":
+                    logger.critical("Got an empty 'modelName' in the 'show version' returned by device %s", self.name)
         else:
             logger.warning("Could not connect to device %s: cannot open eAPI port", self.name)
 
diff --git a/anta/reporter/__init__.py b/anta/reporter/__init__.py
index 73ba8353..38f2e8e6 100644
--- a/anta/reporter/__init__.py
+++ b/anta/reporter/__init__.py
@@ -14,6 +14,7 @@ from jinja2 import Template
 from rich.table import Table
 
 from anta import RICH_COLOR_PALETTE, RICH_COLOR_THEME
+from anta.tools import convert_categories
 
 if TYPE_CHECKING:
     import pathlib
@@ -125,7 +126,7 @@ class ReportTable:
         def add_line(result: TestResult) -> None:
             state = self._color_result(result.result)
             message = self._split_list_to_txt_list(result.messages) if len(result.messages) > 0 else ""
-            categories = ", ".join(result.categories)
+            categories = ", ".join(convert_categories(result.categories))
             table.add_row(str(result.name), result.test, state, message, result.description, categories)
 
         for result in manager.results:
diff --git a/anta/reporter/csv_reporter.py b/anta/reporter/csv_reporter.py
index 4263c1e5..33c50a8f 100644
--- a/anta/reporter/csv_reporter.py
+++ b/anta/reporter/csv_reporter.py
@@ -12,6 +12,7 @@ from dataclasses import dataclass
 from typing import TYPE_CHECKING
 
 from anta.logger import anta_log_exception
+from anta.tools import convert_categories
 
 if TYPE_CHECKING:
     import pathlib
@@ -71,7 +72,7 @@ class ReportCsv:
             TestResult converted into a list.
         """
         message = cls.split_list_to_txt_list(result.messages) if len(result.messages) > 0 else ""
-        categories = cls.split_list_to_txt_list(result.categories) if len(result.categories) > 0 else "None"
+        categories = cls.split_list_to_txt_list(convert_categories(result.categories)) if len(result.categories) > 0 else "None"
         return [
             str(result.name),
             result.test,
diff --git a/anta/reporter/md_reporter.py b/anta/reporter/md_reporter.py
index f4eadb2b..887fd668 100644
--- a/anta/reporter/md_reporter.py
+++ b/anta/reporter/md_reporter.py
@@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, ClassVar
 from anta.constants import MD_REPORT_TOC
 from anta.logger import anta_log_exception
 from anta.result_manager.models import AntaTestStatus
+from anta.tools import convert_categories
 
 if TYPE_CHECKING:
     from collections.abc import Generator
@@ -238,8 +239,8 @@ class SummaryTotalsDeviceUnderTest(MDReportBase):
         """Generate the rows of the summary totals device under test table."""
         for device, stat in self.results.device_stats.items():
             total_tests = stat.tests_success_count + stat.tests_skipped_count + stat.tests_failure_count + stat.tests_error_count
-            categories_skipped = ", ".join(sorted(stat.categories_skipped))
-            categories_failed = ", ".join(sorted(stat.categories_failed))
+            categories_skipped = ", ".join(sorted(convert_categories(list(stat.categories_skipped))))
+            categories_failed = ", ".join(sorted(convert_categories(list(stat.categories_failed))))
             yield (
                 f"| {device} | {total_tests} | {stat.tests_success_count} | {stat.tests_skipped_count} | {stat.tests_failure_count} | {stat.tests_error_count} "
                 f"| {categories_skipped or '-'} | {categories_failed or '-'} |\n"
@@ -286,7 +287,7 @@ class TestResults(MDReportBase):
         """Generate the rows of the all test results table."""
         for result in self.results.get_results(sort_by=["name", "test"]):
             messages = self.safe_markdown(", ".join(result.messages))
-            categories = ", ".join(result.categories)
+            categories = ", ".join(convert_categories(result.categories))
             yield (
                 f"| {result.name or '-'} | {categories or '-'} | {result.test or '-'} "
                 f"| {result.description or '-'} | {self.safe_markdown(result.custom_field) or '-'} | {result.result or '-'} | {messages or '-'} |\n"
diff --git a/anta/result_manager/__init__.py b/anta/result_manager/__init__.py
index f0f4cadf..055a3a17 100644
--- a/anta/result_manager/__init__.py
+++ b/anta/result_manager/__init__.py
@@ -10,7 +10,6 @@ from collections import defaultdict
 from functools import cached_property
 from itertools import chain
 
-from anta.constants import ACRONYM_CATEGORIES
 from anta.result_manager.models import AntaTestStatus, TestResult
 
 from .models import CategoryStats, DeviceStats, TestStats
@@ -162,9 +161,6 @@ class ResultManager:
         result
             TestResult to update the statistics.
         """
-        result.categories = [
-            " ".join(word.upper() if word.lower() in ACRONYM_CATEGORIES else word.title() for word in category.split()) for category in result.categories
-        ]
         count_attr = f"tests_{result.result}_count"
 
         # Update device stats
diff --git a/anta/runner.py b/anta/runner.py
index dcb2d962..0147c3cc 100644
--- a/anta/runner.py
+++ b/anta/runner.py
@@ -146,8 +146,7 @@ def prepare_tests(
     # Using a set to avoid inserting duplicate tests
     device_to_tests: defaultdict[AntaDevice, set[AntaTestDefinition]] = defaultdict(set)
 
-    # Create AntaTestRunner tuples from the tags
-    final_tests_count = 0
+    # Create the device to tests mapping from the tags
     for device in inventory.devices:
         if tags:
             if not any(tag in device.tags for tag in tags):
@@ -160,8 +159,6 @@ def prepare_tests(
         # Add the tests with matching tags from device tags
         device_to_tests[device].update(catalog.get_tests_by_tags(device.tags))
 
-        final_tests_count += len(device_to_tests[device])
-
     if len(device_to_tests.values()) == 0:
         msg = (
             f"There are no tests{f' matching the tags {tags} ' if tags else ' '}to run in the current test catalog and device inventory, please verify your inputs."
diff --git a/anta/tools.py b/anta/tools.py
index dc4dc12c..4f73db9c 100644
--- a/anta/tools.py
+++ b/anta/tools.py
@@ -13,6 +13,7 @@ from functools import wraps
 from time import perf_counter
 from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast
 
+from anta.constants import ACRONYM_CATEGORIES
 from anta.custom_types import REGEXP_PATH_MARKERS
 from anta.logger import format_td
 
@@ -372,3 +373,25 @@ def safe_command(command: str) -> str:
         The sanitized command.
     """
     return re.sub(rf"{REGEXP_PATH_MARKERS}", "_", command)
+
+
+def convert_categories(categories: list[str]) -> list[str]:
+    """Convert categories for reports.
+
+    if the category is part of the defined acronym, transform it to upper case
+    otherwise capitalize the first letter.
+
+    Parameters
+    ----------
+    categories
+        A list of categories
+
+    Returns
+    -------
+    list[str]
+        The list of converted categories
+    """
+    if isinstance(categories, list):
+        return [" ".join(word.upper() if word.lower() in ACRONYM_CATEGORIES else word.title() for word in category.split()) for category in categories]
+    msg = f"Wrong input type '{type(categories)}' for convert_categories."
+    raise TypeError(msg)


Test patch:
diff --git a/anta/tests/flow_tracking.py b/anta/tests/flow_tracking.py
index 5336cf14..676bdb4f 100644
--- a/anta/tests/flow_tracking.py
+++ b/anta/tests/flow_tracking.py
@@ -11,6 +11,7 @@ from typing import ClassVar
 
 from pydantic import BaseModel
 
+from anta.decorators import skip_on_platforms
 from anta.models import AntaCommand, AntaTemplate, AntaTest
 from anta.tools import get_failed_logs
 
@@ -151,6 +152,7 @@ class VerifyHardwareFlowTrackerStatus(AntaTest):
         """Render the template for each hardware tracker."""
         return [template.render(name=tracker.name) for tracker in self.inputs.trackers]
 
+    @skip_on_platforms(["cEOSLab", "vEOS-lab"])
     @AntaTest.anta_test
     def test(self) -> None:
         """Main test function for VerifyHardwareFlowTrackerStatus."""
diff --git a/anta/tests/security.py b/anta/tests/security.py
index 007022dc..71c9f12e 100644
--- a/anta/tests/security.py
+++ b/anta/tests/security.py
@@ -57,7 +57,7 @@ class VerifySSHStatus(AntaTest):
         except StopIteration:
             self.result.is_failure("Could not find SSH status in returned output.")
             return
-        status = line.split("is ")[1]
+        status = line.split()[-1]
 
         if status == "disabled":
             self.result.is_success()
diff --git a/anta/tests/snmp.py b/anta/tests/snmp.py
index c7329b6d..ffbae1c0 100644
--- a/anta/tests/snmp.py
+++ b/anta/tests/snmp.py
@@ -7,9 +7,9 @@
 # mypy: disable-error-code=attr-defined
 from __future__ import annotations
 
-from typing import TYPE_CHECKING, ClassVar
+from typing import TYPE_CHECKING, ClassVar, get_args
 
-from anta.custom_types import PositiveInteger
+from anta.custom_types import PositiveInteger, SnmpPdu
 from anta.models import AntaCommand, AntaTest
 from anta.tools import get_value
 
@@ -237,3 +237,60 @@ class VerifySnmpContact(AntaTest):
             self.result.is_failure(f"Expected `{self.inputs.contact}` as the contact, but found `{contact}` instead.")
         else:
             self.result.is_success()
+
+
+class VerifySnmpPDUCounters(AntaTest):
+    """Verifies the SNMP PDU counters.
+
+    By default, all SNMP PDU counters will be checked for any non-zero values.
+    An optional list of specific SNMP PDU(s) can be provided for granular testing.
+
+    Expected Results
+    ----------------
+    * Success: The test will pass if the SNMP PDU counter(s) are non-zero/greater than zero.
+    * Failure: The test will fail if the SNMP PDU counter(s) are zero/None/Not Found.
+
+    Examples
+    --------
+    ```yaml
+    anta.tests.snmp:
+      - VerifySnmpPDUCounters:
+          pdus:
+            - outTrapPdus
+            - inGetNextPdus
+    ```
+    """
+
+    name = "VerifySnmpPDUCounters"
+    description = "Verifies the SNMP PDU counters."
+    categories: ClassVar[list[str]] = ["snmp"]
+    commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp", revision=1)]
+
+    class Input(AntaTest.Input):
+        """Input model for the VerifySnmpPDUCounters test."""
+
+        pdus: list[SnmpPdu] | None = None
+        """Optional list of SNMP PDU counters to be verified. If not provided, test will verifies all PDU counters."""
+
+    @AntaTest.anta_test
+    def test(self) -> None:
+        """Main test function for VerifySnmpPDUCounters."""
+        snmp_pdus = self.inputs.pdus
+        command_output = self.instance_commands[0].json_output
+
+        # Verify SNMP PDU counters.
+        if not (pdu_counters := get_value(command_output, "counters")):
+            self.result.is_failure("SNMP counters not found.")
+            return
+
+        # In case SNMP PDUs not provided, It will check all the update error counters.
+        if not snmp_pdus:
+            snmp_pdus = list(get_args(SnmpPdu))
+
+        failures = {pdu: value for pdu in snmp_pdus if (value := pdu_counters.get(pdu, "Not Found")) == "Not Found" or value == 0}
+
+        # Check if any failures
+        if not failures:
+            self.result.is_success()
+        else:
+            self.result.is_failure(f"The following SNMP PDU counters are not found or have zero PDU counters:\n{failures}")
diff --git a/examples/tests.yaml b/examples/tests.yaml
index ade4e764..cf961f8e 100644
--- a/examples/tests.yaml
+++ b/examples/tests.yaml
@@ -398,6 +398,9 @@ anta.tests.snmp:
       location: New York
   - VerifySnmpContact:
       contact: Jon@example.com
+  - VerifySnmpPDUCounters:
+      pdus:
+        - outTrapPdus
 
 anta.tests.software:
   - VerifyEOSVersion:
diff --git a/tests/benchmark/conftest.py b/tests/benchmark/conftest.py
index c07cc99c..61f2fa11 100644
--- a/tests/benchmark/conftest.py
+++ b/tests/benchmark/conftest.py
@@ -38,3 +38,20 @@ def catalog(anta_mock_env: AntaMockEnvironment) -> AntaCatalog:
 def pytest_terminal_summary(terminalreporter: TerminalReporter) -> None:
     """Display the total number of ANTA unit test cases used to benchmark."""
     terminalreporter.write_sep("=", f"{TEST_CASE_COUNT} ANTA test cases")
+
+
+def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
+    """Parametrize inventory for benchmark tests."""
+    if "inventory" in metafunc.fixturenames:
+        for marker in metafunc.definition.iter_markers(name="parametrize"):
+            if "inventory" in marker.args[0]:
+                # Do not override test function parametrize marker for inventory arg
+                return
+        metafunc.parametrize(
+            "inventory",
+            [
+                pytest.param({"count": 1, "disable_cache": True, "reachable": True}, id="1-device"),
+                pytest.param({"count": 2, "disable_cache": True, "reachable": True}, id="2-devices"),
+            ],
+            indirect=True,
+        )
diff --git a/tests/benchmark/test_anta.py b/tests/benchmark/test_anta.py
index 6885e2e8..e82de645 100644
--- a/tests/benchmark/test_anta.py
+++ b/tests/benchmark/test_anta.py
@@ -22,27 +22,18 @@ from .utils import collect, collect_commands
 logger = logging.getLogger(__name__)
 
 
-@pytest.mark.parametrize(
-    "inventory",
-    [
-        pytest.param({"count": 1, "disable_cache": True, "reachable": False}, id="1 device"),
-        pytest.param({"count": 2, "disable_cache": True, "reachable": False}, id="2 devices"),
-    ],
-    indirect=True,
-)
-def test_anta_dry_run(benchmark: BenchmarkFixture, catalog: AntaCatalog, inventory: AntaInventory) -> None:
-    """Test and benchmark ANTA in Dry-Run Mode."""
+def test_anta_dry_run(benchmark: BenchmarkFixture, event_loop: asyncio.AbstractEventLoop, catalog: AntaCatalog, inventory: AntaInventory) -> None:
+    """Benchmark ANTA in Dry-Run Mode."""
     # Disable logging during ANTA execution to avoid having these function time in benchmarks
     logging.disable()
 
-    def bench() -> ResultManager:
-        """Need to wrap the ANTA Runner to instantiate a new ResultManger for each benchmark run."""
+    def _() -> ResultManager:
         manager = ResultManager()
         catalog.clear_indexes()
-        asyncio.run(main(manager, inventory, catalog, dry_run=True))
+        event_loop.run_until_complete(main(manager, inventory, catalog, dry_run=True))
         return manager
 
-    manager = benchmark(bench)
+    manager = benchmark(_)
 
     logging.disable(logging.NOTSET)
     if len(manager.results) != len(inventory) * len(catalog.tests):
@@ -51,30 +42,21 @@ def test_anta_dry_run(benchmark: BenchmarkFixture, catalog: AntaCatalog, invento
     logger.info(bench_info)
 
 
-@pytest.mark.parametrize(
-    "inventory",
-    [
-        pytest.param({"count": 1, "disable_cache": True}, id="1 device"),
-        pytest.param({"count": 2, "disable_cache": True}, id="2 devices"),
-    ],
-    indirect=True,
-)
 @patch("anta.models.AntaTest.collect", collect)
 @patch("anta.device.AntaDevice.collect_commands", collect_commands)
 @respx.mock  # Mock eAPI responses
-def test_anta(benchmark: BenchmarkFixture, catalog: AntaCatalog, inventory: AntaInventory) -> None:
-    """Test and benchmark ANTA. Mock eAPI responses."""
+def test_anta(benchmark: BenchmarkFixture, event_loop: asyncio.AbstractEventLoop, catalog: AntaCatalog, inventory: AntaInventory) -> None:
+    """Benchmark ANTA."""
     # Disable logging during ANTA execution to avoid having these function time in benchmarks
     logging.disable()
 
-    def bench() -> ResultManager:
-        """Need to wrap the ANTA Runner to instantiate a new ResultManger for each benchmark run."""
+    def _() -> ResultManager:
         manager = ResultManager()
         catalog.clear_indexes()
-        asyncio.run(main(manager, inventory, catalog))
+        event_loop.run_until_complete(main(manager, inventory, catalog))
         return manager
 
-    manager = benchmark(bench)
+    manager = benchmark(_)
 
     logging.disable(logging.NOTSET)
 
diff --git a/tests/benchmark/test_runner.py b/tests/benchmark/test_runner.py
new file mode 100644
index 00000000..b020a85d
--- /dev/null
+++ b/tests/benchmark/test_runner.py
@@ -0,0 +1,48 @@
+# Copyright (c) 2023-2024 Arista Networks, Inc.
+# Use of this source code is governed by the Apache License 2.0
+# that can be found in the LICENSE file.
+"""Benchmark tests for anta.runner."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from anta.result_manager import ResultManager
+from anta.runner import get_coroutines, prepare_tests
+
+if TYPE_CHECKING:
+    from collections import defaultdict
+
+    from pytest_codspeed import BenchmarkFixture
+
+    from anta.catalog import AntaCatalog, AntaTestDefinition
+    from anta.device import AntaDevice
+    from anta.inventory import AntaInventory
+
+
+def test_prepare_tests(benchmark: BenchmarkFixture, catalog: AntaCatalog, inventory: AntaInventory) -> None:
+    """Benchmark `anta.runner.prepare_tests`."""
+
+    def _() -> defaultdict[AntaDevice, set[AntaTestDefinition]] | None:
+        catalog.clear_indexes()
+        return prepare_tests(inventory=inventory, catalog=catalog, tests=None, tags=None)
+
+    selected_tests = benchmark(_)
+
+    assert selected_tests is not None
+    assert len(selected_tests) == len(inventory)
+    assert sum(len(tests) for tests in selected_tests.values()) == len(inventory) * len(catalog.tests)
+
+
+def test_get_coroutines(benchmark: BenchmarkFixture, catalog: AntaCatalog, inventory: AntaInventory) -> None:
+    """Benchmark `anta.runner.get_coroutines`."""
+    selected_tests = prepare_tests(inventory=inventory, catalog=catalog, tests=None, tags=None)
+
+    assert selected_tests is not None
+
+    coroutines = benchmark(lambda: get_coroutines(selected_tests=selected_tests, manager=ResultManager()))
+    for coros in coroutines:
+        coros.close()
+
+    count = sum(len(tests) for tests in selected_tests.values())
+    assert count == len(coroutines)
diff --git a/tests/conftest.py b/tests/conftest.py
index dd535c10..7858e401 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -17,18 +17,20 @@ from anta.inventory import AntaInventory
 DATA_DIR: Path = Path(__file__).parent.resolve() / "data"
 
 
-@pytest.fixture(params=[{"count": 1}], ids=["1-reachable-device-without-cache"])
+@pytest.fixture
 def inventory(request: pytest.FixtureRequest) -> Iterator[AntaInventory]:
     """Generate an ANTA inventory."""
     user = "admin"
     password = "password"  # noqa: S105
-    disable_cache = request.param.get("disable_cache", True)
-    reachable = request.param.get("reachable", True)
-    if "filename" in request.param:
-        inv = AntaInventory.parse(DATA_DIR / request.param["filename"], username=user, password=password, disable_cache=disable_cache)
+    params = request.param if hasattr(request, "param") else {}
+    count = params.get("count", 1)
+    disable_cache = params.get("disable_cache", True)
+    reachable = params.get("reachable", True)
+    if "filename" in params:
+        inv = AntaInventory.parse(DATA_DIR / params["filename"], username=user, password=password, disable_cache=disable_cache)
     else:
         inv = AntaInventory()
-        for i in range(request.param["count"]):
+        for i in range(count):
             inv.add_device(
                 AsyncEOSDevice(
                     host=f"device-{i}.anta.arista.com",
diff --git a/tests/units/anta_tests/test_security.py b/tests/units/anta_tests/test_security.py
index 549890ad..0d4a478b 100644
--- a/tests/units/anta_tests/test_security.py
+++ b/tests/units/anta_tests/test_security.py
@@ -45,12 +45,32 @@ DATA: list[dict[str, Any]] = [
         "expected": {"result": "failure", "messages": ["Could not find SSH status in returned output."]},
     },
     {
-        "name": "failure-ssh-disabled",
+        "name": "failure-ssh-enabled",
         "test": VerifySSHStatus,
         "eos_data": ["SSHD status for Default VRF is enabled\nSSH connection limit is 50\nSSH per host connection limit is 20\nFIPS status: disabled\n\n"],
         "inputs": None,
         "expected": {"result": "failure", "messages": ["SSHD status for Default VRF is enabled"]},
     },
+    {
+        "name": "success-4.32",
+        "test": VerifySSHStatus,
+        "eos_data": [
+            "User certificate authentication methods: none (neither trusted CA nor SSL profile configured)\n"
+            "SSHD status for Default VRF: disabled\nSSH connection limit: 50\nSSH per host connection limit: 20\nFIPS status: disabled\n\n"
+        ],
+        "inputs": None,
+        "expected": {"result": "success"},
+    },
+    {
+        "name": "failure-ssh-enabled-4.32",
+        "test": VerifySSHStatus,
+        "eos_data": [
+            "User certificate authentication methods: none (neither trusted CA nor SSL profile configured)\n"
+            "SSHD status for Default VRF: enabled\nSSH connection limit: 50\nSSH per host connection limit: 20\nFIPS status: disabled\n\n"
+        ],
+        "inputs": None,
+        "expected": {"result": "failure", "messages": ["SSHD status for Default VRF: enabled"]},
+    },
     {
         "name": "success",
         "test": VerifySSHIPv4Acl,
diff --git a/tests/units/anta_tests/test_snmp.py b/tests/units/anta_tests/test_snmp.py
index f6d964f8..88f1bcdd 100644
--- a/tests/units/anta_tests/test_snmp.py
+++ b/tests/units/anta_tests/test_snmp.py
@@ -7,7 +7,7 @@ from __future__ import annotations
 
 from typing import Any
 
-from anta.tests.snmp import VerifySnmpContact, VerifySnmpIPv4Acl, VerifySnmpIPv6Acl, VerifySnmpLocation, VerifySnmpStatus
+from anta.tests.snmp import VerifySnmpContact, VerifySnmpIPv4Acl, VerifySnmpIPv6Acl, VerifySnmpLocation, VerifySnmpPDUCounters, VerifySnmpStatus
 from tests.units.anta_tests import test
 
 DATA: list[dict[str, Any]] = [
@@ -152,4 +152,87 @@ DATA: list[dict[str, Any]] = [
             "messages": ["SNMP contact is not configured."],
         },
     },
+    {
+        "name": "success",
+        "test": VerifySnmpPDUCounters,
+        "eos_data": [
+            {
+                "counters": {
+                    "inGetPdus": 3,
+                    "inGetNextPdus": 2,
+                    "inSetPdus": 3,
+                    "outGetResponsePdus": 3,
+                    "outTrapPdus": 9,
+                },
+            }
+        ],
+        "inputs": {},
+        "expected": {"result": "success"},
+    },
+    {
+        "name": "success-specific-pdus",
+        "test": VerifySnmpPDUCounters,
+        "eos_data": [
+            {
+                "counters": {
+                    "inGetPdus": 3,
+                    "inGetNextPdus": 0,
+                    "inSetPdus": 0,
+                    "outGetResponsePdus": 0,
+                    "outTrapPdus": 9,
+                },
+            }
+        ],
+        "inputs": {"pdus": ["inGetPdus", "outTrapPdus"]},
+        "expected": {"result": "success"},
+    },
+    {
+        "name": "failure-counters-not-found",
+        "test": VerifySnmpPDUCounters,
+        "eos_data": [
+            {
+                "counters": {},
+            }
+        ],
+        "inputs": {},
+        "expected": {"result": "failure", "messages": ["SNMP counters not found."]},
+    },
+    {
+        "name": "failure-incorrect-counters",
+        "test": VerifySnmpPDUCounters,
+        "eos_data": [
+            {
+                "counters": {
+                    "inGetPdus": 0,
+                    "inGetNextPdus": 2,
+                    "inSetPdus": 0,
+                    "outGetResponsePdus": 3,
+                    "outTrapPdus": 9,
+                },
+            }
+        ],
+        "inputs": {},
+        "expected": {
+            "result": "failure",
+            "messages": ["The following SNMP PDU counters are not found or have zero PDU counters:\n{'inGetPdus': 0, 'inSetPdus': 0}"],
+        },
+    },
+    {
+        "name": "failure-pdu-not-found",
+        "test": VerifySnmpPDUCounters,
+        "eos_data": [
+            {
+                "counters": {
+                    "inGetNextPdus": 0,
+                    "inSetPdus": 0,
+                    "outGetResponsePdus": 0,
+                },
+            }
+        ],
+        "inputs": {"pdus": ["inGetPdus", "outTrapPdus"]},
+        "expected": {
+            "result": "failure",
+            "messages": ["The following SNMP PDU counters are not found or have zero PDU counters:\n{'inGetPdus': 'Not Found', 'outTrapPdus': 'Not Found'}"],
+        },
+    },
 ]
diff --git a/tests/units/reporter/test_csv.py b/tests/units/reporter/test_csv.py
index e0a9d4e1..1d59daef 100644
--- a/tests/units/reporter/test_csv.py
+++ b/tests/units/reporter/test_csv.py
@@ -13,6 +13,7 @@ import pytest
 
 from anta.reporter.csv_reporter import ReportCsv
 from anta.result_manager import ResultManager
+from anta.tools import convert_categories
 
 
 class TestReportCsv:
@@ -25,7 +26,7 @@ class TestReportCsv:
         assert rows[index + 1][2] == result_manager.results[index].result
         assert rows[index + 1][3] == ReportCsv().split_list_to_txt_list(result_manager.results[index].messages)
         assert rows[index + 1][4] == result_manager.results[index].description
-        assert rows[index + 1][5] == ReportCsv().split_list_to_txt_list(result_manager.results[index].categories)
+        assert rows[index + 1][5] == ReportCsv().split_list_to_txt_list(convert_categories(result_manager.results[index].categories))
 
     def test_report_csv_generate(
         self,
diff --git a/tests/units/result_manager/test__init__.py b/tests/units/result_manager/test__init__.py
index e1087536..1fd51cbe 100644
--- a/tests/units/result_manager/test__init__.py
+++ b/tests/units/result_manager/test__init__.py
@@ -85,12 +85,12 @@ class TestResultManager:
 
         result_manager.results = results
 
-        # Check the current categories order and name format
-        expected_order = ["OSPF", "BGP", "VXLAN", "System"]
+        # Check the current categories order
+        expected_order = ["ospf", "bgp", "vxlan", "system"]
         assert list(result_manager.category_stats.keys()) == expected_order
 
-        # Check the sorted categories order and name format
-        expected_order = ["BGP", "OSPF", "System", "VXLAN"]
+        # Check the sorted categories order
+        expected_order = ["bgp", "ospf", "system", "vxlan"]
         assert list(result_manager.sorted_category_stats.keys()) == expected_order
 
     @pytest.mark.parametrize(
diff --git a/tests/units/test_catalog.py b/tests/units/test_catalog.py
index ca78a870..57a8e2f3 100644
--- a/tests/units/test_catalog.py
+++ b/tests/units/test_catalog.py
@@ -260,10 +260,10 @@ class TestAntaCatalog:
         """Test AntaCatalog.build_indexes()."""
         catalog: AntaCatalog = AntaCatalog.parse(DATA_DIR / "test_catalog_with_tags.yml")
         catalog.build_indexes()
-        assert len(catalog._tests_without_tags) == 6
+        assert len(catalog.tag_to_tests[None]) == 6
         assert "leaf" in catalog.tag_to_tests
         assert len(catalog.tag_to_tests["leaf"]) == 3
-        all_unique_tests = catalog._tests_without_tags
+        all_unique_tests = catalog.tag_to_tests[None]
         for tests in catalog.tag_to_tests.values():
             all_unique_tests.update(tests)
         assert len(all_unique_tests) == 11
@@ -275,8 +275,8 @@ class TestAntaCatalog:
         catalog.build_indexes({"VerifyUptime", "VerifyCoredump", "VerifyL3MTU"})
         assert "leaf" in catalog.tag_to_tests
         assert len(catalog.tag_to_tests["leaf"]) == 1
-        assert len(catalog._tests_without_tags) == 1
-        all_unique_tests = catalog._tests_without_tags
+        assert len(catalog.tag_to_tests[None]) == 1
+        all_unique_tests = catalog.tag_to_tests[None]
         for tests in catalog.tag_to_tests.values():
             all_unique_tests.update(tests)
         assert len(all_unique_tests) == 4
diff --git a/tests/units/test_device.py b/tests/units/test_device.py
index 62c16c9e..faf61448 100644
--- a/tests/units/test_device.py
+++ b/tests/units/test_device.py
@@ -430,6 +430,22 @@ REFRESH_PARAMS: list[ParameterSet] = [
         {"is_online": True, "established": False, "hw_model": None},
         id="httpx.ConnectError",
     ),
+    pytest.param(
+        {},
+        (
+            {"return_value": True},
+            {
+                "return_value": [
+                    {
+                        "mfgName": "Arista",
+                        "modelName": "",
+                    }
+                ]
+            },
+        ),
+        {"is_online": True, "established": False, "hw_model": ""},
+        id="modelName empty string",
+    ),
 ]
 COLLECT_PARAMS: list[ParameterSet] = [
     pytest.param(
diff --git a/tests/units/test_tools.py b/tests/units/test_tools.py
index 29abac5e..16f04433 100644
--- a/tests/units/test_tools.py
+++ b/tests/units/test_tools.py
@@ -11,7 +11,7 @@ from typing import Any
 
 import pytest
 
-from anta.tools import custom_division, get_dict_superset, get_failed_logs, get_item, get_value
+from anta.tools import convert_categories, custom_division, get_dict_superset, get_failed_logs, get_item, get_value
 
 TEST_GET_FAILED_LOGS_DATA = [
     {"id": 1, "name": "Alice", "age": 30, "email": "alice@example.com"},
@@ -499,3 +499,17 @@ def test_get_item(
 def test_custom_division(numerator: float, denominator: float, expected_result: str) -> None:
     """Test custom_division."""
     assert custom_division(numerator, denominator) == expected_result
+
+
+@pytest.mark.parametrize(
+    ("test_input", "expected_raise", "expected_result"),
+    [
+        pytest.param([], does_not_raise(), [], id="empty list"),
+        pytest.param(["bgp", "system", "vlan", "configuration"], does_not_raise(), ["BGP", "System", "VLAN", "Configuration"], id="list with acronyms and titles"),
+        pytest.param(42, pytest.raises(TypeError, match="Wrong input type"), None, id="wrong input type"),
+    ],
+)
+def test_convert_categories(test_input: list[str], expected_raise: AbstractContextManager[Exception], expected_result: list[str]) -> None:
+    """Test convert_categories."""
+    with expected_raise:
+        assert convert_categories(test_input) == expected_result