第322篇:自动化测试与网络验证

关键词

网络测试、自动化验证、pytest、pyATS、Genie、单元测试、集成测试、回归测试、TDD


一、为什么要做网络自动化测试

1.1 网络变更的风险

一次典型网络变更的"惊险一跃":

  变更前 → 变更中 → 变更后
  ┌──────────────────────────────────────────┐
  │  变更前(评审阶段)                       │
  │  ┌─ 工程师 review 配置                    │
  │  ├─ 凭经验判断"应该没问题"               │
  │  ├─ 异常不在回滚就在变更                   │
  │  └─ 但回滚本身也有风险                    │
  │                                           │
  │  变更中(实施阶段)                       │
  │  ┌─ "commit" 命令敲下去的瞬间              │
  │  ├─ 心跳加速                              │
  │  └─ 祈祷不要出问题                        │
  │                                           │
  │  变更后(验证阶段)                       │
  │  ┌─ 人工检查状态(show 命令逐个查)       │
  │  ├─ 可能漏查某个关键指标                  │
  │  ├─ 不良影响半小时后才被发现              │
  │  └─ "昨晚我们改了啥?"                    │
  └──────────────────────────────────────────┘

  自动化测试的解决方案:
  ┌─ 变更前:自动验证配置语法和约束
  ├─ 变更中:自动化执行并实时验证
  ├─ 变更后:自动执行全量验证用例
  └─ 回归:已有功能不因变更而破坏

1.2 测试金字塔

网络自动化测试金字塔:

E2E 测试 ┌───────────┐ ← 端到端业务验证(少而精) 集成测试 ┌───────┐ 单元测试 最多 最快 最稳 ← 多设备交互验证 ← 单设备/单功能验证(多而快)

各层特点: ┌─ 单元测试:测试单一函数/模块/单设备配置 │ └─ 执行时间:毫秒-秒级 │ └─ 比例:占总测试 70% │ ├─ 集成测试:测试多设备交互(BGP 建联/OSPF 邻接) │ └─ 执行时间:秒-分钟级 │ └─ 比例:占总测试 20% │ └─ E2E 测试:测试端到端业务(用户→接入→核心→外网) └─ 执行时间:分钟级 └─ 比例:占总测试 10%


二、Python 测试框架

2.1 pytest 基础

#!/usr/bin/env python3
# test_network_basics.py — pytest 网络测试示例

import pytest
from netmiko import ConnectHandler
from typing import Dict, Any

# =============================================
# Fixture:共享设备连接
# =============================================

@pytest.fixture(scope="module")
def device_connection():
    """创建设备连接,测试结束后自动关闭"""
    device = {
        "device_type": "huawei_vrp",
        "host": "192.168.1.1",
        "username": "admin",
        "password": "admin123",
    }
    conn = ConnectHandler(**device)
    conn.enable()
    yield conn
    conn.disconnect()


# =============================================
# 单元测试:单条命令验证
# =============================================

def test_ping_gateway(device_connection):
    """测试到网关的可达性"""
    output = device_connection.send_command(
        "ping 192.168.1.254 -c 3"
    )
    # 成功率 100% 才算通过
    assert "100% packet loss" not in output
    assert "0.00% packet loss" not in output or \
           "100%" not in output


def test_interface_status(device_connection):
    """测试关键接口状态"""
    output = device_connection.send_command(
        "display interface brief"
    )
    # 核心接口必须 UP
    assert "GigabitEthernet0/0/1" in output
    # 提取接口状态行
    for line in output.splitlines():
        if "GE0/0/1" in line:
            assert "UP" in line.upper(), \
                f"接口 GE0/0/1 状态异常: {line}"


def test_bgp_peer_status(device_connection):
    """测试 BGP peer 状态"""
    output = device_connection.send_command(
        "display bgp peer"
    )
    # 所有 BGP peer 必须 Established
    assert "Established" in output
    # 检查是否有非 Established 的 peer
    assert "Idle" not in output
    assert "Active" not in output

2.2 参数化测试

# =============================================
# 参数化测试:批量验证多个接口
# =============================================

INTERFACES_TO_CHECK = [
    "GigabitEthernet0/0/1",
    "GigabitEthernet0/0/2",
    "GigabitEthernet0/0/3",
]

@pytest.mark.parametrize("interface", INTERFACES_TO_CHECK)
def test_interface_is_up(device_connection, interface):
    """参数化测试:每个接口都要 UP"""
    output = device_connection.send_command(
        f"display interface {interface}"
    )
    assert "current state : UP" in output or \
           "line protocol is up" in output, \
           f"接口 {interface} 未处于 UP 状态"
    # 检查是否有 CRC 错误
    for line in output.splitlines():
        if "CRC" in line and "error" in line:
            count = int(line.split()[-1])
            assert count == 0, \
                f"接口 {interface} 存在 CRC 错误: {count}"


