欢迎光临

智能座舱 HMI 自动化测试体系构建:从仿真环境搭建到 CI/CD 集成的全流程实战

在智能座舱开发中,HMI(人机交互界面)的质量直接决定了用户体验的上限。然而,座舱 HMI 的测试面临着独特的挑战:它依赖 CAN/LIN 总线信号输入、需要与底层服务(导航、语音、车控)实时交互、还要在多屏异构的硬件环境下运行。传统的手工测试不仅效率低下,而且无法覆盖复杂的时序场景和边界条件。本文将从零开始,构建一套完整的座舱 HMI 自动化测试体系,涵盖仿真环境搭建、测试框架选型、用例设计模式和 CI/CD 集成四个核心环节。

一、座舱 HMI 测试的核心痛点与架构分析

与移动端或 Web 端的 UI 测试不同,座舱 HMI 测试有着本质区别:

  • 信号驱动而非事件驱动:座舱界面的状态变化由 CAN 总线信号触发(如车速变化驱动仪表盘指针旋转),而非简单的用户点击事件
  • 多源数据融合:一个导航界面可能同时需要 GPS 定位数据、道路信息、ADAS 告警信号和语音交互状态
  • 硬实时约束:从信号到达界面刷新的延迟必须控制在 50ms 以内,否则驾驶员会感知到卡顿
  • 安全等级差异:仪表盘属于 ASIL-B 级别,娱乐系统属于 QM 级别,两者的测试策略完全不同

基于以上痛点,座舱 HMI 测试架构需要分为三层:

层级 职责 典型工具
信号仿真层 模拟 CAN/LIN/Ethernet 总线信号 Vector CANoe、CANsim、vCan
服务模拟层 模拟导航、语音、车控等系统服务 Mock Service、SOME/IP Stub
HMI 验证层 执行 UI 操作与断言 Appium、自研框架、图像比对

二、仿真环境搭建:让测试脱离实车依赖

2.1 CAN 总线信号仿真

座舱 HMI 最核心的数据源是 CAN 总线。在测试环境中,我们需要一个可编程的 CAN 信号发生器。以下是基于 Linux SocketCAN 的轻量级仿真方案:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
#!/usr/bin/env python3
"""CAN 信号仿真器 - 基于 python-can 库"""

import can
import time
import struct
import threading
from dataclasses import dataclass

@dataclass
class SignalDef:
    """CAN 信号定义"""
    name: str
    start_bit: int
    length: int
    factor: float
    offset: float
    min_val: float
    max_val: float

# 定义仪表盘所需的典型信号
SIGNAL_REGISTRY = {
    0x1A0: [  # 动力总成帧
        SignalDef("EngineSpeed", 0, 16, 0.25, 0, 0, 16383.75),
        SignalDef("VehicleSpeed", 16, 16, 0.01, 0, 0, 655.35),
        SignalDef("EngineTemp", 32, 8, 1, -40, -40, 215),
    ],
    0x2A0: [  # 车身控制帧
        SignalDef("FuelLevel", 0, 8, 0.4, 0, 0, 100),
        SignalDef("DoorStatus", 8, 8, 1, 0, 0, 255),
        SignalDef("LightStatus", 16, 8, 1, 0, 0, 255),
    ],
}

def encode_signal(signal: SignalDef, value: float) -> int:
    """将物理值编码为原始值"""
    raw = int((value - signal.offset) / signal.factor)
    raw = max(0, min(raw, (1 << signal.length) - 1))
    return raw

