Introduction to Test Harness Design
Overview of Test Harness Requirements
A test harness provisions, configures, exercises, and validates network devices or software components in a repeatable manner. Core requirements include:
- Deterministic provisioning – bring up a known topology with repeatable configuration steps.
- Isolation – each test runs in a sandbox that does not leak state.
- Observability – structured logging, metrics, and artifact collection (pcaps, configs, telemetry) for post‑mortem analysis.
- Extensibility – plug‑in points for device‑specific drivers, protocol stacks, or custom verification logic.
- Portability – the same harness code runs unchanged across different lab environments (physical, virtual, cloud) without hardcoding interface names, node order, or vendor‑specific quirks.
Importance of Portability in Test Harnesses
Portability eliminates the maintenance burden of maintaining multiple, near‑identical copies of a harness for each lab. When a harness assumes a fixed mapping such as “eth0 on router‑1 connects to eth1 on switch‑A”, any change in the underlying inventory forces a code change and risks introducing subtle bugs. Portable harnesses:
- Reduce test‑to‑test variability, improving confidence that failures are due to the system under test, not the harness.
- Enable lab‑agnostic CI/CD pipelines where the same harness validates code against a reference lab, a staging lab, and a production‑like lab.
- Facilitate knowledge sharing across teams; a harness developed by the routing team can be reused by the security or QoS teams without re‑instrumentation.
Design Principles for Portable Test Harnesses
Modularizing Setup and Verification Logic
Separate concerns into distinct layers:
- Orchestration layer – node lifecycle (power on/off, VM spawn, container start).
- Configuration layer – renders device‑specific configs from data models.
- Verification layer – protocol‑agnostic checkers (e.g., BGP state, ACL hit counts) that consume operational state via a unified API.
- Reporting layer – normalizes results into JUnit, TestResult, or custom JSON.
Each layer exposes a well‑defined interface (e.g., Python abstract base classes) so implementations can be swapped without affecting other layers.
Abstracting Interface Names and Node Order
Instead of referencing eth0 or ge-0/0/1 directly, the harness works with logical identifiers defined in an external topology file:
- Logical port – a tuple
(node_id, role)whereroledescribes the function (e.g.,uplink,downlink,loopback). - Topology map – a YAML/JSON document that maps logical ports to physical or virtual interface names for a given lab.
- Resolution function – given a logical port and a lab context, returns the concrete interface name to be used in CLI or API calls.
Node order is abstracted similarly: tests refer to nodes by role (e.g., leaf1, spine2) rather than by index in a list. The harness builds a runtime dictionary node_map[role] = node_object from the topology file.
Avoiding One-Off Assumptions and Hardcoding
| Pitfall | Portable Alternative |
|---|---|
Hardcoded IP subnets (e.g., 10.0.0.0/24) | Subnet pools allocated dynamically from a CIDR block defined in the topology file. |
Vendor‑specific CLI strings (e.g., configure terminal) | Device driver abstractions that map generic commands (enter_config_mode) to vendor CLI/NETCONF/gNMI. |
| Fixed test sequence length | Data‑driven test steps where each step is a dictionary {action, args, expected} read from a test case file. |
| Assumed OS version | Version‑agnostic feature detection (e.g., query show version and adapt parser). |
By externalizing all variable data, the core harness logic remains unchanged across labs.
Architecture of a Portable Test Harness
High-Level Architecture Overview
+-------------------+ +-------------------+ +-------------------+
| Orchestrator | | Config Renderer | | Verifier |
| (node lifecycle) |<---->| (Jinja2/Templating)|<---->| (checkers) |
+-------------------+ +-------------------+ +-------------------+
^ ^ ^
| | |
| Topology & Test | Operational |
| Data (YAML/JSON) | State (gNMI/CLI) |
+-------------------------+-------------------------+
+-------------------+
| Reporter |
| (JUnit, JSON, HTML)|
+-------------------+
- The Orchestrator talks to lab‑specific provisioners (VMware, libvirt, Kubernetes, bare‑metal IPMI) via a plugin interface.
- The Config Renderer consumes a data model (e.g., YAML describing BGP peers, ACLs) and a Jinja2 template library to produce device‑specific configs.
- The Verifier issues operational queries (via gNMI, NETCONF, SSH, or RESTCONF) and evaluates predicates against the returned data.
- The Reporter normalizes outcomes and publishes artifacts.
Component-Based Design for Reusability
Each component is packaged as a Python module with a clear public API:
orchestrator.provision(topology) -> Dict[str, NodeHandle]config.render(node_role, template_name, data) -> strverifier.check(node_handle, check_def) -> boolreporter.emit(result, format) -> None
Dependencies are injected via constructor arguments or environment variables, enabling unit testing with mocks.
Data-Driven Approach for Configuration
All variable lives in data files:
topology.yaml– defines nodes, roles, logical ports, and mapping to lab‑specific attributes.test_cases/– each file contains a list of steps:action: configure_bgp,params: {local_as: 65001, peer: spine1}.device_profiles/– vendor‑specific command templates and parser definitions.
The harness reads these files at startup, builds an internal representation, and proceeds through the test steps. Changing a lab only requires swapping the topology file; no code modification is needed.
Implementing Portable Test Harnesses
Using Parameterized Testing for Flexibility
Parameterized test frameworks (e.g., pytest.mark.parametrize, Robot Framework data‑driven tests) allow a single test function to iterate over a dataset. Example in Python with pytest:
import pytest
@pytest.mark.parametrize("role,expected_as", [
("leaf1", 65001),
("leaf2", 65002),
("leaf3", 65003),
])
def test_bgp_peer_established(orchestrator, verifier, role, expected_as):
node = orchestrator.get_node(role)
state = verifier.get_bgp_summary(node)
assert state["local_as"] == expected_as
assert state["peer_state"] == "Established"
The dataset (role,expected_as) can be generated dynamically from the topology file, eliminating hardcoded values.
Leveraging Templating for Interface and Node Configuration
Jinja2 (or similar) templates enable rendering of CLI or YANG‑based configs from abstract data. Example template for a loopback interface:
# templates/loopback.j2
interface {{ logical_iface.name }}
description {{ logical_iface.description }}
ip address {{ logical_iface.ip }}/{{ logical_iface.prefix }}
Rendering call:
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("templates"))
template = env.get_template("loopback.j2")
config = template.render(logical_iface=iface_data)
The iface_data dictionary contains only logical fields (name, description, ip, prefix). The mapping from logical name to physical interface (e.g., eth0, GigabitEthernet0/0/0) is performed by the orchestrator before sending the config to the device.
Example Code: Parameterizing Setup and Verification Logic
Below is a minimal, self‑contained example that demonstrates the full flow: reading topology, provisioning nodes (mocked), rendering configs, applying them, and verifying BGP state.
# portable_harness.py
import yaml
import jinja2
from abc import ABC, abstractmethod
from typing import Dict, Any
# ---------- Abstract Interfaces ----------
class NodeHandle(ABC):
@abstractmethod
def send_config(self, cfg: str) -> None: ...
@abstractmethod
def get_state(self, path: str) -> Any: ...
class Orchestrator(ABC):
@abstractmethod
def provision(self, topology: Dict) -> Dict[str, NodeHandle]: ...
class Verifier(ABC):
@abstractmethod
def check_bgp_summary(self, node: NodeHandle) -> Dict[str, Any]: ...
# ---------- Concrete Mock Implementations ----------
class MockNode(NodeHandle):
def __init__(self, name: str):
self.name = name
self.configs = []
self.state = {}
def send_config(self, cfg: str) -> None:
self.configs.append(cfg)
# naive parsing for demo
if "router bgp" in cfg:
self.state["bgp_configured"] = True
def get_state(self, path: str) -> Any:
return self.state.get(path)
class MockOrchestrator(Orchestrator):
def provision(self, topology: Dict) -> Dict[str, NodeHandle]:
return {role: MockNode(name) for role, name in topology["nodes"].items()}
class MockVerifier(Verifier):
def check_bgp_summary(self, node: NodeHandle) -> Dict[str, Any]:
# In a real harness this would query gNMI/NETCONF
return {
"local_as": node.get_state("bgp_local_as") or 0,
"peer_state": "Established" if node.get_state("bgp_configured") else "Idle",
}
# ---------- Harness Core ----------
def load_topology(path: str) -> Dict:
with open(path) as f:
return yaml.safe_load(f)
def render_interface(node_role: str, iface_data: Dict) -> str:
env = jinja2.Environment(loader=jinja2.FileSystemLoader("templates"))
tmpl = env.get_template("interface.j2")
return tmpl.render(node=node_role, iface=iface_data)
def apply_config(orch: Orchestrator, verifier: Verifier,
topology: Dict, node_role: str, iface_data: Dict):
nodes = orch.provision(topology)
node = nodes[node_role]
cfg = render_interface(node_role, iface_data)
node.send_config(cfg)
# Simulate committing
return node
def run_test(topology_path: str):
topo = load_topology(topology_path)
orch = MockOrchestrator()
verifier = MockVerifier()
for role, data in topo["interfaces"].items():
node = apply_config(orch, verifier, topo, role, data)
summary = verifier.check_bgp_summary(node)
assert summary["peer_state"] == "Established", f"BGP not up on {role}"
if __name__ == "__main__":
run_test("topology.yaml")
Explanation of portability aspects:
- Topology (
topology.yaml) definesnodes: {leaf1: vm-leaf1, spine1: vm-spine1}andinterfaces: {leaf1: {name: loopback0, ip: 10.0.0.1, prefix: 32}}. Changing the underlying hypervisor or renaming VMs only requires editing this file. - Interface names are never referenced directly; the Jinja2 template uses the logical
namefield. - Verification logic is abstracted behind
MockVerifier; swapping in a real gNMI‑based verifier requires no changes to the test flow.
Troubleshooting Common Issues in Portable Test Harnesses
Debugging Interface and Node Configuration Issues
- Symptom: Config push fails with “invalid interface” error.
- Diagnosis steps:
- Verify the topology file’s mapping for the logical interface under the current lab context.
- Enable orchestrator debug logging (
export ORCH_LOG_LEVEL=DEBUG) to see the resolved physical name. - Capture the rendered config (
orchestrator.save_rendered_config=True) and compare with device CLI help.
- CLI example (using a harness that ships a
harness-clitool):# Show resolved mapping for leaf1/loopback0 harness-cli topology show --node leaf1 --iface loopback0 --lab lab-a # Render config without applying harness-cli config render --template interface.j2 --data '{"node":"leaf1","iface":{"name":"loopback0","ip":"10.0.0.1","prefix":32}}' # Push config with verbose logging harness-cli device configure --node leaf1 --config-file rendered.cfg --verbose
Resolving One-Off Assumption Violations
- Symptom: Test passes in lab‑A but fails in lab‑B with “unexpected BGP peer AS”.
- Root cause: Test case hardcoded peer ASN instead of reading it from topology.
- Fix: Refactor the test step to read
peer_asfrom the topology undernodes[<role>].bgp.local_as. - CLI example to detect hardcoded values:
# Grep for literal AS numbers in test case files grep -R '\b650[0-9][0-9]\b' test_cases/ # If any appear, they need to be parameterized.
CLI Examples for Troubleshooting and Debugging
| Goal | Command | Description |
|---|---|---|
| List all nodes and their resolved physical IDs | harness-cli inventory list --lab lab-c | Shows mapping from logical role to VM/container name. |
| Verify template rendering matches device expectations | harness-cli template validate --template interface.j2 --schema interface_schema.json | Uses JSON‑Schema to ensure rendered output contains mandatory keys. |
| Run a single test step in isolation | harness-cli test run --test-case bgp_peer.yaml --step 2 --dry-run | Executes only step 2, printing actions without touching devices. |
| Collect artifacts after a failure | harness-cli artifacts collect --run-id 2024-09-24_15-30 --output ./artifacts | Pulls configs, logs, and telemetry from all nodes. |
Scaling Portable Test Harnesses
Horizontal Scaling for Large-Scale Labs
- Node provisioning pool – The orchestrator can request nodes from a cluster manager (Kubernetes, OpenStack, VMware vSphere) via a plugin that scales out workers.
- Test sharding – A test suite is split into independent shards,