# =============================================
# 自定义 Fixture:测试数据准备
# =============================================

@pytest.fixture
def test_vlan_data() -> Dict[str, Any]:
    """VLAN 测试数据"""
    return {
        "vlan_id": 100,
        "vlan_name": "TEST_VLAN_100",
        "interface": "GigabitEthernet0/0/10",
        "ip": "10.100.100.1/24",
        "expected_state": "UP",
    }


def test_vlan_creation(device_connection, test_vlan_data):
    """测试 VLAN 创建后的状态"""
    # 配置 VLAN
    commands = [
        f"vlan batch {test_vlan_data['vlan_id']}",
        f"interface {test_vlan_data['interface']}",
        f"port link-type access",
        f"port default vlan {test_vlan_data['vlan_id']}",
    ]
    output = device_connection.send_config_set(commands)
    assert "Error" not in output

    # 验证 VLAN 存在
    output = device_connection.send_command(
        f"display vlan {test_vlan_data['vlan_id']}"
    )
    assert str(test_vlan_data['vlan_id']) in output

三、pyATS / Genie 测试框架

3.1 框架概述

pyATS / Genie 是 Cisco 开源的网络测试框架:

  pyATS(核心框架):
  ┌──────────────────────────────────────────┐
  │  测试用例管理                             │
  │  ┌─ Testcase 定义和执行                   │
  │  ├─ 测试报告生成                          │
  │  ├─ 并行执行                             │
  │  └─ CI/CD 集成                           │
  └──────────────────────────────────────────┘

  Genie(网络领域扩展):
  ┌──────────────────────────────────────────┐
  │  网络解析与验证                           │
  │  ┌─ 设备输出解析(show → 结构化数据)    │
  │  ├─ 状态验证(operational state)         │
  │  ├─ 差分对比(diff state between runs)   │
  │  └─ 网络模型抽象                         │
  └──────────────────────────────────────────┘

  pyATS + Genie 的优势:
  ┌─ 多厂商支持:Cisco/Huawei/Juniper/Arista
  ├─ 开箱即用的测试库
  ├─ YAML 测试用例定义(无需写代码)
  ├─ 自动解析 show 命令输出
  └─ 社区丰富的测试用例模板

3.2 安装与基础使用

# 安装
# python -m pip install pyats genie

# =============================================
# pyATS 测试脚本示例
# =============================================

from pyats import aetest
from genie.testbed import load
import logging

logger = logging.getLogger(__name__)


class CommonSetup(aetest.CommonSetup):
    """通用测试初始化"""

    @aetest.subsection
    def connect_to_devices(self, testbed):
        """连接到测试床中的所有设备"""
        for name, device in testbed.devices.items():
            try:
                device.connect()
                logger.info(f"已连接设备: {name}")
            except Exception as e:
                logger.error(f"连接设备 {name} 失败: {e}")
                self.failed(f"无法连接到 {name}")


class BGPTest(aetest.Testcase):
    """BGP 状态测试"""

    @aetest.test
    def test_bgp_summary(self, testbed):
        """验证所有设备的 BGP 状态"""
        for name, device in testbed.devices.items():
            bgp = device.parse("display bgp peer")  # Genie 解析

            # 遍历所有 BGP peer
            for peer_ip, peer_info in bgp.get("peer", {}).items():
                state = peer_info.get("state", "")
                assert state == "Established", \
                    f"{name} 的 BGP peer {peer_ip} 状态: {state}"

                # 检查 uptime
                uptime = peer_info.get("up_time", "")
                logger.info(
                    f"{name} → peer {peer_ip}: "
                    f"状态={state}, 运行时间={uptime}"
                )

    @aetest.test
    def test_bgp_routes(self, testbed):
        """验证 BGP 路由表"""
        for name, device in testbed.devices.items():
            bgp_routes = device.parse("display bgp routing-table")

            # 必须存在默认路由
            default_route = bgp_routes.get("routes", {}).get("0.0.0.0/0")
            assert default_route is not None, \
                f"{name} 缺少默认 BGP 路由"


class InterfaceTest(aetest.Testcase):
    """接口状态测试"""

    @aetest.test
    def test_interfaces(self, testbed):
        """验证所有接口状态"""
        for name, device in testbed.devices.items():
            interfaces = device.parse("display interface brief")

            for intf_name, intf_info in interfaces.items():
                status = intf_info.get("oper_status", "")
                if intf_name.startswith("GE"):
                    assert status.lower() == "up", \
                        f"{name} 接口 {intf_name} 状态: {status}"


class CommonCleanup(aetest.CommonCleanup):
    """测试清理"""

    @aetest.subsection
    def disconnect_devices(self, testbed):
        """断开所有设备连接"""
        for name, device in testbed.devices.items():
            device.disconnect()


