4、测试框架设计:框架分层架构、配置管理模块、测试用例设计模式、数据驱动思想

好,咱们进入第四章。说实话,框架设计这部分,是我在整个DoIP自动化测试课程里最想跟你聊透的。为什么?因为我在项目里见过太多「能跑但改不动」的框架了。你想想看,一个测试框架如果写成一坨大泥球,前期跑得欢,后期改个配置要翻遍所有脚本——那还不如用手工测呢。

这一章,我会把框架拆成几个关键模块来讲。咱们不搞花架子,直接上实战经验。

4.1 框架分层架构:别让所有代码挤在一个文件里

我刚开始做DoIP测试那会儿,也犯过傻。所有测试用例、DoIP报文构造、日志打印,全塞在一个Python文件里。结果呢?一个用例改了,另外三个用例跟着崩。后来我痛定思痛,引入了分层架构。

说白了,分层就是「各司其职」。我习惯把DoIP测试框架分成三层:

  • 底层(Driver层):负责最基础的DoIP协议交互。比如发送UDP车辆发现请求、建立TCP连接、路由激活、发送诊断请求。这一层只做一件事:把DoIP报文发出去,把响应收回来。不关心业务逻辑。
  • 中间层(Service层):封装具体的诊断服务。比如读取ECU的VIN码、读取DTC、执行例程。这一层调用底层接口,组合成有意义的业务操作。
  • 上层(Test层):就是你的测试用例。调用中间层的方法,加上断言和日志,形成一条完整的测试场景。

核心原则:上层可以调用下层,下层绝不能反向依赖上层。这是铁律。

举个例子,我写一个读取VIN的测试用例,代码结构应该是这样的:

# 底层:DoIPDriver.py
class DoIPDriver:
    def send_vehicle_identification_request(self):
        # 发送UDP广播
        pass

    def send_routing_activation(self, source_address):
        # 发送路由激活请求
        pass

# 中间层:DiagnosticService.py
class DiagnosticService:
    def __init__(self, driver: DoIPDriver):
        self.driver = driver

    def read_vin(self):
        # 先激活路由,再发送读取VIN的请求
        self.driver.send_routing_activation(0x0E80)
        # 构造UDS 0x22 读取VIN
        return self.driver.send_diagnostic_request([0x22, 0xF1, 0x90])

# 上层:test_vin.py
def test_read_vin():
    driver = DoIPDriver()
    service = DiagnosticService(driver)
    vin = service.read_vin()
    assert len(vin) == 17, "VIN长度应为17位"

你看,这样分层之后,如果DoIP协议版本升级了,我只需要改底层Driver层。测试用例几乎不用动。我在项目中遇到过协议从ISO 13400-2:2012升级到2019版,底层改了三天,上层两百多个用例一个没动。这就是分层的好处。

4.2 配置管理模块:别把IP地址写死在代码里

嗯,这里我要说一个我踩过的坑。有一次,我把DoIP网关的IP地址直接写在了测试脚本里。结果换了一个测试台架,IP变了,我满世界找那个硬编码的字符串。找了两个小时,最后发现藏在了一个异常处理的注释里……

从那以后,我强制自己用配置管理模块。说白了,就是把所有可变的东西抽出来,放到一个单独的文件里。我习惯用YAML格式,因为可读性好。

一个典型的DoIP测试配置文件长这样:

# doip_config.yaml
doip:
  gateway_ip: "192.168.1.100"
  gateway_port: 13400
  logical_address: 0x0E80
  timeout: 5.0  # 秒

diagnostic:
  tester_address: 0x0F00
  ecu_address: 0x0001
  vin: "WDB12345678901234"

test:
  retry_count: 3
  log_level: "DEBUG"
  report_path: "./reports/"

然后在Python里,我写一个配置加载器:

import yaml

class ConfigManager:
    def __init__(self, config_path="doip_config.yaml"):
        with open(config_path, 'r') as f:
            self.config = yaml.safe_load(f)

    def get_doip_gateway(self):
        return (self.config['doip']['gateway_ip'],
                self.config['doip']['gateway_port'])

    def get_timeout(self):
        return self.config['doip']['timeout']

我的小技巧:配置文件中不要放密码或密钥。如果非要放,记得用环境变量覆盖。我一般会在ConfigManager里加一个逻辑:如果环境变量存在,优先用环境变量的值。