class CANSimulator:
    def __init__(self, channel: str = "vcan0"):
        self.bus = can.Bus(interface="socketcan", channel=channel)
        self.signal_values = {}
        self.running = False
        self._thread = None

    def set_signal(self, arb_id: int, signal_name: str, value: float):
        """设置信号值(线程安全)"""
        key = (arb_id, signal_name)
        self.signal_values[key] = value

    def _build_frame(self, arb_id: int) -> can.Message:
        """构建 CAN 帧"""
        data = bytearray(8)
        signals = SIGNAL_REGISTRY.get(arb_id, [])
        for sig in signals:
            raw = encode_signal(sig, self.signal_values.get((arb_id, sig.name), 0))
            start_byte = sig.start_bit // 8
            if sig.length <= 8:
                data[start_byte] = raw & 0xFF
            elif sig.length <= 16:
                data[start_byte] = (raw >> 8) & 0xFF
                data[start_byte + 1] = raw & 0xFF
        return can.Message(arbitration_id=arb_id, data=data, is_extended_id=False)

    def _send_loop(self):
        """周期性发送 CAN 帧(10ms 周期)"""
        while self.running:
            for arb_id in SIGNAL_REGISTRY:
                frame = self._build_frame(arb_id)
                self.bus.send(frame)
            time.sleep(0.01)

    def start(self):
        self.running = True
        self._thread = threading.Thread(target=self._send_loop, daemon=True)
        self._thread.start()

    def stop(self):
        self.running = False
        if self._thread:
            self._thread.join(timeout=2)

# 使用示例:模拟加速场景
if __name__ == "__main__":
    sim = CANSimulator(channel="vcan0")
    sim.start()
    for speed in range(0, 120, 5):
        sim.set_signal(0x1A0, "VehicleSpeed", speed)
        sim.set_signal(0x1A0, "EngineSpeed", speed * 40)
        time.sleep(0.1)
    sim.stop()

搭建虚拟 CAN 接口的命令如下:


1
2
3
4
5
6
7
# 创建虚拟 CAN 接口
sudo modprobe vcan
sudo ip link add dev vcan0 type vcan
sudo ip link set vcan0 up

# 验证接口状态
ip link show vcan0

2.2 系统服务模拟

座舱 HMI 依赖的后端服务(导航引擎、语音助手、电话服务等)需要通过 Mock 替代。对于 Android Automotive 平台,可以通过 ADB 挂载 Mock Service APK;对于 Linux/QNX 平台,则需要实现 SOME/IP Stub:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# SOME/IP Service Stub 配置示例(基于 vsomeip)
[service]
service_id = 0x8001
instance_id = 0x0001
major_version = 1
minor_version = 0

[method.get_navigation_status]
method_id = 0x0001
request_type = "notification"
response = '{"state":"active","destination":"Shanghai","eta":"2026-08-20T10:30:00"}'

[method.get_phone_status]
method_id = 0x0002
response = '{"state":"idle","last_caller":"13800138000"}'

[event.vehicle_alert]
event_id = 0x8001
cycle_time_ms = 100
payload = '{"type":"lane_departure","severity":"warning","active":false}'

三、HMI 测试框架选型与搭建

3.1 Android Automotive 平台:Appium + 定制 Driver

对于基于 Android Automotive 的 IVI 系统,Appium 仍然是自动化测试的首选。但需要对标准 Appium 进行车载化扩展,特别是在信号注入和状态同步方面:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# conftest.py - 座舱 HMI 测试的 Pytest 夹具

import pytest
from appium import webdriver
from appium.options.android import UiAutomator2Options
from can_simulator import CANSimulator

@pytest.fixture(scope="session")
def can_sim():
    """CAN 仿真器,整个测试会话共享"""
    sim = CANSimulator(channel="vcan0")
    sim.start()
    yield sim
    sim.stop()

@pytest.fixture(scope="session")
def driver():
    """Appium WebDriver,连接座舱 Android 设备"""
    options = UiAutomator2Options()
    options.platform_name = "Android"
    options.device_name = "cockpit_display"
    options.automation_name = "UiAutomator2"
    options.app_package = "com.cockpit.hmi"
    options.app_activity = ".MainActivity"
    options.no_reset = True
    options.new_command_timeout = 300

    driver = webdriver.Remote("http://localhost:4723", options=options)
    yield driver
    driver.quit()

