你写过的那种"先跑起来再说"的单机脚本,三个月后连你自己都看不懂。工程化不是加装饰------是让代码活得比你久。
为什么你的"自动化脚本"活不过三个月
先看一个典型场景:刚入职时写的蓝牙控车测试脚本,PyTest + Appium 一把梭,500 行塞在一个文件里。三个月后需求变了------新增一个车型,控车流程多了两步------接手的同事盯着你的 test_ble_control_v2_fixed_final.py 看了半小时,决定:重写。
单机脚本死在三个地方:
- 没有抽象层:用例逻辑和元素定位、设备操作、断言混在一起
- 没有失败诊断:截图只截全屏,没有任何上下文,排查靠猜
- 没有 CI 集成:用例在本地能跑,没人知道什么时候开始不能跑了
下面是我在一线汽车大厂把一套车联 APP 自动化从"单机脚本"升级到"工程化框架"的完整方案。
一、Page Object 在车联场景的适配
传统的 Page Object 模式假设一个页面 = 一个类,页面上的操作封装为方法。但车联 APP 的场景不是这样的:
- 首页:车辆状态卡片是动态渲染的,不同车型、不同配置,卡片数量和内容都不一样
- 控车页:空调/门窗/充电------操作结果依赖车辆真实状态,不是"点一下等 2 秒"就能解决的
- 蓝牙页:连接状态是一个持续变化的过程(扫描→连接→配对→已连接),每一步都可能超时
1.1 三层抽象:页面层 → 组件层 → 操作层
我把传统 Page Object 拆成了三层:
markdown
Page(页面层)
└── Component(组件层):可复用的 UI 组件
└── Action(操作层):原子操作 + 等待策略
python
# 框架核心:BasePage + Component 组合
class BasePage:
"""页面基类,封装 driver + 等待 + 截图"""
def __init__(self, driver, config):
self.driver = driver
self.config = config
self.wait = WebDriverWait(driver, config.timeout)
def find(self, locator):
"""统一元素查找,内置重试"""
return self.wait.until(EC.presence_of_element_located(locator))
def screenshot_with_context(self, name, **metadata):
"""带上下文的截图------后面会展开讲"""
pass
class Component:
"""可复用的 UI 组件基类"""
def __init__(self, page: BasePage, locators: dict):
self.page = page
self.locators = locators
def exists(self) -> bool:
"""组件是否在页面上(适应动态渲染)"""
try:
self.page.driver.find_element(*self.locators["root"])
return True
except NoSuchElementException:
return False
1.2 车联场景特有的组件
以车辆状态卡片为例------不同车型的卡片内容完全不同:
python
class VehicleStatusCard(Component):
"""车辆状态卡片------适配不同车型"""
STATUS_MAP = {
"default": ["range", "battery", "odometer", "lock_status"],
"ev_only": ["range", "battery", "charging_status", "precondition"],
"ice_only": ["fuel_level", "odometer", "lock_status"],
}
def __init__(self, page, vehicle_type="default"):
super().__init__(page, self._build_locators(vehicle_type))
self.vehicle_type = vehicle_type
self.expected_fields = self.STATUS_MAP.get(vehicle_type, self.STATUS_MAP["default"])
def get_status(self) -> dict:
"""读取当前车辆状态,只读本车型存在的字段"""
result = {}
for field in self.expected_fields:
if field in self.locators:
result[field] = self.page.find(self.locators[field]).text
return result
def verify_fields_complete(self) -> bool:
"""验证关键字段是否全部渲染"""
actual = set(self.get_status().keys())
expected = set(f for f in self.expected_fields if f in self.locators)
return actual == expected
这里的关键设计:车型配置驱动组件行为,而非硬编码。新增车型 = 新增一个 STATUS_MAP 条目 + 对应 locator,不碰用例逻辑。
二、失败诊断:截图不够,上下文才够
自动化用例失败分三种:
| 类型 | 表现 | 单张截图能看出来吗 |
|---|---|---|
| 元素定位失败 | NoSuchElement | 看全屏截图只能看到"页面上有个元素找不到",不知道它在哪 |
| 断言失败 | assert != expected | 看到截图也没用,需要知道实际值和期望值 |
| 环境异常 | 网络/蓝牙/登录态丢失 | 截图看不出来 |
2.1 结构化失败快照
我在每个失败点采集的不只是一张截图:
python
class FailureSnapshot:
"""结构化失败快照"""
def capture(self, test_name: str, context: dict):
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
base_path = f"artifacts/{test_name}/{timestamp}"
artifacts = {
"screenshot_full": self._capture_screenshot(base_path),
"screenshot_element": self._capture_element_screenshot(base_path, context.get("target_element")),
"page_source": self._capture_page_source(base_path),
"context": context, # step_name, expected, actual, locator 等
"device_log": self._capture_logcat(base_path, lines=200),
"app_log": self._capture_app_log(base_path),
}
# 写入结构化 JSON 供 CI 报告消费
with open(f"{base_path}/failure_report.json", "w") as f:
json.dump(artifacts, f, indent=2, default=str)
return base_path
六个维度的信息,任何一个都比单张截图有用。
2.2 日志关联
Appium 的日志和用例步骤日志是两条线,排查时得来回对照。我只做了一件事:每条操作日志带用例上下文。
python
import logging
class ContextLogger:
"""带上下文的日志------每条日志都带 test_case + step"""
def __init__(self, test_name: str):
self.test_name = test_name
self.step_counter = 0
self.logger = logging.getLogger(test_name)
def step(self, description: str):
self.step_counter += 1
self.logger.info(f"[{self.test_name}][Step{self.step_counter}] {description}")
return self
def action(self, action: str, detail: str = ""):
self.logger.debug(f"[{self.test_name}][Step{self.step_counter}][{action}] {detail}")
效果:查日志时直接 grep "TC_MY23_AC_001\[Step5\]" 就能定位到具体步骤的所有相关信息,不用在全量日志里游泳。
三、与 CI 的集成:把"人要跑"变成"机器必跑"
工程化的标志不是代码写得多漂亮------是 没人跑的时候它也在跑,而且跑完有人管。
3.1 GitLab CI 流水线设计
yaml
# .gitlab-ci.yml
ui-automation:
stage: regression
image: android-appium:3.1
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule"'
when: always # 定时回归:每天凌晨执行全量
- if: '$CI_COMMIT_TAG =~ /^release/'
when: always # 发版前:强制通过
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
when: manual # MR 到主干:手动触发
script:
- pip install -r requirements.txt
- pytest tests/ --alluredir=allure-results
--device-type=${DEVICE_TYPE:-real}
--retries=2
--maxfail=10
- allure generate allure-results -o allure-report --clean
artifacts:
when: always
paths:
- allure-report/
- artifacts/
expire_in: 30 days
tags:
- android-device-farm
3.2 失败通知:只发有用的
全量回归 200 条用例,失败 3 条------你发 200 行的测试报告没人看。我只发一条钉钉/企微消息:
yaml
🔴 UI自动化回归 · main 分支
失败: 3/188 (98.4%)
├── TC_lock_unlock_001: 车门状态未同步 (发生 2 次)
├── TC_ble_scan_003: 蓝牙扫描超时
└── 详情 → <allure报告链接>
只列失败项,不给红绿表格。 看自动化报告的人只需要知道"哪个用例挂了、挂了多久"。
四、可复用的框架目录结构
bash
car-app-automation/
├── pages/ # Page Object
│ ├── base_page.py
│ ├── home_page.py
│ ├── vehicle_control_page.py
│ └── bluetooth_page.py
├── components/ # 可复用 UI 组件
│ ├── vehicle_status_card.py
│ ├── climate_panel.py
│ └── navigation_bar.py
├── fixtures/ # 测试固件
│ ├── vehicle_states.py # "车在车位/行驶中/充电中"等状态预设
│ └── mock_tbox.py # Mock T-Box 指令响应
├── utils/
│ ├── failure_snapshot.py # 结构化失败快照
│ ├── context_logger.py # 上下文日志
│ └── vehicle_config.py # 车型/配置映射
├── conftest.py # PyTest 全局 fixture
├── tests/
│ ├── test_vehicle_control/
│ └── test_bluetooth/
└── ci/
└── .gitlab-ci.yml
直接 git clone, pip install -r requirements.txt, 跑。不需要看 10 页的环境配置文档------那是工程化失败的标志。
总结
车联 APP UI 自动化框架的工程化,核心就三件事:
- 抽象做对:Page → Component → Action 三层,车型差异走配置不走代码
- 失败能诊断:截图 + 页面源码 + 设备日志 + 上下文,六维快照替代单张截图
- CI 闭环:定时跑 → 自动通知 → 只发失败 → 人只在需要时介入
我们花了两个月把 500 行单文件脚本重构到这个框架上,最大的收益不是"跑得更快"------是三个月后换人接手,新人用了半天就加上了第三种车型的适配。那个对着 test_ble_control_v2_fixed_final.py 发懵的场景,再也没出现过。
🏷️ [APP测试] #UI自动化 #PageObject #工程化 #车联APP
📖 下一篇:APP测试 多车型、多品牌的自动化适配策略 ------ 一套框架覆盖不同年款/配置,抽象车端能力模型,用配置驱动而不是 if-else 堆叠。