Automated SR-Linux Smoke Testing with Containerlab, gNMI & pytest

Before diving into full-blown feature testing or complex integrations, it’s essential to verify that your SR-Linux topology even works at a basic level. That’s where smoke testing comes in.

This article walks through how to perform automated smoke tests on a Containerlab-deployed SR-Linux topology. The goal is simple: confirm that the infrastructure is alive, reachable, and configured as expected.
The toolchain is deliberately minimal:

  • UV takes care of the Python environment and dependencies.
  • pytest handles test execution and assertions.
  • pygnmi talks to the routers over gNMI.

No Makefiles, no tangled Bash scripts, just fast, focused validation that your lab is ready for deeper testing or development.

Environment

This project utilizes the UV package manager, an incredible tool that can create an environment and install all dependencies within seconds, all with just one command. After executing uv sync, the package manager will use the specific Python version assigned for the project, create a virtual environment, and install all dependencies.

lukrad@SiedliskoZua:~/Projects/sr-linux-mclag$ uv sync
Using CPython 3.13.3 interpreter at: /usr/bin/python3.13
Creating virtual environment at: .venv
Resolved 17 packages in 7ms
Installed 12 packages in 7ms
 + cffi==1.17.1
 + cryptography==45.0.2
 + dictdiffer==0.9.0
 + grpcio==1.71.0
 + iniconfig==2.1.0
 + packaging==25.0
 + pluggy==1.6.0
 + protobuf==6.31.0
 + pycparser==2.22
 + pygnmi==0.8.15
 + pytest==8.3.5
 + pyyaml==6.0.2

If you don’t have UV installed on your system, this guide will help you to do so.

To run the tests, we need a topology. For this article, we will use the already described virtual topology based on Containerlab.

From now on, everything is ready to run the tests, but before that, let’s take a look at the codebase.

Codebase

Let’s start with the common parts used in the tests.

Fixtures

In the conftest.py file, there is a definition of one fixture that we will use in the tests. Let’s look at it.


@pytest.fixture(scope="function")
def gnmi_client(device):
    sr_username = os.getenv("SR_USERNAME")
    sr_password = os.getenv("SR_PASSWORD")

    if not sr_username or not sr_password:
        pytest.skip("Credentials are absent, skipping gNMI client tests")
    assert device, "Device hostname was not provided"

    try:
        with gNMIclient(
            target=(device, GNMI_PORT),
            username=sr_username,
            password=sr_password,
            insecure=True,
        ) as client:
            yield client
    except FutureTimeoutError:
        pytest.fail("gNMI client connection timed out")
    except gNMIException as e:
        pytest.fail(f"gNMI client connection failed: {e}")

Fixture gnmi_client creates a gNMI connection to the given device. To create it, we need credentials and the device IP/hostname. Credentials are loaded from the environment variables. Remember to set them before test execution; otherwise, tests using this fixture will be skipped. Here’s an example of how to set them on Linux.

lukrad@SiedliskoZua:~/Projects/sr-linux-mclag$ export SR_USERNAME=admin
lukrad@SiedliskoZua:~/Projects/sr-linux-mclag$ export SR_PASSWORD=NokiaSrl1!

You can check if everything is set by using the env command.

lukrad@SiedliskoZua:~/Projects/sr-linux-mclag$ env | grep SR_
SR_USERNAME=admin
SR_PASSWORD=NokiaSrl1!

Having credentials in place, we can create connections, and for that, we need the following data:

  1. Device hostname/IP
  2. gNMI port
  3. Credentials

This fixture takes the device hostname as an argument, so we can use it for the connection creation. The port is the same for all of the connections, so it was defined as the GNMI_PORT variable in the constants.py file and imported. Credentials are already in place, so we have everything necessary.

Connection is created by instantiating an object of the gNMIclient class. Right after it’s created, fixture yields it. Later, test functions will use this fixture to create gNMI clients for particular devices. Those connections will be torn down automatically (thanks to the context manager) after the test finishes.

However, we’re not living in a perfect world, so there are two except blocks handling different exceptions:

  1. FutureTimeoutError – error raised if the script is not able to reach the device
  2. gNMIException – generic exception

If the code reaches any of those exception blocks, the executed test will fail.

Models

In models.py, there are definitions of three data classes:

  • InterfaceBase
  • Subinterface
  • Interface
@dataclass
class InterfaceBase:
    name: str


@dataclass
class Subinterface(InterfaceBase):
    index: int
    ip_address: str

    def __str__(self):
        return f"{self.name}.{self.index}"


@dataclass
class Interface(InterfaceBase):
    def __str__(self):
        return self.name