@pytest.fixture(autouse=True)
def reset_vehicle_state(can_sim):
    """每个用例执行前重置车辆状态"""
    can_sim.set_signal(0x1A0, "VehicleSpeed", 0)
    can_sim.set_signal(0x1A0, "EngineSpeed", 0)
    can_sim.set_signal(0x2A0, "DoorStatus", 0)
    can_sim.set_signal(0x2A0, "LightStatus", 0)
    yield
    can_sim.set_signal(0x1A0, "VehicleSpeed", 0)

3.2 Linux/QNX 平台:基于图像识别的测试方案

对于非 Android 的原生 HMI(如 Qt/QML 或 Wayland 应用),无法使用 Appium 的控件树定位。此时需要基于图像识别的测试方案,结合 OpenCV 模板匹配和 OCR 文字识别:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
"""基于模板匹配的座舱 HMI 图像测试框架"""

import cv2
import numpy as np
import time
from dataclasses import dataclass
from typing import Optional, Tuple

@dataclass
class MatchResult:
    found: bool
    position: Optional[Tuple[int, int]] = None
    confidence: float = 0.0

class CockpitVisualTester:
    def __init__(self, screenshot_cmd: str = "adb shell screencap -p"):
        self.screenshot_cmd = screenshot_cmd
        self.screenshot = None

    def capture(self) -> np.ndarray:
        """截取当前座舱屏幕"""
        import subprocess
        result = subprocess.run(
            self.screenshot_cmd, shell=True, capture_output=True
        )
        img_array = np.frombuffer(result.stdout, dtype=np.uint8)
        self.screenshot = cv2.imdecode(img_array, cv2.IMREAD_COLOR)
        return self.screenshot

    def find_element(self, template_path: str,
                     threshold: float = 0.85) -> MatchResult:
        """在截图中查找模板图像"""
        if self.screenshot is None:
            self.capture()
        template = cv2.imread(template_path, cv2.IMREAD_COLOR)
        if template is None:
            raise FileNotFoundError(f"Template not found: {template_path}")
        result = cv2.matchTemplate(
            self.screenshot, template, cv2.TM_CCOEFF_NORMED
        )
        min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
        if max_val >= threshold:
            h, w = template.shape[:2]
            center = (max_loc[0] + w // 2, max_loc[1] + h // 2)
            return MatchResult(found=True, position=center, confidence=max_val)
        return MatchResult(found=False, confidence=max_val)

    def tap(self, x: int, y: int):
        """点击指定坐标"""
        import subprocess
        subprocess.run(f"adb shell input tap {x} {y}", shell=True)
        time.sleep(0.3)

    def tap_element(self, template_path: str,
                    timeout: float = 5.0) -> bool:
        """等待并点击指定元素"""
        deadline = time.time() + timeout
        while time.time() < deadline:
            self.capture()
            result = self.find_element(template_path)
            if result.found and result.position:
                self.tap(*result.position)
                return True
            time.sleep(0.5)
        return False

    def read_text(self, region: Tuple[int,int,int,int]) -> str:
        """OCR 读取指定区域的文字"""
        if self.screenshot is None:
            self.capture()
        x, y, w, h = region
        roi = self.screenshot[y:y+h, x:x+w]
        import pytesseract
        gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
        _, binary = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY)
        return pytesseract.image_to_string(
            binary, lang="chi_sim+eng", config="--psm 7"
        ).strip()

四、测试用例设计模式

4.1 信号驱动测试模式

这是座舱测试最核心的模式——注入 CAN 信号,验证 HMI 状态变化。每个测试用例遵循”注入-等待-断言”的三步范式:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# test_instrument_cluster.py - 仪表盘信号驱动测试

import pytest
import time

