Selenium与Appium统一PO框架:工厂+适配器模式实现跨平台自动化测试

Selenium与Appium统一PO框架:工厂+适配器模式实现跨平台自动化测试 1. 项目概述为什么我们需要统一的测试框架在自动化测试领域Web端和移动端Mobile的测试常常是割裂的。前端团队用Selenium写一套Page ObjectPO模型移动端团队用Appium再写一套。两套代码两套维护成本两套学习曲线。更头疼的是当业务逻辑在Web和App端高度一致时比如一个电商的登录、搜索、下单流程测试工程师却要维护两套几乎重复的脚本这无疑是巨大的资源浪费。我经历过不止一个项目初期为了快速上线Web和App测试各自为政。结果到了中期业务逻辑稍有变动两边都要改稍有不慎就漏改了一边导致测试用例失效。维护成本呈指数级上升。于是一个很自然的想法就冒出来了能不能让Selenium和Appium共用一套PO代码让同一份业务逻辑描述既能驱动浏览器也能驱动手机App这不仅仅是“偷懒”更是架构上的优化。统一的设计意味着代码复用率最大化核心业务逻辑如“用户登录”、“添加商品到购物车”只需编写和维护一次。降低维护成本业务规则变更时只需修改一处。提升团队协作效率Web和移动端测试工程师可以基于同一套底层框架和模式进行开发知识共享更容易。加速新人上手只需学习一套框架设计模式就能同时开展Web和App的自动化测试。这个项目的核心就是设计一个抽象层将Selenium用于Web和Appium用于Mobile的底层驱动差异封装起来让上层的Page Object只关心“做什么”业务逻辑而不关心“怎么做”是通过浏览器还是通过手机App。接下来我将详细拆解如何实现这套统一框架的设计思路、核心实现以及避坑指南。2. 核心设计思路抽象与封装的艺术要实现Selenium和Appium共用PO代码关键在于“抽象”。我们不能让PO层直接调用driver.find_element_by_idSelenium或driver.find_element(AppiumBy.ID, ...)因为它们的API虽然相似但并非完全一致且底层驱动对象完全不同。2.1 设计模式选择工厂模式 适配器模式这是整个框架的基石。我们将采用两种经典设计模式的组合。工厂模式 (Factory Pattern)用于创建驱动实例。根据配置如platformweb或platformmobile工厂类决定是实例化一个Selenium的WebDriver还是一个Appium的AndroidDriver/IOSDriver。适配器模式 (Adapter Pattern)这是统一PO代码的核心。我们定义一个统一的“元素操作接口”然后为Selenium和Appium分别编写一个“适配器”。这个适配器将统一的接口调用翻译成各自底层驱动的具体API调用。这样PO代码只与这个统一的接口交互完全不知道背后是Selenium还是Appium在干活。2.2 统一元素定位与操作接口我们需要设计一个BasePage类它提供所有页面对象都需要的基础方法但这些方法内部调用的是我们定义的统一接口。这个接口需要涵盖自动化测试中最常用的操作元素查找find_element(locator),find_elements(locator)元素操作click(element),send_keys(element, text),get_text(element),get_attribute(element, name)等待机制wait_for_element(locator, timeout)平台无关的定位器我们需要一种方式来表达“ID为username的输入框”并且让Selenium和Appium都能理解。通常我们可以使用一个元组(by, value)但需要处理by的枚举值在不同平台下的细微差别。2.3 配置驱动与上下文管理框架需要能够根据运行时配置灵活地初始化和切换不同的驱动上下文。例如一个测试套件里可能先跑Web端的测试再跑App端的测试。框架需要能优雅地创建、销毁不同的Driver实例并确保PO对象能绑定到正确的Driver上。3. 核心细节解析与实操要点3.1 定义统一的定位器策略Selenium的By类和Appium的AppiumBy类大部分常量是相同的如ID,XPATH,CLASS_NAME但Appium有一些特有的如ACCESSIBILITY_ID在iOS中是accessibility_id在Android中是content-desc。为了统一我们可以定义一个自己的Locator类或字典结构。实操方案我们可以使用一个简单的元组(strategy, value)其中strategy是我们自定义的字符串。在适配器内部将这些字符串映射到Selenium的By或Appium的AppiumBy。# 统一使用的定位器例如 username_locator (“id”, “com.example.app:id/username”) # Appium # 或 username_locator (“id”, “username”) # Selenium # 甚至可以使用更通用的键值对在适配层做转换 locator {“id”: “username”}更优方案定义一个枚举类LocatorStrategy包含ID,XPATH,CSS_SELECTOR,ACCESSIBILITY_ID,CLASS_NAME等。在创建驱动适配器时根据平台类型将这个枚举值转换为对应的SeleniumBy或AppiumAppiumBy值。from enum import Enum class LocatorStrategy(Enum): ID “id” XPATH “xpath” CSS “css” ACCESSIBILITY_ID “accessibility_id” CLASS_NAME “class_name” # ... 其他 # 在Selenium适配器中 if strategy LocatorStrategy.ACCESSIBILITY_ID: # Appium支持但Selenium没有直接对应的By。可能需要用其他属性定位如CSS [accessibility-id‘xxx’]。 # 这暴露了差异点需要在设计时考虑回退方案或约定使用其他共有定位方式。 raise NotImplementedError(“Selenium does not support ACCESSIBILITY_ID directly”)注意ACCESSIBILITY_ID是主要的差异点。在统一框架中如果Web和App的UI元素都使用了相同的id或test-id属性那是最理想的。如果不行可能需要为同一元素在不同平台准备不同的定位器这可以通过配置来管理稍微增加了PO的复杂度但业务逻辑依然统一。3.2 驱动工厂的实现驱动工厂负责解析配置可以从配置文件、环境变量或命令行参数读取并创建对应的驱动实例。from selenium import webdriver from appium import webdriver as appiumdriver from selenium.webdriver.chrome.options import Options as ChromeOptions from appium.options.common.base import AppiumOptions class DriverFactory: staticmethod def create_driver(config): platform config.get(“platform”, “web”).lower() if platform “web”: browser config.get(“browser”, “chrome”) options ChromeOptions() # 添加一些通用选项如无头模式、禁用沙箱等 if config.get(“headless”, False): options.add_argument(“--headless”) driver webdriver.Chrome(optionsoptions) # 隐式等待等通用设置 driver.implicitly_wait(config.get(“implicit_wait”, 10)) return driver elif platform in [“android”, “ios”]: options AppiumOptions() options.platform_name config.get(“platform_name”, platform.capitalize()) options.device_name config.get(“device_name”, “emulator”) options.app config.get(“app_path”) # 或 app_package/app_activity options.automation_name config.get(“automation_name”, “UiAutomator2”) # 或 XCUITest # ... 设置其他Appium能力 driver appiumdriver.Remote(command_executorconfig[“appium_server”], optionsoptions) return driver else: raise ValueError(f“Unsupported platform: {platform}”)3.3 核心适配器与BasePage类这是最核心的部分。我们创建一个DriverAdapter抽象基类定义统一接口然后实现SeleniumAdapter和AppiumAdapter。from abc import ABC, abstractmethod from typing import Any class DriverAdapter(ABC): “”“驱动适配器抽象基类”“” def __init__(self, driver): self._driver driver abstractmethod def find_element(self, locator): pass abstractmethod def find_elements(self, locator): pass abstractmethod def click(self, element): pass abstractmethod def send_keys(self, element, text): pass abstractmethod def get_text(self, element): pass # ... 其他抽象方法 class SeleniumAdapter(DriverAdapter): “”“Selenium驱动适配器”“” def find_element(self, locator): # locator 可能是一个 (strategy, value) 元组 strategy, value locator # 将自定义strategy映射到Selenium By by_map { “id”: By.ID, “xpath”: By.XPATH, “css”: By.CSS_SELECTOR, “class_name”: By.CLASS_NAME, “name”: By.NAME, “tag_name”: By.TAG_NAME, “link_text”: By.LINK_TEXT, “partial_link_text”: By.PARTIAL_LINK_TEXT, } by by_map.get(strategy) if not by: raise ValueError(f“Unsupported locator strategy for Selenium: {strategy}”) return self._driver.find_element(by, value) def click(self, element): # Selenium的WebElement自带click方法 element.click() # ... 实现其他方法 class AppiumAdapter(DriverAdapter): “”“Appium驱动适配器”“” def find_element(self, locator): strategy, value locator # 将自定义strategy映射到AppiumBy from appium.webdriver.common.appiumby import AppiumBy by_map { “id”: AppiumBy.ID, “xpath”: AppiumBy.XPATH, “css”: AppiumBy.CSS_SELECTOR, # 注意Appium对CSS支持有限通常不建议用 “class_name”: AppiumBy.CLASS_NAME, “accessibility_id”: AppiumBy.ACCESSIBILITY_ID, “android_uiautomator”: AppiumBy.ANDROID_UIAUTOMATOR, # Android特有 “ios_predicate”: AppiumBy.IOS_PREDICATE, # iOS特有 } by by_map.get(strategy) if not by: raise ValueError(f“Unsupported locator strategy for Appium: {strategy}”) return self._driver.find_element(by, value) # ... 实现其他方法有了适配器我们的BasePage就可以只依赖DriverAdapter了。class BasePage: “”“所有Page Object的基类”“” def __init__(self, adapter: DriverAdapter): self._adapter adapter def find_element(self, locator): return self._adapter.find_element(locator) def click(self, locator): element self.find_element(locator) self._adapter.click(element) def input_text(self, locator, text): element self.find_element(locator) self._adapter.send_keys(element, text) def get_element_text(self, locator): element self.find_element(locator) return self._adapter.get_text(element) # 可以封装更复杂的业务等待 def wait_for_element_visible(self, locator, timeout10): # 这里可以调用适配器封装的显式等待或者自己实现轮询 # 简单示例使用隐式等待后的查找不推荐用于显式条件 # 更好的做法是在适配器里实现一个 wait.until 的封装 pass3.4 页面对象PO的统一编写现在具体的页面对象如LoginPage就可以继承BasePage并使用统一的方法来编写了。class LoginPage(BasePage): “”“登录页面兼容Web和App”“” # 定位器定义。理想情况下Web和App使用相同的定位策略和值。 # 如果不同可以通过配置文件或条件判断来加载不同的定位器。 USERNAME_INPUT (“id”, “username”) # 假设Web和App的username输入框id都是‘username’ PASSWORD_INPUT (“id”, “password”) LOGIN_BUTTON (“xpath”, “//button[text()‘登录’]”) def __init__(self, adapter): super().__init__(adapter) def login(self, username, password): “”“登录业务逻辑”“” self.input_text(self.USERNAME_INPUT, username) self.input_text(self.PASSWORD_INPUT, password) self.click(self.LOGIN_BUTTON) # 返回下一个页面对象例如HomePage return HomePage(self._adapter)关键点LoginPage的login方法完全不知道自己在操作浏览器还是手机App。它只关心找到用户名框、输入、找到密码框、输入、点击登录按钮。底层是SeleniumAdapter还是AppiumAdapter在干活由初始化LoginPage时传入的adapter决定。4. 实操过程与核心环节实现4.1 项目结构与配置管理一个清晰的项目结构是框架可维护的基础。建议如下unified_test_framework/ ├── config/ │ ├── web_config.yaml # Web测试配置浏览器类型、URL、隐式等待等 │ ├── android_config.yaml # Android测试配置设备名、app路径、Appium server等 │ └── ios_config.yaml # iOS测试配置 ├── core/ │ ├── __init__.py │ ├── driver_factory.py # 驱动工厂 │ ├── adapter.py # DriverAdapter及其实现SeleniumAdapter, AppiumAdapter │ └── base_page.py # BasePage类 ├── pages/ │ ├── __init__.py │ ├── login_page.py # 登录页面PO │ ├── home_page.py # 主页PO │ └── ... # 其他页面PO ├── tests/ │ ├── conftest.py # Pytest fixtures用于初始化driver和adapter │ ├── test_web_login.py # Web端测试用例 │ └── test_app_login.py # App端测试用例 ├── utils/ │ └── helper.py # 工具函数如读取配置、截图等 └── requirements.txt # 项目依赖配置管理使用YAML或JSON文件管理不同环境的配置。在conftest.py中根据命令行参数或环境变量加载对应的配置并通过DriverFactory创建驱动和适配器。# conftest.py (使用pytest) import pytest import yaml from core.driver_factory import DriverFactory from core.adapter import SeleniumAdapter, AppiumAdapter def load_config(platform): with open(f“config/{platform}_config.yaml”, ‘r’) as f: return yaml.safe_load(f) pytest.fixture(scope“session”) def config(request): # 可以通过命令行参数指定平台例如pytest --platformweb platform request.config.getoption(“--platform”, default“web”) return load_config(platform) pytest.fixture(scope“function”) # 每个测试函数一个driver保证隔离 def driver_adapter(config): driver DriverFactory.create_driver(config) adapter_class SeleniumAdapter if config[“platform”] “web” else AppiumAdapter adapter adapter_class(driver) yield adapter # 测试结束后清理 driver.quit()4.2 测试用例的编写与组织测试用例现在可以专注于测试数据和行为断言页面操作全部委托给PO。# tests/test_web_login.py import pytest from pages.login_page import LoginPage from pages.home_page import HomePage class TestLogin: “”“测试登录功能”“” pytest.mark.web def test_login_success(self, driver_adapter): “”“Web端成功登录测试”“” # 初始化页面对象传入适配器 login_page LoginPage(driver_adapter) # 假设driver_adapter对应的driver已经打开了登录页 # 如果没打开可能需要一个Navigator类来封装页面跳转或者直接在fixture里打开 # driver_adapter._driver.get(config[“base_url”] “/login”) home_page login_page.login(“valid_user”, “valid_pass”) # 断言登录成功例如检查首页是否出现了用户名的欢迎语 welcome_text home_page.get_welcome_text() assert “valid_user” in welcome_text # tests/test_app_login.py class TestAppLogin: pytest.mark.android def test_login_success_on_android(self, driver_adapter): “”“Android端成功登录测试”“” # 代码和test_web_login几乎一模一样 login_page LoginPage(driver_adapter) home_page login_page.login(“valid_user”, “valid_pass”) welcome_text home_page.get_welcome_text() assert “valid_user” in welcome_text看到了吗TestLogin和TestAppLogin里的测试逻辑完全一样。唯一的区别是pytest.mark装饰器和driver_adapterfixture背后加载的配置不同。这就是统一框架的最大价值。4.3 处理平台特有操作虽然我们极力统一但Web和Mobile确实存在一些特有的操作。例如Mobile端有滑屏、捏合缩放、按物理键等Web端有切换窗口/iframe、执行JavaScript等。解决方案在DriverAdapter抽象基类中只为共有操作定义抽象方法。对于平台特有操作可以在具体的适配器类中实现为非抽象方法并在PO层通过判断适配器类型来有条件地调用。class DriverAdapter(ABC): # ... 共有抽象方法 ... # 不把 swipe 定义为抽象方法因为Web没有 class AppiumAdapter(DriverAdapter): # ... 实现共有方法 ... def swipe(self, start_x, start_y, end_x, end_y, duration0): “”“Appium特有的滑屏操作”“” action TouchAction(self._driver) action.press(xstart_x, ystart_y).wait(duration).move_to(xend_x, yend_y).release().perform() class SeleniumAdapter(DriverAdapter): # ... 实现共有方法 ... def execute_script(self, script, *args): “”“Selenium特有的执行JS操作”“” return self._driver.execute_script(script, *args) # 在PO中使用 class HomePage(BasePage): def scroll_to_bottom(self): if isinstance(self._adapter, AppiumAdapter): # 移动端用滑屏 screen_size self._adapter._driver.get_window_size() start_x screen_size[‘width’] / 2 start_y screen_size[‘height’] * 0.8 end_y screen_size[‘height’] * 0.2 self._adapter.swipe(start_x, start_y, start_x, end_y, 500) elif isinstance(self._adapter, SeleniumAdapter): # Web端用JS滚动 self._adapter.execute_script(“window.scrollTo(0, document.body.scrollHeight);”) else: raise NotImplementedError(“Unsupported adapter for scrolling”)注意在PO中判断适配器类型isinstance是一种妥协它破坏了完美的抽象。应尽量将平台差异在更底层封装。例如可以定义一个Scrollable接口或者将“滚动到底部”这个行为也抽象成一个方法在各自的适配器里用不同方式实现。但有时为了快速实现特定功能isinstance判断也是一种务实的方案。5. 常见问题与排查技巧实录在实际落地这套统一框架的过程中我踩过不少坑。这里分享一些典型问题和解决思路。5.1 定位器不兼容问题这是最常见的问题。Web元素和App元素的属性往往不同。问题App端元素常用resource-idAndroid或accessibility_idiOS而Web端就是id。即使都是id值也可能不同。解决思路推动开发规范在项目初期与开发团队约定为可测试性添加统一的测试ID属性例如># locators/login_page.yaml web: username: {“strategy”: “id”, “value”: “username”} password: {“strategy”: “id”, “value”: “password”} android: username: {“strategy”: “id”, “value”: “com.example.app:id/et_username”} password: {“strategy”: “id”, “value”: “com.example.app:id/et_password”} ios: username: {“strategy”: “accessibility_id”, “value”: “Username Field”} password: {“strategy”: “accessibility_id”, “value”: “Password Field”}在BasePage的初始化中根据当前平台加载对应的定位器字典。条件定位在PO的定位器属性中做简单判断。property def username_locator(self): if self._adapter.platform “web”: return (“id”, “username”) else: # mobile return (“id”, “com.example.app:id/et_username”)5.2 等待机制差异Selenium和Appium的显式等待API几乎一样WebDriverWait但等待的条件expected_conditions在Appium中略有不同位于appium.webdriver.common.appiumby和selenium.webdriver.support.expected_conditions的混合。另外移动端加载和动画更多等待时间可能需要调整。问题直接使用Selenium的EC.visibility_of_element_located在Appium上可能不奏效或者移动端需要更长的超时时间。解决思路统一等待工具类封装一个自己的Wait类内部根据平台选择导入不同的expected_conditions模块并提供一套通用的等待条件如element_visible,element_clickable。参数化等待时间在配置文件中为不同平台设置不同的默认超时和轮询间隔。自定义等待条件为移动端特有的状态如Toast提示出现消失、页面切换动画结束编写自定义的等待条件并集成到统一的等待工具中。5.3 驱动生命周期管理Web测试通常每个用例或类初始化一个driver。而Appium测试为了节省启动App的时间有时会用一个driver跑多个用例。问题如何设计fixture如果使用pytest来优雅地管理这两种不同生命周期的driver解决思路使用scope参数在conftest.py中可以根据配置决定driver_adapterfixture的作用域。pytest.fixture(scopeconfig.get(“driver_scope”, “function”)) def driver_adapter(config): # ... 创建逻辑 ...在Web配置中设置driver_scope: function在App配置中设置driver_scope: session需注意用例间的状态隔离如退出登录。重启策略对于Appium即使scope‘session’也可以在fixture中判断如果遇到某些崩溃或异常状态自动重启driver和App。5.4 测试报告与截图测试失败时截取屏幕对于Debug至关重要。Web的截图和App的截图API相同driver.save_screenshot但保存的路径和命名可能需要统一管理。解决思路在BasePage或一个单独的Reporter工具类中封装一个screenshot方法。该方法调用driver.save_screenshot并按照统一的规则生成文件名包含时间戳、平台、用例名保存到指定目录。这个方法是平台无关的可以放心放在基类里。5.5 框架的扩展性未来如果还要集成桌面应用测试如Windows上的WinAppDriver呢解决思路我们的框架设计是开放的。只需要为新的桌面驱动创建一个新的适配器类如WinAppDriverAdapter实现DriverAdapter接口。在DriverFactory中添加创建WinAppDriver的逻辑。准备一套对应的定位器配置桌面应用可能用accessibility_id或name。 原有的PO代码和测试用例几乎不需要改动就能支持新的测试平台。这充分证明了抽象和封装带来的巨大优势。6. 性能优化与最佳实践当框架稳定运行后可以考虑一些优化点来提升体验和效率。6.1 使用Page Factory模式简化PO原始的PO写法每个元素都要调用find_element。可以使用类似SeleniumPageFactory的模式通过装饰器或元类在访问页面属性时自动完成元素查找。# 一个简单的自定义PageFactory思路 def find_by(locator): “”“装饰器将方法转换为返回查找元素的属性”“” def decorator(func): property def wrapper(self): return self._adapter.find_element(locator) return wrapper return decorator class LoginPage(BasePage): find_by((“id”, “username”)) def username_input(self): pass # 函数体不会被调用装饰器已将其变为属性 def login(self, user, pwd): # 使用起来更简洁 self.username_input.send_keys(user) # 这里self.username_input已经是WebElement了6.2 并行测试支持统一的框架更容易集成到CI/CD流水线中并支持并行测试。可以使用pytest-xdist插件。关键在于确保每个并行进程使用的driver端口、设备或用户会话是独立的避免冲突。对于Web确保每个进程使用独立的浏览器用户数据目录或匿名会话。对于Mobile需要多台设备或模拟器或者在云测平台如Sauce Labs, BrowserStack上配置不同的设备标识。6.3 日志与监控在适配器的方法中加入详细的日志记录对于排查元素查找失败、操作超时等问题非常有帮助。class LoggingAdapter(DriverAdapter): “”“一个带日志记录的适配器装饰器/包装器”“” def __init__(self, wrapped_adapter): self._wrapped wrapped_adapter self.logger logging.getLogger(__name__) def find_element(self, locator): self.logger.debug(f“Finding element with locator: {locator}”) try: element self._wrapped.find_element(locator) self.logger.debug(“Element found.”) return element except Exception as e: self.logger.error(f“Failed to find element {locator}: {e}”) raise # ... 包装其他方法 ...7. 总结与个人体会构建这样一套Selenium与Appium共用的PO框架前期在抽象层和适配器上的投入会在项目中后期得到十倍、百倍的回报。它迫使你思考什么是测试的本质——是验证业务逻辑而不是纠缠于find_element_by_xpath和find_element(AppiumBy.XPATH, ...)的语法差异。我个人最大的体会是统一框架的成功30%在于技术实现70%在于团队协作和规范。你必须和开发、产品同学沟通推动为UI元素添加可测试的标识。你必须编写清晰的文档让团队其他成员理解这套模式。你必须在代码评审中坚持使用统一的BasePage和适配器接口。一开始可能会觉得有点“重”不如直接写脚本快。但当你看到第一个业务需求变更只需要修改一个PO文件然后Web和App的所有相关测试用例都自动通过时那种成就感和效率提升是无可比拟的。这套框架不仅统一了代码更在某种程度上统一了Web和移动端的测试思维让质量保障变得更加高效和优雅。最后一个小技巧在框架开发的早期可以先针对一个最简单的用户流程如登录实现端到端的打通。用这个“最小可行产品”去验证框架设计的可行性并获得团队的初步反馈。之后再逐步将其他页面和功能迁移进来这样迭代的风险和阻力会小很多。