在智能座舱开发中,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>/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 > ${REPORT_DIR}/failure.png'
archiveArtifacts artifacts: "${REPORT_DIR}/failure.png"
}
cleanup {
sh 'pkill -f appium || true; sudo ip link set vcan0 down 2>/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 > 0:
delta = (frame["timestamp"] - self.scenario[i-1]["timestamp"]) / speed
if delta <= 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) -> 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 > 10)
total_pixels = gray_diff.size
diff_percent = diff_pixels / total_pixels
result = {
"status": "pass" if diff_percent <= self.threshold else "fail",
"diff_percent": round(diff_percent * 100, 2),
}
if diff_percent > 0:
diff_path = self.diff_dir / f"{baseline_name}_diff.png"
cv2.imwrite(str(diff_path), diff)
highlight = baseline.copy()
mask = gray_diff > 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 小时,真正实现质量左移和快速迭代。
汤不热吧