# =============================================
# 主入口
# =============================================

if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser()
    parser.add_argument("--testbed", required=True,
                        help="测试床 YAML 文件")
    args = parser.parse_args()

    # 加载测试床
    testbed = load(args.testbed)

    # 运行测试
    aetest.main(testbed=testbed)

3.3 测试床 YAML 定义

# testbed.yaml — pyATS 测试床定义
---
testbed:
  name: production_network_testbed
  credentials:
    default:
      username: admin
      password: admin123
    enable:
      password: enable123

devices:
  core-sw01:
    os: iosxe
    type: switch
    connections:
      cli:
        protocol: ssh
        ip: 192.168.1.1
        port: 22

  core-sw02:
    os: iosxe
    type: switch
    connections:
      cli:
        protocol: ssh
        ip: 192.168.1.2
        port: 22

  acc-sw01:
    os: iosxe
    type: switch
    connections:
      cli:
        protocol: ssh
        ip: 192.168.1.10
        port: 22

topology:
  # 拓扑关系定义
  core-sw01:
    interfaces:
      GigabitEthernet0/0/1:
        link: core-sw02:GigabitEthernet0/0/1
        type: trunk

四、配置变更的 CI/CD 测试流水线

4.1 流水线阶段

配置变更 CI/CD 测试流水线:

  ┌──────────────────────────────────────────┐
  │  阶段 1:语法检查(Syntax Check)          │
  │  ┌─ Python 语法检查(flake8, mypy)       │
  │  ├─ YAML/JSON 格式校验                   │
  │  ├─ Jinja2 模板渲染测试                  │
  │  └─ CLI 命令语法预检(不支持?)         │
  │                                           │
  │  阶段 2:单元测试(Unit Test)             │
  │  ┌─ 渲染后的配置是否正确?               │
  │  ├─ IP 地址是否冲突?                    │
  │  ├─ VLAN ID 是否在允许范围?             │
  │  └─ 参数是否完整?                      │
  │                                           │
  │  阶段 3:集成测试(Integration Test)      │
  │  ┌─ 在沙箱/测试环境部署                   │
  │  ├─ OSPF 邻接是否建立?                  │
  │  ├─ BGP peer 是否 Established?          │
  │  └─ 路由是否正常?                      │
  │                                           │
  │  阶段 4:合规检查(Compliance)            │
  │  ┌─ 是否符合安全基线?                   │
  │  ├─ 是否有冗余配置?                     │
  │  ├─ NTP/DNS/SNMP 是否配置正确?          │
  │  └─ 密码复杂度是否达标?                │
  │                                           │
  │  阶段 5:E2E 验证(End-to-End)           │
  │  ┌─ 端到端连通性测试                     │
  │  ├─ 业务流量是否正常?                   │
  │  ├─ 延迟/丢包指标是否达标?              │
  │  └─ 回滚方案是否验证?                  │
  └──────────────────────────────────────────┘

4.2 GitLab CI 集成

# .gitlab-ci.yml — 网络测试 CI 流水线

stages:
  - syntax-check
  - unit-test
  - integration-test
  - compliance
  - e2e-test

# =============================================
# 阶段 1:语法检查
# =============================================

syntax-check:
  stage: syntax-check
  image: python:3.10
  script:
    - python -m pip install pyyaml jsonschema
    # YAML 格式检查
    - python -c "
import yaml, sys
for f in ['config/devices.yml', 'config/network.yml']:
    try:
        with open(f) as fp:
            yaml.safe_load(fp)
        print(f'{f}: OK')
    except Exception as e:
        print(f'{f}: FAIL - {e}')
        sys.exit(1)
"
    # Jinja2 模板渲染测试
    - python -m pip install jinja2
    - python scripts/validate_templates.py

# =============================================
# 阶段 2:单元测试
# =============================================

unit-test:
  stage: unit-test
  image: python:3.10
  script:
    - python -m pip install pytest netmiko
    - pytest tests/unit/ -v --junitxml=report-unit.xml
  artifacts:
    reports:
      junit: report-unit.xml

# =============================================
# 阶段 3:集成测试(沙箱环境)
# =============================================

integration-test:
  stage: integration-test
  only:
    - master
    - develop
  script:
    - python -m pip install pyats genie
    # 在沙箱环境部署配置
    - ansible-playbook -i inventory/staging.yml deploy.yml
    # 运行集成测试
    - pyats run job tests/integration/bgp_test.py --testbed testbeds/staging.yaml
    - pyats run job tests/integration/ospf_test.py --testbed testbeds/staging.yaml
  artifacts:
    paths:
      - pyats-logs/

# =============================================
# 阶段 4:合规检查
# =============================================