class TestInstrumentCluster:

    def test_speed_needle_follows_can_signal(self, driver, can_sim):
        """验证车速指针跟随 CAN 信号变化"""
        can_sim.set_signal(0x1A0, "VehicleSpeed", 60)
        time.sleep(0.5)
        speed_text = driver.find_element(
            "id", "com.cockpit.hmi:id/speed_value"
        ).text
        assert "60" in speed_text, f"Expected 60, got: {speed_text}"

    def test_speed_needle_range_boundary(self, driver, can_sim):
        """验证仪表盘最大量程边界"""
        can_sim.set_signal(0x1A0, "VehicleSpeed", 260)
        time.sleep(0.5)
        speed_text = driver.find_element(
            "id", "com.cockpit.hmi:id/speed_value"
        ).text
        assert speed_text in ("260", "---"), \
            f"Out-of-range display error: {speed_text}"

    def test_fuel_warning_triggers_at_low_level(self, driver, can_sim):
        """验证低油量告警触发"""
        for level in range(30, 7, -1):
            can_sim.set_signal(0x2A0, "FuelLevel", level)
            time.sleep(0.05)
        time.sleep(0.5)
        warning = driver.find_element(
            "id", "com.cockpit.hmi:id/fuel_warning"
        )
        assert warning.is_displayed(), "Low fuel warning not triggered"

    def test_door_open_shows_indicator(self, driver, can_sim):
        """验证车门打开时仪表盘显示门开指示"""
        can_sim.set_signal(0x2A0, "DoorStatus", 0b0001)
        time.sleep(0.3)
        door_icon = driver.find_element(
            "id", "com.cockpit.hmi:id/door_fl_indicator"
        )
        assert door_icon.is_displayed(), "Door indicator not shown"

4.2 多信号组合场景测试

真实的驾驶场景往往涉及多个信号的协同变化。以下是一个典型的”高速变道”场景测试,验证转向灯、ADAS 告警和车速信号的协同交互:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class TestDrivingScenarios:

    def test_high_speed_lane_change(self, driver, can_sim):
        """高速变道场景:验证多信号协同下 HMI 的正确响应"""
        # 1. 加速到 120km/h
        for speed in range(0, 125, 10):
            can_sim.set_signal(0x1A0, "VehicleSpeed", speed)
            time.sleep(0.02)
        time.sleep(0.5)

        # 2. 开启左转向灯
        can_sim.set_signal(0x2A0, "LightStatus", 0b01000000)
        time.sleep(0.3)
        turn_signal = driver.find_element(
            "id", "com.cockpit.hmi:id/turn_signal_left"
        )
        assert turn_signal.is_displayed()

        # 3. ADAS 发出车道偏离告警
        can_sim.set_signal(0x300, "LDW_Alert", 1)
        time.sleep(0.3)
        ldw_icon = driver.find_element(
            "id", "com.cockpit.hmi:id/ldw_alert"
        )
        assert ldw_icon.is_displayed()

        # 4. 回正方向盘,告警消失
        can_sim.set_signal(0x300, "LDW_Alert", 0)
        can_sim.set_signal(0x2A0, "LightStatus", 0)
        time.sleep(0.5)
        assert not ldw_icon.is_displayed()

4.3 性能延迟测试

座舱 HMI 对延迟极为敏感,必须验证从信号注入到界面刷新的端到端延迟。ASIL-B 级别的仪表盘功能要求 P95 延迟不超过 50ms:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import time
import statistics

class TestHMILatency:

    LATENCY_THRESHOLD_MS = 50

    def _measure_signal_to_ui_latency(self, driver, can_sim,
                                       signal_config, ui_element_id,
                                       iterations=20):
        """测量从 CAN 信号注入到 UI 元素变化的延迟"""
        latencies = []
        for _ in range(iterations):
            arb_id, signal_name, test_value = signal_config
            start = time.perf_counter()
            can_sim.set_signal(arb_id, signal_name, test_value)
            while True:
                elapsed = (time.perf_counter() - start) * 1000
                try:
                    elem = driver.find_element("id", ui_element_id)
                    if elem.is_displayed():
                        latencies.append(elapsed)
                        break
                except Exception:
                    pass
                if elapsed > 200:
                    latencies.append(elapsed)
                    break
                time.sleep(0.001)
        return {
            "mean_ms": statistics.mean(latencies),
            "p95_ms": sorted(latencies)[int(len(latencies) * 0.95)],
            "max_ms": max(latencies),
            "min_ms": min(latencies),
        }

    def test_speed_display_latency(self, driver, can_sim):
        """验证车速显示延迟在 ASIL-B 要求范围内"""
        result = self._measure_signal_to_ui_latency(
            driver, can_sim,
            signal_config=(0x1A0, "VehicleSpeed", 80),
            ui_element_id="com.cockpit.hmi:id/speed_value",
        )
        assert result["p95_ms"] <= self.LATENCY_THRESHOLD_MS, \
            f"P95 latency {result['p95_ms']:.1f}ms exceeds threshold"