InterfaceBase is a base class for Interfaces and Subinterfaces. It has only a name field. Child class Interface doesn’t have any other fields, but it has overridden __str__ method, which is invoked when the object of that class is cast to a string. Subinterface class, however, does have two more fields – index and ip_address. The first one is an integer representing the subinterface index number, and ip_address represents a string with the IP address, but it can also be None.

Inventory

Let’s take a look at the first part of the inventory.py file.

SPINE1 = "clab-sr-linux-mclag-spine1"
SPINE2 = "clab-sr-linux-mclag-spine2"


LEAF1 = "clab-sr-linux-mclag-leaf1"
LEAF2 = "clab-sr-linux-mclag-leaf2"
LEAF3 = "clab-sr-linux-mclag-leaf3"


CLIENT1 = "clab-sr-linux-mclag-client1"
CLIENT2 = "clab-sr-linux-mclag-client2"

Here we have variables representing hostnames of particular SR-Linux devices. They’re taken from the output of Containerlab, which is displayed at the end of the deployment process.

╭─────────────────────────────┬─────────────────────────────┬─────────┬───────────────────╮
│             Name            │          Kind/Image         │  State  │   IPv4/6 Address  │
├─────────────────────────────┼─────────────────────────────┼─────────┼───────────────────┤
│ clab-sr-linux-mclag-client1 │ nokia_srlinux               │ running │ 172.20.20.6       │
│                             │ ghcr.io/nokia/srlinux:24.10 │         │ 3fff:172:20:20::6 │
├─────────────────────────────┼─────────────────────────────┼─────────┼───────────────────┤
│ clab-sr-linux-mclag-client2 │ nokia_srlinux               │ running │ 172.20.20.4       │
│                             │ ghcr.io/nokia/srlinux:24.10 │         │ 3fff:172:20:20::4 │
├─────────────────────────────┼─────────────────────────────┼─────────┼───────────────────┤
│ clab-sr-linux-mclag-leaf1   │ nokia_srlinux               │ running │ 172.20.20.2       │
│                             │ ghcr.io/nokia/srlinux:24.10 │         │ 3fff:172:20:20::2 │
├─────────────────────────────┼─────────────────────────────┼─────────┼───────────────────┤
│ clab-sr-linux-mclag-leaf2   │ nokia_srlinux               │ running │ 172.20.20.7       │
│                             │ ghcr.io/nokia/srlinux:24.10 │         │ 3fff:172:20:20::7 │
├─────────────────────────────┼─────────────────────────────┼─────────┼───────────────────┤
│ clab-sr-linux-mclag-leaf3   │ nokia_srlinux               │ running │ 172.20.20.5       │
│                             │ ghcr.io/nokia/srlinux:24.10 │         │ 3fff:172:20:20::5 │
├─────────────────────────────┼─────────────────────────────┼─────────┼───────────────────┤
│ clab-sr-linux-mclag-spine1  │ nokia_srlinux               │ running │ 172.20.20.8       │
│                             │ ghcr.io/nokia/srlinux:24.10 │         │ 3fff:172:20:20::8 │
├─────────────────────────────┼─────────────────────────────┼─────────┼───────────────────┤
│ clab-sr-linux-mclag-spine2  │ nokia_srlinux               │ running │ 172.20.20.3       │
│                             │ ghcr.io/nokia/srlinux:24.10 │         │ 3fff:172:20:20::3 │
╰─────────────────────────────┴─────────────────────────────┴─────────┴───────────────────╯

Hostnames can be used to connect to the containers, and that’s their purpose here.

Now, it’s time to dive into test definitions!

Tests

There are two groups of tests:

  • Interface
  • BGP

Interfaces

The interface part has three tests, each covering a different aspect of the interface state/configuration:

  • Admin State
  • Oper State
  • IPv4 Address Configuration

To be able to check every configured interface, we need a reference list. In the inventory file, there is an INTERFACES_DEFINITION variable with a list of interfaces and subinterfaces for each device. Each interface uses data models from the models file. Note that for subinterfaces, there are also IPv4 address values that will be used for comparison with the collected data from the devices.

