给 iOS、Android、Web 抽象同一套页面对象模型来驱动自动化测试,我们踩过的坑和最终设计
在 iOS、Android、Web 三端统一页面对象模型这件事上,我们最终确认了一个核心原则:只抽象行为语义,不抽象 UI 结构。这个决策是踩了整整四个月的坑之后才定下来的。
最初的设计思路很朴素——既然三端做的是同一个业务,页面结构理应相似,那把页面控件统一命名、统一操作接口,测试脚本就能跨端复用。但当我们真正开始落地时,问题一个接一个冒出来。
第一版的理想主义设计
2023 年 Q2,我们开始动手设计这套跨端 POM。当时团队维护着 iOS(XCUITest)、Android(Espresso)、Web(Playwright)三套独立的自动化测试仓库,共约 600 条用例,每次需求同步都要在三端分别实现一遍。
我们的第一版抽象长这样:
class LoginPage(BasePage):
username_field = Element(id="username")
password_field = Element(id="password")
login_button = Element(id="login_btn")
def login(self, username, password):
self.username_field.send_keys(username)
self.password_field.send_keys(password)
self.login_button.click()
Element 定位器背后会根据平台分发到不同的实现——iOS 走 accessibilityIdentifier,Android 走 resource-id,Web 走 CSS selector。看起来很美,但第一个项目接入就崩了。
我们拿这套模型去套一个商品详情页,三端的 UI 结构差异远超预期。iOS 用了 UINavigationController 的返回手势,页面顶部有个系统级返回按钮;Android 的返回键在底部导航栏;Web 端根本没有返回按钮,用户直接点浏览器的后退。同一个"返回"操作,三端的交互路径完全不同。强行用统一的 Element("back_button") 去封装,要么在某一端找不到控件,要么找到了但操作逻辑对不上。
更棘手的是列表项。iOS 端我们用的是 UITableView,Android 端是 RecyclerView,Web 端是虚拟滚动的 div 列表。三端的列表项复用机制、可见性判定、滑动加载逻辑都不一样。第一版抽象里我们写了个 scroll_to_item(index) 方法,iOS 上要调 swipeUp 直到元素可见,Android 上要调 scrollToPosition,Web 上要调 scrollIntoViewIfNeeded。代码里塞满了 if-platform 分支,抽象层比实现层还重。
踩坑:控件定位的跨端噩梦
控件定位是第二个大坑。我们的 iOS 项目在 2022 年做了无障碍改造,所有 UI 控件都加了 accessibilityIdentifier,命名规范是 screen_action_element,比如 login_tap_loginButton。Android 端用的是 resource-id,命名是驼峰式的 loginBtn。Web 端 QA 习惯用 data-testid,命名是短横线式 login-btn。
第一版我们试图用映射表统一:
ELEMENT_MAP = {
"ios": {"login_button": "login_tap_loginButton"},
"android": {"login_button": "loginBtn"},
"web": {"login_button": "[data-testid='login-btn']"}
}
每个页面维护这样一张映射表,一个中型页面的映射表轻松超过 200 行。更别说三端的控件粒度还经常不一致——iOS 里一个日期选择器是一个整体控件 UIDatePicker,Android 里是三个独立的 EditText(年、月、日),Web 里是一个 input type="date"。到底该抽象成三个 Element 还是一个?无论怎么选,总有一端的实现很别扭。
到 2023 年 8 月,我们已经有 14 个页面接入了这套模型,维护成本不降反升。映射表经常因为某一端的 UI 微调而需要更新,三端测试同学互相等对方改好映射表才能跑通用例,比原来各自维护还慢。
转向行为语义抽象
9 月份我们做了一次架构评审,决定推翻重来。核心变化是:不再抽象控件,只抽象行为。
新设计里,POM 层不再暴露具体的 Element,而是暴露业务行为:
class LoginPage(BasePage):
def login(self, username: str, password: str):
self.driver.perform(Action.LOGIN,
credentials={"username": username, "password": password})
perform 方法内部会根据平台路由到对应的执行器。iOS 执行器知道自己要去调 XCUITest 的 typeText 和 tap;Android 执行器走 Espresso 的 typeText 和 click;Web 执行器走 Playwright 的 fill 和 click。控件定位完全封装在执行器内部,POM 层不需要知道这个按钮叫 login_btn 还是 loginButton。
这个设计的关键在于 Action 的定义。我们参考了 WebDriver 的 W3C 标准,把所有的用户操作归纳为 12 种原子 Action:TAP、SWIPE、TYPE、SCROLL_TO、LONG_PRESS、DOUBLE_TAP 等等。每个 Action 带有平台无关的参数,比如 TYPE 带文本内容,SWIPE 带方向和距离。
具体到执行器实现,以 iOS 为例:
class IOSActionExecutor: ActionExecutor {
func execute(_ action: Action, params: [String: Any]) {
switch action {
case .TAP:
let identifier = resolveAccessibilityId(for: params["target"] as! String)
XCUIApplication().buttons[identifier].tap()
case .TYPE:
let identifier = resolveAccessibilityId(for: params["target"] as! String)
let text = params["text"] as! String
XCUIApplication().textFields[identifier].typeText(text)
// ...
}
}
}
resolveAccessibilityId 方法内部维护了行为到控件标识的映射,但这个映射是按端独立管理的,不要求跨端统一。iOS 团队可以按自己的命名规范维护,Android 团队也一样。
页面状态的一层抽象
行为抽象解决之后,下一个难题是页面状态的验证。跨端测试里最常见的断言是"某个元素是否显示"、"某个文案是否正确",这些仍然依赖控件定位。
我们没有走回头路去抽象控件,而是引入了 ViewState 的概念。每个页面暴露的不是控件列表,而是一组可查询的状态:
class ProductDetailPage(BasePage):
@property
def product_title(self) -> str:
return self.driver.query(ViewState.TITLE)
@property
def is_sold_out(self) -> bool:
return self.driver.query(ViewState.SOLD_OUT_BADGE_VISIBLE)
query 方法同样按端分发。iOS 端知道 TITLE 对应的是 navigationBar.staticTexts 的第一个元素;Android 端知道是 id/toolbar_title 的 text 属性;Web 端是 h1.product-title 的 textContent。
这个设计有个硬约束:每个 ViewState 必须在三端都能取到值。如果某一端取不到(比如 iOS 没有"加入购物车"的悬浮按钮,而是用导航栏右侧按钮),那这个 ViewState 就不该放在共享 POM 里,应该下沉到端各自的测试用例中处理。
我们定了一条规则:共享 POM 只放三端完全一致的业务状态,端特有的交互留在端自己的测试层。这听起来是妥协,实际上让我们避免了大量为了统一而统一的扭曲设计。
数据驱动层与 POM 的边界
行为抽象和状态抽象稳定后,我们又踩了一个新坑:测试数据的构造混进了 POM。
举个例子,下单流程需要先创建订单、选择支付方式、输入密码。有同事在 POM 层写了 create_order_and_pay 这样的组合方法,里面既调用了下单的 Action,又调用了 mock 支付网关的接口。POM 层开始承担业务流程编排的职责,变得臃肿不堪。
我们后来明确了分层:POM 只负责页面行为和状态,业务流程编排放在 TestCase 层,数据构造放在 DataFixture 层。POM 里不应该出现任何 API 调用或数据库操作。
class CheckoutTest:
def test_alipay_checkout(self):
# 数据构造
order = DataFixture.create_order(payment_method="alipay")
# 页面操作
checkout_page = CheckoutPage(self.driver)
checkout_page.select_payment("支付宝")
checkout_page.confirm_payment()
# 页面验证
assert checkout_page.payment_status == "success"
这个分层在 2023 年 12 月的一次 Code Review 中被正式写入团队的编码规范。
跨端差异的兜底策略
即使只抽象行为语义,三端的差异仍然会在某些场景下溢出。比如 iOS 的系统弹窗(定位权限、通知权限),Android 的运行时权限弹窗,Web 端根本不弹这些。我们的处理方式是给 POM 加了一个平台能力声明:
class PageCapability:
HANDLE_SYSTEM_PERMISSION = "handle_system_permission"
HANDLE_LOCATION_DIALOG = "handle_location_dialog"
每个 POM 可以声明自己需要哪些平台特殊能力,执行器在初始化时会检查当前平台是否支持。不支持的能力,相关的测试用例直接 skip,而不是报错。
这个机制在 2024 年 1 月救了我们一次。iOS 16.4 改了通知权限弹窗的触发时机,我们的 iOS 执行器还没适配,但 Android 和 Web 端的用例照常跑,iOS 端相关的 3 条用例被自动跳过,没有阻塞 CI。
运行数据
到 2024 年 3 月,这套新模型已经接入了 37 个页面,覆盖了电商 App 的主要业务流程。三端共享的 POM 代码约 4200 行,端各自的执行器代码约 1800-2500 行不等。原来三端各自维护的测试代码总量约 18000 行,现在降到约 11000 行(含共享 POM + 端执行器 + 端特有测试)。
用例编写效率的提升更明显。新增一个跨端用例,原来需要三端各写一份,耗时约 2 人天;现在只需要写一份共享脚本,0.5 人天,端特有的验证逻辑再加 0.3 人天。维护成本上,一个 UI 变更原来要改三处,现在如果只是控件位置或标识变化,只需改对应端的执行器,共享 POM 不动。
最大的意外收获是:这套行为抽象意外地成了产品和设计的"活文档"。产品经理开始看我们的 Action 定义来理解一个页面到底支持哪些用户操作,设计师也会对照 ViewState 列表检查自己的设计稿是否遗漏了某些状态。
常见问题
跨端 POM 真的能做到 100% 复用吗?
做不到,也不应该追求 100%。我们的经验是,核心业务流程(登录、下单、搜索等)的复用率能达到 80-90%,但端特有的交互(如 iOS 的 3D Touch、Android 的返回键逻辑)必须留在端自己的测试层。强行统一只会让代码更难维护。
如果三端的 UI 差异很大,行为抽象还有意义吗?
有意义,但要重新评估"同一页面"的定义。如果三端的页面结构完全不同,说明它们本质上不是同一个页面,应该拆成不同的 POM。行为抽象的前提是三端确实在解决同一个用户任务,只是实现方式不同。如果连用户任务都不同了,那就不该强行统一。
控件定位的映射表谁来维护?
按端维护,各端的测试开发各自负责。iOS 的 accessibilityIdentifier 由 iOS 测试开发在 iOS 执行器里管理,Android 同理。跨端的 POM 层不碰具体的定位符,只调用 Action。这样避免了跨团队协调的延迟。
这套模型适合什么规模的团队?
我们团队是 4 个测试开发 + 8 个业务测试,维护三端约 600 条用例。低于这个规模可能不值得投入——搭建执行器框架、定义 Action 体系的前期成本在 2-3 人月左右。如果只有一两端或者用例数不到 100 条,直接用各端原生框架更划算。