五、CI/CD 集成:让测试成为开发流程的守门人

5.1 测试金字塔与流水线设计

座舱 HMI 的测试金字塔与常规应用不同——由于对硬件仿真环境的依赖,集成测试的占比更高:

测试层级 占比 运行环境 执行时间
单元测试(逻辑层) 20% 开发机 / CI 节点 < 2 min
组件测试(单个 HMI 模块) 30% 仿真器 + Mock 服务 5-15 min
集成测试(全信号链路) 40% CAN 仿真 + 完整 Mock 20-60 min
场景测试(驾驶场景回放) 10% 硬件在环 (HiL) 台架 1-4 hours

5.2 Jenkins Pipeline 配置

以下是一个完整的座舱 HMI CI/CD 流水线配置,包含环境准备、分层测试执行、报告生成和失败截图归档:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// Jenkinsfile - 座舱 HMI 自动化测试流水线

pipeline {
    agent { label 'cockpit-test' }

    environment {
        CAN_CHANNEL = 'vcan0'
        APPIUM_HOST = 'http://localhost:4723'
        DEVICE_SERIAL = 'cockpit_display_01'
        REPORT_DIR = 'test-reports'
    }

    stages {
        stage('Env Setup') {
            steps {
                sh '''
                    sudo modprobe vcan
                    sudo ip link add dev vcan0 type vcan 2&gt;/dev/null || true
                    sudo ip link set vcan0 up
                    appium --address 0.0.0.0 --port 4723 \
                        --log-timestamp --log ${REPORT_DIR}/appium.log &
amp; &
                    sleep 5
                    adb -s ${DEVICE_SERIAL} wait-for-device
                '''
            }
        }
        stage('Unit Tests') {
            steps {
                sh 'python -m pytest tests/unit/ -v --junitxml=${REPORT_DIR}/unit.xml -o addopts= -n 4'
            }
        }
        stage('Component Tests') {
            steps {
                sh 'python -m pytest tests/component/ -v --junitxml=${REPORT_DIR}/component.xml -o addopts= --timeout=120'
            }
        }
        stage('Integration - Cluster') {
            steps {
                sh 'python -m pytest tests/integration/test_cluster.py -v --junitxml=${REPORT_DIR}/cluster.xml -o addopts= --can-channel=${CAN_CHANNEL}'
            }
        }
        stage('Integration - IVI') {
            steps {
                sh 'python -m pytest tests/integration/test_infotainment.py -v --junitxml=${REPORT_DIR}/ivi.xml -o addopts= --can-channel=${CAN_CHANNEL}'
            }
        }
        stage('Latency Benchmark') {
            steps {
                sh 'python -m pytest tests/performance/test_latency.py -v --junitxml=${REPORT_DIR}/latency.xml -o addopts= --benchmark-json=${REPORT_DIR}/benchmark.json'
            }
        }
    }
    post {
        always {
            junit "${REPORT_DIR}/*.xml"
            archiveArtifacts artifacts: "${REPORT_DIR}/benchmark.json"
        }
        failure {
            sh 'adb -s ${DEVICE_SERIAL} exec-out screencap -p &gt; ${REPORT_DIR}/failure.png'
            archiveArtifacts artifacts: "${REPORT_DIR}/failure.png"
        }
        cleanup {
            sh 'pkill -f appium || true; sudo ip link set vcan0 down 2&gt;/dev/null || true'
        }
    }
}

5.3 场景回放测试:用真实路测数据驱动自动化