INTERFACES_DEFINITION = [
    (
        SPINE1,
        [
            Interface(name="ethernet-1/1"),
            Subinterface(name="ethernet-1/1", index=0, ip_address="192.0.2.0/31"),
            Interface(name="ethernet-1/2"),
            Subinterface(name="ethernet-1/2", index=0, ip_address="192.0.2.2/31"),
            Interface(name="ethernet-1/3"),
            Subinterface(name="ethernet-1/3", index=0, ip_address="192.0.2.4/31"),
            Interface(name="system0"),
            Subinterface(name="system0", index=0, ip_address="198.51.100.1/32"),
        ],
    ),
[...]
    (
        LEAF1,
        [
            Interface(name="ethernet-1/1"),
            Subinterface(name="ethernet-1/1", index=0, ip_address="192.0.2.1/31"),
            Interface(name="ethernet-1/2"),
            Subinterface(name="ethernet-1/2", index=0, ip_address="192.0.2.7/31"),
            Interface(name="ethernet-1/3"),
            Interface(name="irb1"),
            Subinterface(name="irb1", index=111, ip_address="203.0.113.1/25"),
            Subinterface(name="irb1", index=222, ip_address="203.0.113.129/25"),
            Interface(name="lag1"),
            Interface(name="system0"),
            Subinterface(name="system0", index=0, ip_address="198.51.100.3/32"),
        ],
    ),
[...]

Having all the definitions in place, let’s go to the particular tests.

Admin State

Each test function is designed to test one device at a time.


@pytest.mark.parametrize("device,interfaces", INTERFACES_DEFINITION)
def test_interface_admin_state_enabled(gnmi_client, device, interfaces):
    for interface in interfaces:
        path = build_path_for_interface(interface, "admin-state")

        result = gnmi_client.get(path=[path], encoding="json_ietf")
        admin_state = strip_returned_value(result)
        assert admin_state == EXPECTED_INTERFACE_ADMIN_STATE, (
            f"Expected admin-state for interface {interface} to be ",
            f'"{EXPECTED_INTERFACE_ADMIN_STATE}", got "{admin_state}"',
        )

As arguments for the interface tests we’re passing:

  • gnmi_client – fixture described earlier
  • device – string with the device hostname
  • interfaces – a list of interfaces for a particular device

INTERFACES_DEFINITION is a list of tuples. The first element of each tuple is the hostname, and the second is a list of interfaces. By using @pytest.mark.parametrize, we can execute the test for each device, because the function will be called for each tuple in our list.

Inside the function, we’re iterating through the list of interfaces for a particular device. Before sending a gNMI request, we need to construct a path to the resource we want to query. For that, there is a helper function – build_path_for_interface, which takes an interface/subinterface object and the resource name. This function is implemented in the helpers.py file.

def strip_returned_value(result: dict) -> str:
    assert "update" in result["notification"][0].keys(), (
        "Expected 'update' key in the result, probably requested value is not present on the device."
    )
    return result["notification"][0]["update"][0]["val"]


def build_path_for_interface(interface: Interface | Subinterface, leaf: str) -> str:
    if isinstance(interface, Interface):
        return f"/interface[name={interface.name}]/{leaf}"
    return f"/interface[name={interface.name}]/subinterface[index={interface.index}]/{leaf}"

Path structure depends on the type of interface, because for subinterfaces, we also need to provide an index. The function checks what type of data objects were passed as an argument, and based on that, it returns the appropriate path in a string format.

Path for interface admin-state was taken from the official YANG browser.

The next action in the code that’s executed is a get call on the gnmi_client object. It holds a gNMI session for a particular device. As arguments, we’re passing a list of paths – in our case, just a single one. The second argument is the type of encoding that we want to receive – json_ietf.

The structure of the returned value is quite complex, which is why there is another helper function – strip_returned_value. It takes what’s returned and strips just the admin-state value.

The last thing to check is if the returned value is as we expect. In this case, we want to receive enable for each interface, but to make the code more readable, there is a string variable EXPECTED_INTERFACE_ADMIN_STATE in the constants file with which we’re comparing the received value from the gNMI call.

EXPECTED_INTERFACE_ADMIN_STATE = "enable"
EXPECTED_INTERFACE_OPER_STATE = "up"

If the result of the comparison is false, which means that those values are different, the test fails, and the appropriate message is constructed to make log analysis easier.

Oper state

The operational state test has a similar structure to the Admin state.


@pytest.mark.parametrize("device,interfaces", INTERFACES_DEFINITION)
def test_interface_oper_state_up(gnmi_client, device, interfaces):
    for interface in interfaces:
        path = build_path_for_interface(interface, "oper-state")

        result = gnmi_client.get(path=[path], encoding="json_ietf")
        oper_state = strip_returned_value(result)
        assert oper_state == EXPECTED_INTERFACE_OPER_STATE, (
            f"Expected oper-state for interface {interface} to be "
            f'"{EXPECTED_INTERFACE_OPER_STATE}", got "{oper_state}"'
        )

The first difference is in the path-building process. We need to build a path according to this schema.

Value returned by device is different, that’s why we have another variable with the expected oper state – EXPECTED_INTERFACE_OPER_STATE with assigned value up.

IPv4 address configuration

Now, let’s examine a more advanced example. We have a bunch of interfaces on each of the devices. Only subinterfaces can have assigned IP addresses, but not all of them do.


@pytest.mark.parametrize("device,interfaces", INTERFACES_DEFINITION)
def test_subinterface_ipv4_address_configured(gnmi_client, device, interfaces):
    for interface in interfaces:
        if not isinstance(interface, Subinterface):
            # Skip interfaces that are not subinterfaces
            continue

        if not interface.ip_address:
            # Skip subinterfaces without IP address
            continue

        path = build_path_for_interface(interface, "ipv4/address")
        result = gnmi_client.get(path=[path], encoding="json_ietf")
        configured_ipv4_address = strip_returned_value(result)["address"][0][
            "ip-prefix"
        ]
        assert configured_ipv4_address == interface.ip_address, (
            f'Expected configured IPv4 address for interface {interface} to be "{interface.ip_address}", '
            f'got "{configured_ipv4_address}"'
        )

Again, we’re iterating through the interfaces, but at the beginning of the loop, we’re checking two conditions:

  1. If the interface object is not of type Subinterface
  2. If the interface object doesn’t have an assigned IP address

First condition: check if it’s a subinterface, because only subinterfaces can have an IP address assigned.

After the first condition, we’re certain that the processed interface is a subinterface. Keeping in mind that not all subinterfaces have a configured IP address, we’re checking that in the second if statement. If subinterfaces don’t have an IP address assigned, we’re skipping it.

After those two conditions, we have a clear understanding of the processed interface:

  1. Is a subinterface
  2. Has an IP address assigned

Building a path is similar, but now we’re using this schema. As a result, we will receive a list of IP addresses assigned to this subinterface. In our case, we have a maximum of 1 IP address assigned per subinterface, so we’re grabbing the value from the first element of the returned list. At the end, we’re comparing if the IP address collected from the device is the same as the one defined in our inventory file.

BGP

There are a lot of attributes that can be verified when it comes to BGP. In the tests, we decided to check if all neighbors are present, and for each of them, verify:

  1. Peer address
  2. Peer AS
  3. Peer type
  4. Group
  5. Peer state

Similarly to the interfaces, we have defined a model for each BGP neighbor in the models file. Here’s its structure.

class BgpPeerType(Enum):
    EBGP = "ebgp"
    IBGP = "ibgp"


@dataclass
class BgpNeighbor:
    peer_address: str
    peer_as: int
    peer_type: BgpPeerType
    group: str

Definitions of neighbors for each device are present in the BGP_NEIGHBORS_DEFINITION variable in the inventory file.

BGP_NEIGHBORS_DEFINITION = [
    (
        SPINE1,
        [
            BgpNeighbor(
                peer_address="192.0.2.1",
                peer_as=4200000003,
                peer_type=BgpPeerType.EBGP,
                group="underlay_leaf",
            ),
            BgpNeighbor(
                peer_address="192.0.2.3",
                peer_as=4200000004,
                peer_type=BgpPeerType.EBGP,
                group="underlay_leaf",
            ),
            BgpNeighbor(
                peer_address="192.0.2.5",
                peer_as=4200000005,
                peer_type=BgpPeerType.EBGP,
                group="underlay_leaf",
            ),
            BgpNeighbor(
                peer_address="198.51.100.3",
                peer_as=4200000666,
                peer_type=BgpPeerType.IBGP,
                group="overlay_leaf",
            ),
            BgpNeighbor(
                peer_address="198.51.100.4",
                peer_as=4200000666,
                peer_type=BgpPeerType.IBGP,
                group="overlay_leaf",
            ),
            BgpNeighbor(
                peer_address="198.51.100.5",
                peer_as=4200000666,
                peer_type=BgpPeerType.IBGP,
                group="overlay_leaf",
            ),
        ],
    ),
[...]

We’re aiming to have all neighbors in the established state. This is also defined as the EXPECTED_BGP_PEER_STATE variable in the constants file.

EXPECTED_BGP_PEER_STATE = "established"

Let’s take a look at the test definition.


@pytest.mark.parametrize("device,bgp_neighbors", BGP_NEIGHBORS_DEFINITION)
def test_bgp_neighbor_state(gnmi_client, device, bgp_neighbors):
    for neighbor in bgp_neighbors:
        path = f"/network-instance[name=default]/protocols/bgp/neighbor[peer-address={neighbor.peer_address}]"

        result = gnmi_client.get(path=[path], encoding="json_ietf")
        neighbor_data = strip_returned_value(result)

        assert neighbor_data["peer-as"] == neighbor.peer_as, (
            f'Expected BGP neighbor peer AS to be "{neighbor.peer_as}", got "{neighbor_data["peer-as"]}"'
        )
        assert neighbor_data["peer-type"] == neighbor.peer_type.value, (
            f'Expected BGP neighbor peer type to be "{neighbor.peer_type.value}", got ',
            f"{neighbor_data['peer-type']}",
        )
        assert neighbor_data["peer-group"] == neighbor.group, (
            f'Expected BGP neighbor group to be "{neighbor.group}", got "{neighbor_data["group"]}"'
        )
        assert neighbor_data["session-state"] == EXPECTED_BGP_PEER_STATE, (
            f'Expected BGP neighbor session state to be "{EXPECTED_BGP_PEER_STATE}", got ',
            f"{neighbor_data['session-state']}",
        )

This time, instead of passing the interface list from the inventory, we’re passing BGP neighbors definitions, but the structure of those two lists is similar.

In the test, we’re iterating through each neighbor for a particular device. At the beginning, we’re constructing a path for a gNMI call, but this time, we’re specifying the exact BGP peer address. As a response, we will get details about a specific BGP neighbor. After the result is received from the call, we’re using the strip_returned_data helper function to get a dictionary with collected information.

In this test, we have multiple assertions because we’re checking different parameters.

At first, we need to check if the requested peer is configured on the device. If it’s not present, the assert in the strip_returned_data function will cause the test to fail. After that, we’re sure that the peer exists, so we can check it in detail

First, assert checks if the peer AS number is as expected. The second one checks if the BGP peer is of the type it should be. The third checks for group compliance. Those three assertions refer to the BGP configuration, but the last one is different. It checks the session state. BGP has several different states, and we want to be sure that all BGP sessions are established. Once again, we have a variable, EXPECTED_BGP_PEER_STATE, defined in the constants file for comparison.

Running the tests

UV makes running the tests as easy as it can be. It’s a matter of one command – uv run pytest

If the topology is deployed and you have set the environment variables before running the tests, the result should be as follows.

lukrad@SiedliskoZua:~/Projects/sr-linux-mclag$ uv run pytest
================================================================================================================================= test session starts ==================================================================================================================================
platform linux -- Python 3.13.3, pytest-8.3.5, pluggy-1.6.0
rootdir: /home/lukrad/Projects/sr-linux-mclag
configfile: pyproject.toml
collected 26 items                                                                                                                                                                                                                                                                     

tests/test_bgp.py .....                                                                                                                                                                                                                                                          [ 19%]
tests/test_interfaces.py .....................                                                                                                                                                                                                                                   [100%]

==================================================================================================================================

Each dot represents an executed test that passed. As we can see, there are 5 dots in the BGP section, which means that this test was executed for a total of 5 devices. In the interfaces section, there are more dots because we have 3 tests defined in the test_interfaces.py file.

If credentials are not present in the environment variables, the result will be like this.

lukrad@SiedliskoZua:~/Documents/sr-linux-mclag$ uv run pytest
================================================================================================================================= test session starts ==================================================================================================================================
platform linux -- Python 3.13.3, pytest-8.3.5, pluggy-1.6.0
rootdir: /home/lukrad/Documents/sr-linux-mclag
configfile: pyproject.toml
collected 26 items                                                                                                                                                                                                                                                                     

tests/test_bgp.py sssss                                                                                                                                                                                                                                                          [ 19%]
tests/test_interfaces.py sssssssssssssssssssss                                                                                                                                                                                                                                   [100%]

================================================================================================================================= 26 skipped in 0.02s ==================================================================================================================================

Symbol s represents a skipped test. If we want to receive more information about skipped tests, we can add -v to have more verbose output.

lukrad@SiedliskoZua:~/Documents/sr-linux-mclag$ uv run pytest -v
================================================================================================================================= test session starts ==================================================================================================================================
platform linux -- Python 3.13.3, pytest-8.3.5, pluggy-1.6.0 -- /home/lukrad/Documents/sr-linux-mclag/.venv/bin/python
cachedir: .pytest_cache
rootdir: /home/lukrad/Documents/sr-linux-mclag
configfile: pyproject.toml
collected 26 items                                                                                                                                                                                                                                                                     

tests/test_bgp.py::test_bgp_neighbor_state[clab-sr-linux-mclag-spine1-bgp_neighbors0] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                                               [  3%]
tests/test_bgp.py::test_bgp_neighbor_state[clab-sr-linux-mclag-spine2-bgp_neighbors1] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                                               [  7%]
tests/test_bgp.py::test_bgp_neighbor_state[clab-sr-linux-mclag-leaf1-bgp_neighbors2] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                                                [ 11%]
tests/test_bgp.py::test_bgp_neighbor_state[clab-sr-linux-mclag-leaf2-bgp_neighbors3] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                                                [ 15%]
tests/test_bgp.py::test_bgp_neighbor_state[clab-sr-linux-mclag-leaf3-bgp_neighbors4] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                                                [ 19%]
tests/test_interfaces.py::test_interface_admin_state_enabled[clab-sr-linux-mclag-spine1-interfaces0] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                                [ 23%]
tests/test_interfaces.py::test_interface_admin_state_enabled[clab-sr-linux-mclag-spine2-interfaces1] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                                [ 26%]
tests/test_interfaces.py::test_interface_admin_state_enabled[clab-sr-linux-mclag-leaf1-interfaces2] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                                 [ 30%]
tests/test_interfaces.py::test_interface_admin_state_enabled[clab-sr-linux-mclag-leaf2-interfaces3] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                                 [ 34%]
tests/test_interfaces.py::test_interface_admin_state_enabled[clab-sr-linux-mclag-leaf3-interfaces4] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                                 [ 38%]
tests/test_interfaces.py::test_interface_admin_state_enabled[clab-sr-linux-mclag-client1-interfaces5] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                               [ 42%]
tests/test_interfaces.py::test_interface_admin_state_enabled[clab-sr-linux-mclag-client2-interfaces6] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                               [ 46%]
tests/test_interfaces.py::test_interface_oper_state_up[clab-sr-linux-mclag-spine1-interfaces0] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                                      [ 50%]
tests/test_interfaces.py::test_interface_oper_state_up[clab-sr-linux-mclag-spine2-interfaces1] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                                      [ 53%]
tests/test_interfaces.py::test_interface_oper_state_up[clab-sr-linux-mclag-leaf1-interfaces2] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                                       [ 57%]
tests/test_interfaces.py::test_interface_oper_state_up[clab-sr-linux-mclag-leaf2-interfaces3] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                                       [ 61%]
tests/test_interfaces.py::test_interface_oper_state_up[clab-sr-linux-mclag-leaf3-interfaces4] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                                       [ 65%]
tests/test_interfaces.py::test_interface_oper_state_up[clab-sr-linux-mclag-client1-interfaces5] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                                     [ 69%]
tests/test_interfaces.py::test_interface_oper_state_up[clab-sr-linux-mclag-client2-interfaces6] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                                     [ 73%]
tests/test_interfaces.py::test_subinterface_ipv4_address_configured[clab-sr-linux-mclag-spine1-interfaces0] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                         [ 76%]
tests/test_interfaces.py::test_subinterface_ipv4_address_configured[clab-sr-linux-mclag-spine2-interfaces1] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                         [ 80%]
tests/test_interfaces.py::test_subinterface_ipv4_address_configured[clab-sr-linux-mclag-leaf1-interfaces2] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                          [ 84%]
tests/test_interfaces.py::test_subinterface_ipv4_address_configured[clab-sr-linux-mclag-leaf2-interfaces3] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                          [ 88%]
tests/test_interfaces.py::test_subinterface_ipv4_address_configured[clab-sr-linux-mclag-leaf3-interfaces4] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                          [ 92%]
tests/test_interfaces.py::test_subinterface_ipv4_address_configured[clab-sr-linux-mclag-client1-interfaces5] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                        [ 96%]
tests/test_interfaces.py::test_subinterface_ipv4_address_configured[clab-sr-linux-mclag-client2-interfaces6] SKIPPED (Credentials are absent, skipping gNMI client tests)                                                                                                        [100%]

================================================================================================================================= 26 skipped in 0.03s ==================================================================================================================================

The test results will be different if the topology is not deployed.

[...]
.venv/lib/python3.13/site-packages/grpc/_utilities.py:106: FutureTimeoutError

During handling of the above exception, another exception occurred:

device = 'clab-sr-linux-mclag-client2'

    @pytest.fixture(scope="function")
    def gnmi_client(device):
        sr_username = os.getenv("SR_USERNAME")
        sr_password = os.getenv("SR_PASSWORD")
    
        if not sr_username or not sr_password:
            pytest.skip("Credentials are absent, skipping gNMI client tests")
        assert device, "Device hostname was not provided"
    
        try:
            with gNMIclient(
                target=(device, GNMI_PORT),
                username=sr_username,
                password=sr_password,
                insecure=True,
            ) as client:
                yield client
        except FutureTimeoutError:
>           pytest.fail("gNMI client connection timed out")
E           Failed: gNMI client connection timed out

tests/conftest.py:27: Failed
=============================================================================================================================== short test summary info ================================================================================================================================
ERROR tests/test_bgp.py::test_bgp_neighbor_state[clab-sr-linux-mclag-spine1-bgp_neighbors0] - Failed: gNMI client connection timed out
ERROR tests/test_bgp.py::test_bgp_neighbor_state[clab-sr-linux-mclag-spine2-bgp_neighbors1] - Failed: gNMI client connection timed out
ERROR tests/test_bgp.py::test_bgp_neighbor_state[clab-sr-linux-mclag-leaf1-bgp_neighbors2] - Failed: gNMI client connection timed out
ERROR tests/test_bgp.py::test_bgp_neighbor_state[clab-sr-linux-mclag-leaf2-bgp_neighbors3] - Failed: gNMI client connection timed out
ERROR tests/test_bgp.py::test_bgp_neighbor_state[clab-sr-linux-mclag-leaf3-bgp_neighbors4] - Failed: gNMI client connection timed out
ERROR tests/test_interfaces.py::test_interface_admin_state_enabled[clab-sr-linux-mclag-spine1-interfaces0] - Failed: gNMI client connection timed out
ERROR tests/test_interfaces.py::test_interface_admin_state_enabled[clab-sr-linux-mclag-spine2-interfaces1] - Failed: gNMI client connection timed out
ERROR tests/test_interfaces.py::test_interface_admin_state_enabled[clab-sr-linux-mclag-leaf1-interfaces2] - Failed: gNMI client connection timed out
ERROR tests/test_interfaces.py::test_interface_admin_state_enabled[clab-sr-linux-mclag-leaf2-interfaces3] - Failed: gNMI client connection timed out
ERROR tests/test_interfaces.py::test_interface_admin_state_enabled[clab-sr-linux-mclag-leaf3-interfaces4] - Failed: gNMI client connection timed out
ERROR tests/test_interfaces.py::test_interface_admin_state_enabled[clab-sr-linux-mclag-client1-interfaces5] - Failed: gNMI client connection timed out
ERROR tests/test_interfaces.py::test_interface_admin_state_enabled[clab-sr-linux-mclag-client2-interfaces6] - Failed: gNMI client connection timed out
ERROR tests/test_interfaces.py::test_interface_oper_state_up[clab-sr-linux-mclag-spine1-interfaces0] - Failed: gNMI client connection timed out
ERROR tests/test_interfaces.py::test_interface_oper_state_up[clab-sr-linux-mclag-spine2-interfaces1] - Failed: gNMI client connection timed out
ERROR tests/test_interfaces.py::test_interface_oper_state_up[clab-sr-linux-mclag-leaf1-interfaces2] - Failed: gNMI client connection timed out
ERROR tests/test_interfaces.py::test_interface_oper_state_up[clab-sr-linux-mclag-leaf2-interfaces3] - Failed: gNMI client connection timed out
ERROR tests/test_interfaces.py::test_interface_oper_state_up[clab-sr-linux-mclag-leaf3-interfaces4] - Failed: gNMI client connection timed out
ERROR tests/test_interfaces.py::test_interface_oper_state_up[clab-sr-linux-mclag-client1-interfaces5] - Failed: gNMI client connection timed out
ERROR tests/test_interfaces.py::test_interface_oper_state_up[clab-sr-linux-mclag-client2-interfaces6] - Failed: gNMI client connection timed out
ERROR tests/test_interfaces.py::test_subinterface_ipv4_address_configured[clab-sr-linux-mclag-spine1-interfaces0] - Failed: gNMI client connection timed out
ERROR tests/test_interfaces.py::test_subinterface_ipv4_address_configured[clab-sr-linux-mclag-spine2-interfaces1] - Failed: gNMI client connection timed out
ERROR tests/test_interfaces.py::test_subinterface_ipv4_address_configured[clab-sr-linux-mclag-leaf1-interfaces2] - Failed: gNMI client connection timed out
ERROR tests/test_interfaces.py::test_subinterface_ipv4_address_configured[clab-sr-linux-mclag-leaf2-interfaces3] - Failed: gNMI client connection timed out
ERROR tests/test_interfaces.py::test_subinterface_ipv4_address_configured[clab-sr-linux-mclag-leaf3-interfaces4] - Failed: gNMI client connection timed out
ERROR tests/test_interfaces.py::test_subinterface_ipv4_address_configured[clab-sr-linux-mclag-client1-interfaces5] - Failed: gNMI client connection timed out
ERROR tests/test_interfaces.py::test_subinterface_ipv4_address_configured[clab-sr-linux-mclag-client2-interfaces6] - Failed: gNMI client connection timed out
============================================================================================================================ 26 errors in 130.71s (0:02:10) ============================================================================================================================

As you can see, there are 26 errors here, and we have information about each test.

Even if our topology is deployed, BGP tests can fail. Right after deployment, the BGP sessions need some time to reach the established state, so if we run tests right after deployment is done, we will get the following result.

[...]
tests/test_bgp.py:26: AssertionError
__________________________________________________________________________________________________________ test_bgp_neighbor_state[clab-sr-linux-mclag-leaf3-bgp_neighbors4] ___________________________________________________________________________________________________________

gnmi_client = <pygnmi.client.gNMIclient object at 0x774a3de710f0>, device = 'clab-sr-linux-mclag-leaf3'
bgp_neighbors = [BgpNeighbor(peer_address='192.0.2.4', peer_as=4200000001, peer_type=<BgpPeerType.EBGP: 'ebgp'>, group='underlay_spine...Neighbor(peer_address='198.51.100.2', peer_as=4200000666, peer_type=<BgpPeerType.IBGP: 'ibgp'>, group='overlay_spine')]

    @pytest.mark.parametrize("device,bgp_neighbors", BGP_NEIGHBORS_DEFINITION)
    def test_bgp_neighbor_state(gnmi_client, device, bgp_neighbors):
        for neighbor in bgp_neighbors:
            path = f"/network-instance[name=default]/protocols/bgp/neighbor[peer-address={neighbor.peer_address}]"
    
            result = gnmi_client.get(path=[path], encoding="json_ietf")
            neighbor_data = strip_returned_value(result)
    
            assert neighbor_data["peer-as"] == neighbor.peer_as, (
                f'Expected BGP neighbor peer AS to be "{neighbor.peer_as}", got "{neighbor_data["peer-as"]}"'
            )
            assert neighbor_data["peer-type"] == neighbor.peer_type.value, (
                f'Expected BGP neighbor peer type to be "{neighbor.peer_type.value}", got ',
                f"{neighbor_data['peer-type']}",
            )
            assert neighbor_data["peer-group"] == neighbor.group, (
                f'Expected BGP neighbor group to be "{neighbor.group}", got "{neighbor_data["group"]}"'
            )
>           assert neighbor_data["session-state"] == EXPECTED_BGP_PEER_STATE, (
                f'Expected BGP neighbor session state to be "{EXPECTED_BGP_PEER_STATE}", got ',
                f'"{neighbor_data["session-state"]}"',
            )
E           AssertionError: ('Expected BGP neighbor session state to be "established", got ', '"active"')
E           assert 'active' == 'established'
E             
E             - established
E             + active

tests/test_bgp.py:26: AssertionError
=============================================================================================================================== short test summary info ================================================================================================================================
FAILED tests/test_bgp.py::test_bgp_neighbor_state[clab-sr-linux-mclag-spine1-bgp_neighbors0] - AssertionError: ('Expected BGP neighbor session state to be "established", got ', '"active"')
FAILED tests/test_bgp.py::test_bgp_neighbor_state[clab-sr-linux-mclag-spine2-bgp_neighbors1] - AssertionError: ('Expected BGP neighbor session state to be "established", got ', '"active"')
FAILED tests/test_bgp.py::test_bgp_neighbor_state[clab-sr-linux-mclag-leaf1-bgp_neighbors2] - AssertionError: ('Expected BGP neighbor session state to be "established", got ', '"active"')
FAILED tests/test_bgp.py::test_bgp_neighbor_state[clab-sr-linux-mclag-leaf2-bgp_neighbors3] - AssertionError: ('Expected BGP neighbor session state to be "established", got ', '"active"')
FAILED tests/test_bgp.py::test_bgp_neighbor_state[clab-sr-linux-mclag-leaf3-bgp_neighbors4] - AssertionError: ('Expected BGP neighbor session state to be "established", got ', '"active"')
============================================================================================================================= 5 failed, 21 passed in 5.73s =============================================================================================================================

Summary

Automation doesn’t need to be heavyweight; it just needs to be in place. With a lightweight stack of UV, pytest, and gNMI, you can enforce that your network behaves exactly as you intend, every time you deploy it.

Turn your topology into a self-validating, living system that fails fast when expectations aren’t met.

Happy testing!

Authors

Share

One thought on “Automated SR-Linux Smoke Testing with Containerlab, gNMI & pytest

Leave a Reply

Your email address will not be published. Required fields are marked *