compliance:
  stage: compliance
  script:
    - python scripts/compliance_check.py
  artifacts:
    reports:
      junit: report-compliance.xml

# =============================================
# 阶段 5:E2E 验证
# =============================================

e2e-test:
  stage: e2e-test
  only:
    - master
  when: manual  # 手动触发
  script:
    - python -m pip install requests
    - python tests/e2e/business_flow_test.py
    - python tests/e2e/latency_test.py

五、测试数据 Mock 与仿真

5.1 设备模拟器

#!/usr/bin/env python3
# mock_device.py — 模拟设备响应用于测试

from unittest.mock import Mock, patch
import pytest


class MockDeviceConnection:
    """模拟设备连接,不依赖真实设备"""

    def __init__(self, host: str = "192.168.1.1"):
        self.host = host
        self._responses = {}

    def register_response(self, command: str, output: str):
        """注册命令的模拟返回"""
        self._responses[command.strip()] = output

    def send_command(self, command: str, **kwargs) -> str:
        """返回预先注册的模拟响应"""
        cmd = command.strip()

        # 注册默认响应
        if cmd not in self._responses:
            if "display interface brief" in cmd:
                return """
Interface                      Status  Protocol
GigabitEthernet0/0/1           up      up
GigabitEthernet0/0/2           up      up
GigabitEthernet0/0/3           down    down
"""
            elif "display bgp peer" in cmd:
                return """
 BGP Local router identifier: 10.0.0.1
 Local AS number: 65001

 Peer        AS      State        UpTime
 10.0.0.2    65002   Established  01:23:45
 10.0.0.3    65003   Established  02:34:56
"""
            elif "display version" in cmd:
                return "Huawei Versatile Routing Platform Software"

        return self._responses.get(cmd, "")

    def send_config_set(self, commands: list, **kwargs) -> str:
        """模拟配置下发"""
        results = []
        for cmd in commands:
            if "Error" in cmd:
                results.append(f"Error: invalid command - {cmd}")
            else:
                results.append(f"[OK] {cmd}")
        return "\n".join(results)

    def disconnect(self):
        pass


# =============================================
# 使用 Mock 进行测试
# =============================================

@pytest.fixture
def mock_device():
    """创建模拟设备"""
    dev = MockDeviceConnection()
    return dev


def test_interface_check_with_mock(mock_device):
    """使用模拟设备测试接口检查"""
    from netmiko import ConnectHandler

    # 用 Mock 替换真实的 send_command
    with patch.object(ConnectHandler, 'send_command',
                      side_effect=mock_device.send_command):
        conn = ConnectHandler(
            device_type="huawei_vrp",
            host="192.168.1.1",
            username="admin",
            password="admin",
        )

        # 验证接口状态
        output = conn.send_command("display interface brief")
        assert "GigabitEthernet0/0/1" in output
        assert "up" in output


def test_config_error_handling(mock_device):
    """测试配置错误处理"""
    with patch.object(ConnectHandler, 'send_config_set',
                      side_effect=mock_device.send_config_set):
        conn = ConnectHandler(
            device_type="huawei_vrp",
            host="192.168.1.1",
            username="admin",
            password="admin",
        )

        # 正常配置
        result = conn.send_config_set(["vlan 100"])
        assert "Error" not in result

        # 错误配置
        result = conn.send_config_set(["Error: bad command"])
        assert "Error" in result

六、最佳实践总结

网络自动化测试最佳实践:

  1. 测试先行(Test-Driven Network)
  ┌─ 先写测试,再写配置
  ├─ 测试覆盖所有"不能出错"的场景
  └─ 变更前跑测试确保基线正常

  2. 分层测试
  ┌─ 70% 单元测试(快速、可靠)
  ├─ 20% 集成测试(多设备交互)
  └─ 10% E2E 测试(关键业务)

  3. 自动化集成
  ┌─ 所有测试集成到 CI/CD 流水线
  ├─ Git Push 触发自动化测试
  └─ 测试不通过禁止合入主分支

  4. Mock 外部依赖
  ┌─ 单元测试不依赖真实设备
  ├─ 使用 MockDevice 模拟设备响应
  └─ 集成测试才使用沙箱环境

  5. 持续改进
  ┌─ 每次故障"复盘"后补充测试
  ├─ 测试覆盖率持续提升
  └─ 定期回顾测试质量

  最终目标:
  ┌──────────────────────────────────────────┐
  │  让网络变更从"心惊胆战"                   │
  │  变成"信心满满地点击 Merge"               │
  └──────────────────────────────────────────┘

下篇预告:第323篇 — 网络配置合规性检查自动化,介绍如何自动化检查设备配置是否合规,防范配置漂移风险。


下篇预告:第323篇 — 网络配置合规性检查自动化,介绍如何自动化检查设备配置是否合规,防范配置漂移风险。