你想想看,有了配置管理模块,换台架、换ECU、换测试环境,只需要改一个YAML文件。测试代码一行都不用动。这才是自动化测试该有的样子。

4.3 测试用例设计模式:别让每个用例都从零开始

写测试用例,最忌讳的就是「每个用例都重新造轮子」。我见过有人写100个DoIP测试用例,每个用例开头都是「连接网关→激活路由→建立TCP连接」——重复代码满天飞。

我推荐用「模板方法模式」来解决这个问题。说白了,就是定义一个测试用例的骨架,把公共步骤放在父类里,把差异化的部分留给子类去实现。

举个例子:

import unittest

class BaseDoIPTest(unittest.TestCase):
    def setUp(self):
        # 每个用例开始前自动执行
        self.driver = DoIPDriver()
        self.driver.connect(self.config.get_doip_gateway())
        self.driver.activate_routing()

    def tearDown(self):
        # 每个用例结束后自动执行
        self.driver.disconnect()

    def send_and_check(self, request, expected_response):
        response = self.driver.send_diagnostic_request(request)
        self.assertEqual(response, expected_response)

class TestVinReading(BaseDoIPTest):
    def test_read_vin_normal(self):
        # 只需要关注测试逻辑本身
        response = self.driver.send_diagnostic_request([0x22, 0xF1, 0x90])
        self.assertEqual(len(response), 17)

    def test_read_vin_with_wrong_sid(self):
        # 测试异常场景
        response = self.driver.send_diagnostic_request([0x23, 0xF1, 0x90])
        self.assertIn(0x7F, response)  # 应该返回否定响应

你看,setUp和tearDown把公共的连接和断开逻辑封装好了。每个具体的测试用例只需要关注「我要测什么」。我曾经用这个模式重构过一个3000行的测试文件,重构完只剩800行,而且可读性翻倍。

注意:setUp里如果连接失败,记得用try-except捕获异常,并调用self.skipTest()跳过整个用例。不然一个网关没开,后面100个用例全报错,排查起来很痛苦。我吃过这个亏。

4.4 数据驱动思想:同样的逻辑,不同的数据

好,最后聊数据驱动。这个概念其实不复杂,但用好了能省你80%的代码量。

什么叫数据驱动?就是「测试逻辑不变,测试数据变」。比如你要测试DoIP的「读取DTC」功能,需要验证不同ECU地址、不同DTC组别、不同会话模式下的行为。如果每个组合写一个用例,你得写几十个。但如果用数据驱动,你只需要写一个用例,然后传入不同的数据。

我习惯用Python的unittest的subTest功能,或者用pytest的parametrize。这里我用pytest举例:

import pytest

# 测试数据:ECU地址、DTC组别、期望的响应长度
test_data = [
    (0x0001, 0x01, 4),   # 发动机ECU,动力总成DTC
    (0x0002, 0x02, 4),   # 变速箱ECU,底盘DTC
    (0x0003, 0x03, 2),   # 气囊ECU,车身DTC(可能无故障)
]

@pytest.mark.parametrize("ecu_addr, dtc_group, expected_len", test_data)
def test_read_dtc(ecu_addr, dtc_group, expected_len, doip_fixture):
    # doip_fixture 是pytest的fixture,负责连接和激活
    request = [0x19, 0x02, dtc_group]  # UDS 0x19 读取DTC
    response = doip_fixture.send_to_ecu(ecu_addr, request)
    assert len(response) >= expected_len

你看,三组测试数据,只写了一个测试函数。如果以后要加新的ECU地址,只需要在test_data列表里加一行。代码量几乎不增加。

我建议你把测试数据放在外部文件里,比如CSV或Excel。这样测试人员和开发人员可以分工:开发写测试逻辑,测试人员维护测试数据。我在一个项目里就是这么干的,测试人员用Excel维护了500多组测试数据,开发只写了20个测试函数。效率极高。

数据驱动的核心:逻辑与数据分离。逻辑是稳定的,数据是易变的。把易变的东西抽出去,你的框架就活了。

小结

这一章我们聊了四个关键点:分层架构让代码好维护,配置管理让环境切换不头疼,模板方法模式让用例不重复,数据驱动让测试量翻倍而代码量不涨。这些都是我这些年摸爬滚打总结出来的实战经验。

下一章,我们会把这些设计落地,开始写真正的DoIP测试用例。到时候你会看到,框架搭好了,写用例就跟搭积木一样简单。