最高级别的座舱 HMI 测试是用真实路测采集的 CAN 数据回放到仿真环境中,验证 HMI 在真实驾驶场景下的表现。以下是基于 python-can 的场景回放引擎实现:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
"""座舱 HMI 场景回放测试引擎"""

import can
import json
import time
from pathlib import Path

class ScenarioReplayer:
    def __init__(self, can_channel: str = "vcan0"):
        self.bus = can.Bus(interface="socketcan", channel=can_channel)
        self.scenario = []

    def load_blf(self, blf_path: str):
        """加载 Vector BLF 格式的路测记录"""
        logfile = can.LogReader(blf_path)
        self.scenario = []
        for msg in logfile:
            self.scenario.append({
                "timestamp": msg.timestamp,
                "arb_id": msg.arbitration_id,
                "data": list(msg.data),
                "dlc": msg.dlc,
            })
        logfile.stop()

    def load_json(self, json_path: str):
        """加载 JSON 格式的场景描述"""
        with open(json_path) as f:
            data = json.load(f)
        self.scenario = data["frames"]

    def replay(self, speed: float = 1.0, callback=None,
               skip_gaps_greater_than: float = 5.0):
        """回放 CAN 场景"""
        if not self.scenario:
            raise ValueError("No scenario data loaded")

        for i, frame in enumerate(self.scenario):
            if i &gt; 0:
                delta = (frame["timestamp"] - self.scenario[i-1]["timestamp"]) / speed
                if delta &lt;= skip_gaps_greater_than:
                    time.sleep(delta)
                else:
                    time.sleep(0.1)

            msg = can.Message(
                arbitration_id=frame["arb_id"],
                data=bytearray(frame["data"]),
                is_extended_id=False,
            )
            self.bus.send(msg)
            if callback:
                callback(i, frame)


# 使用示例
def test_highway_driving_scenario(driver):
    """使用真实路测数据验证高速驾驶场景"""
    replayer = ScenarioReplayer(can_channel="vcan0")
    replayer.load_blf("scenarios/highway_drive_2026.blf")
    violations = []

    def check_ui_at_frame(index, frame):
        if index % 100 != 0:
            return
        try:
            speed_elem = driver.find_element("id", "com.cockpit.hmi:id/speed_value")
            if not speed_elem.is_displayed():
                violations.append(f"Frame {index}: speed display abnormal")
        except Exception as e:
            violations.append(f"Frame {index}: UI query error - {e}")

    replayer.replay(speed=2.0, callback=check_ui_at_frame)
    assert len(violations) == 0, f"Found {len(violations)} issues: {violations[:5]}"

六、测试数据管理与持续优化

6.1 测试数据版本化

座舱 HMI 测试的数据依赖包括 CAN 数据库(.dbc 文件)、路测场景记录、UI 元素定位器仓库和截图基线。所有这些数据都需要纳入版本管理,推荐的目录结构如下:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
test_data/
├── dbc/                          # CAN 数据库文件
│   ├── powertrain_v2.3.dbc
│   ├── body_control_v1.8.dbc
│   └── adas_v3.1.dbc
├── scenarios/                    # 路测场景
│   ├── highway_drive_2026.blf
│   ├── city_stop_go_2026.blf
│   └── parking_scenario_2026.json
├── locators/                     # UI 元素定位器
│   ├── instrument_cluster.yaml
│   ├── infotainment.yaml
│   └── climate_control.yaml
└── baselines/                    # 截图基线
    ├── cluster_speed_60.png
    ├── cluster_speed_120.png
    └── nav_active_route.png

6.2 视觉回归测试

座舱 HMI 的视觉一致性至关重要——任何布局偏移、颜色偏差或图标模糊都会影响用户体验。通过 OpenCV 像素级比对可以自动检测视觉退化,并在差异超出阈值时生成高亮对比图:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
"""座舱 HMI 视觉回归测试"""

import cv2
import numpy as np
from pathlib import Path

