Remote Lab Testing
The remote lab is a FastAPI service that provisions Netlab network topologies on demand using Containerlab as the orchestration provider. It lets you test function blocks against real virtual devices (FRR, Cisco IOL, etc.) without managing lab infrastructure locally.
How It Works
sequenceDiagram
participant Test as pytest
participant Client as Lab Client
participant Server as Remote Lab Server
participant Netlab as Netlab / Containerlab
Test->>Client: remote_lab_fixture("topology.yml")
Client->>Server: POST /session
Server-->>Client: session_id
Client->>Server: POST /lab (topology file)
Server->>Netlab: Deploy topology
Netlab-->>Server: Devices ready
Server-->>Client: Device list (IPs, credentials)
Client-->>Test: DeviceInfoDto objects
Test->>Test: Run function block
Test->>Client: Teardown
Client->>Server: POST /lab/release
Client->>Server: DELETE /session
Setup
1. Install test dependencies
2. Set the remote lab URL
Or add to .env — the test suite auto-loads it via python-dotenv.
When REMOTE_LAB_URL is unset, fixtures fall back to local execution
(requires Netlab and
Containerlab installed on the host).
Creating Lab Fixtures
1. Write a topology file
Topology files use the Netlab topology format
and are stored in tests/topologies/. Here is a minimal two-router lab using
FRRouting containers:
Change defaults.device to iol for a Cisco IOL lab. For available device
types, modules, and link options, see the
Netlab platform support and
topology reference.
2. Register fixtures in conftest.py
from neops_remote_lab.testing.fixture import remote_lab_fixture
simple_frr = remote_lab_fixture("tests/topologies/simple_frr.yml")
simple_iol = remote_lab_fixture(
"tests/topologies/simple_iol.yml",
reuse_lab=True,
)
| Parameter | Purpose |
|---|---|
| Path | YAML topology file in Netlab format |
reuse_lab=True |
Share the same topology instance across multiple tests (faster) |
Fixtures yield DeviceInfoDto objects representing lab nodes, including their
management IPs and credentials assigned by Containerlab.
One lab per test
Only one lab fixture can be active per test. The ordering plugin groups tests by fixture so the lab is provisioned once and reused within the group.
@fb_test_case_with_lab
The decorator generates a test that provisions a lab topology, converts lab
devices to neops DeviceTypeDto objects, builds a WorkflowContext, and
executes your function block.
Signature
def fb_test_case_with_lab(
test_description: str,
params: P,
run_on: str = "device",
base_devices: list[DeviceTypeDto] | None = None,
base_device_groups: list[DeviceGroupTypeDto] | None = None,
*,
remote_lab_fixture: str | None = None,
succeeds: bool = True,
expected_result_data: R | None = None,
assertions: list[Callable[[FunctionBlockResult[R]], bool]] | None = None,
) -> Callable[[type[FunctionBlock[P, R]]], type[FunctionBlock[P, R]]]:
| Parameter | Purpose |
|---|---|
test_description |
Readable name for the generated test |
params |
Parameter instance for the function block |
run_on |
Entity type: "device" or "group" |
base_devices |
Static devices always present in the context |
base_device_groups |
Static groups always present in the context |
remote_lab_fixture |
Name of a remote_lab_fixture fixture from conftest.py |
succeeds |
Expected result.success value |
expected_result_data |
Strict equality check on result.data |
assertions |
Additional callables; must return True |
Important
At least one of expected_result_data or assertions must be provided.
Example: testing against a lab
@fb_test_case_with_lab(
"Show version on FRR device",
ShowCmdParameters(cmd="show version", password="admin"),
remote_lab_fixture="simple_frr",
assertions=[
lambda r: r.data is not None,
lambda r: r.data.output is not None,
lambda r: r.success is True,
],
)
class GetShowCmdBlock(FunctionBlock[ShowCmdParameters, ShowCmdResult]):
...
Example: testing with a Cisco IOL topology
@fb_test_case_with_lab(
"Show version with IOL fixture",
ShowCmdParameters(cmd="show version", password="admin"),
remote_lab_fixture="simple_iol",
assertions=[
lambda r: r.success is True,
lambda r: "Cisco" in r.data.output,
],
)
class GetShowCmdBlock(FunctionBlock[ShowCmdParameters, ShowCmdResult]):
...
Session Lifecycle
The remote lab uses an exclusive session model:
- Create session — your test joins a FIFO queue.
- Wait for promotion — when it's your turn, the session becomes active.
- Acquire lab — topology is uploaded and deployed (or reused).
- Run tests — devices are reachable via their lab IPs.
- Release lab — decrements reference count; topology is torn down when count reaches zero.
- End session — session is deleted; next in queue is promoted.
Timeouts:
- Waiting sessions are dropped after 600 seconds without a heartbeat.
- Active sessions are dropped after 300 seconds of silence.
Running Lab Tests Selectively
Remote lab tests are marked with remote_lab and excluded from the default
pytest run. To run them explicitly:
uv run pytest -m "remote_lab" # only remote lab tests
uv run pytest -m "function_block or remote_lab" # all integration tests
make test-function-blocks # same via Make
Remote lab tests also carry the function_block marker, so
-m "function_block" includes them as well.
When to Use Remote Lab vs Mocks
| Scenario | Recommended approach |
|---|---|
| Rapid iteration on logic | Standalone tests with mocks |
| Verifying parameter/result schemas | @fb_test_case with mock context |
| Validating device interaction | @fb_test_case_with_lab |
| CI/CD pipeline acceptance tests | @fb_test_case_with_lab |
| Testing error handling for unreachable devices | Mocks (faster, deterministic) |