给 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 条,直接用各端原生框架更划算。