class VisualRegression:
    def __init__(self, baseline_dir: str, diff_dir: str,
                 threshold: float = 0.02):
        self.baseline_dir = Path(baseline_dir)
        self.diff_dir = Path(diff_dir)
        self.threshold = threshold
        self.diff_dir.mkdir(parents=True, exist_ok=True)

    def compare(self, screenshot: np.ndarray,
                baseline_name: str) -&gt; dict:
        """将截图与基线进行像素级比对"""
        baseline_path = self.baseline_dir / f"{baseline_name}.png"
        if not baseline_path.exists():
            cv2.imwrite(str(baseline_path), screenshot)
            return {"status": "new_baseline", "diff_percent": 0}

        baseline = cv2.imread(str(baseline_path))
        if screenshot.shape != baseline.shape:
            screenshot = cv2.resize(
                screenshot, (baseline.shape[1], baseline.shape[0])
            )

        diff = cv2.absdiff(baseline, screenshot)
        gray_diff = cv2.cvtColor(diff, cv2.COLOR_BGR2GRAY)
        diff_pixels = np.count_nonzero(gray_diff &gt; 10)
        total_pixels = gray_diff.size
        diff_percent = diff_pixels / total_pixels

        result = {
            "status": "pass" if diff_percent &lt;= self.threshold else "fail",
            "diff_percent": round(diff_percent * 100, 2),
        }

        if diff_percent &gt; 0:
            diff_path = self.diff_dir / f"{baseline_name}_diff.png"
            cv2.imwrite(str(diff_path), diff)
            highlight = baseline.copy()
            mask = gray_diff &gt; 10
            highlight[mask] = [0, 0, 255]
            hl_path = self.diff_dir / f"{baseline_name}_highlight.png"
            cv2.imwrite(str(hl_path), highlight)

        return result

6.3 Flaky 测试治理

座舱环境中的 Flaky 测试(时过时不过的用例)特别常见,原因往往是 CAN 信号时序的不确定性。解决方案是为测试用例增加显式等待策略而非固定 sleep,并在 CI 中设置重试阈值:


1
2
3
4
5
6
7
8
# pytest.ini - Flaky 测试配置
[pytest]
addopts = --reruns 3 --reruns-delay 2 -v
markers =
    can_signal: CAN 信号驱动测试
    latency: 延迟性能测试
    visual: 视觉回归测试
    hil: 硬件在环测试

建议将关键测试指标接入 Grafana 仪表盘,实现实时监控和历史对比,重点追踪以下指标:

  • 通过率趋势(按模块和 ASIL 等级分组)
  • 延迟 P95 趋势(区分 ASIL-B 和 QM 功能)
  • 视觉差异率趋势(按 HMI 页面分组)
  • Flaky 测试识别(连续 3 次结果不一致的用例自动标记)

七、总结与最佳实践

构建座舱 HMI 自动化测试体系不是一次性工程,而是持续演进的系统工程。以下是核心实践建议:

  • 仿真先行:尽可能用软件仿真替代硬件依赖,让 80% 的测试用例能在 CI 环境中运行,仅将 20% 的场景测试留给 HiL 台架
  • 信号驱动而非 UI 驱动:测试用例应该从 CAN 信号注入开始,而非从 UI 操作开始。这更贴近真实数据流方向,也更容易覆盖边界条件
  • 分层延迟基线:ASIL-B 功能(仪表、告警)的延迟基线为 50ms,QM 功能(娱乐、设置)可以放宽到 200ms,不要一刀切
  • 视觉回归纳入 CI:每次 HMI 版本变更都自动跑截图比对,将视觉退化拦截在合入之前
  • 场景库持续积累:每次路测回来的 BLF 数据都是宝贵的测试素材,建立自动化流程将路测数据转化为可回放的测试场景

通过以上体系的建立,座舱 HMI 的测试覆盖率可以从手工测试时代的 30% 提升到 80% 以上,版本回归时间从 3-5 天缩短到 2-4 小时,真正实现质量左移和快速迭代。

【本站文章皆为原创,未经允许不得转载】:汤不热吧 » 智能座舱 HMI 自动化测试体系构建:从仿真环境搭建到 CI/CD 集成的全流程实战
分享到: 更多 (0)