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

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

1. 项目概述:为什么我们需要统一的测试框架?

在自动化测试领域,Web端和移动端(Mobile)的测试常常是割裂的。前端团队用Selenium写一套Page Object(PO)模型,移动端团队用Appium再写一套。两套代码,两套维护成本,两套学习曲线。更头疼的是,当业务逻辑在Web和App端高度一致时,比如一个电商的登录、搜索、下单流程,测试工程师却要维护两套几乎重复的脚本,这无疑是巨大的资源浪费。

我经历过不止一个项目,初期为了快速上线,Web和App测试各自为政。结果到了中期,业务逻辑稍有变动,两边都要改,稍有不慎就漏改了一边,导致测试用例失效。维护成本呈指数级上升。于是,一个很自然的想法就冒出来了:能不能让Selenium和Appium共用一套PO代码?让同一份业务逻辑描述,既能驱动浏览器,也能驱动手机App?

这不仅仅是“偷懒”,更是架构上的优化。统一的设计意味着:

  1. 代码复用率最大化:核心业务逻辑(如“用户登录”、“添加商品到购物车”)只需编写和维护一次。
  2. 降低维护成本:业务规则变更时,只需修改一处。
  3. 提升团队协作效率:Web和移动端测试工程师可以基于同一套底层框架和模式进行开发,知识共享更容易。
  4. 加速新人上手:只需学习一套框架设计模式,就能同时开展Web和App的自动化测试。

这个项目的核心,就是设计一个抽象层,将Selenium(用于Web)和Appium(用于Mobile)的底层驱动差异封装起来,让上层的Page Object只关心“做什么”(业务逻辑),而不关心“怎么做”(是通过浏览器还是通过手机App)。接下来,我将详细拆解如何实现这套统一框架的设计思路、核心实现以及避坑指南。

2. 核心设计思路:抽象与封装的艺术

要实现Selenium和Appium共用PO代码,关键在于“抽象”。我们不能让PO层直接调用driver.find_element_by_id(Selenium)或driver.find_element(AppiumBy.ID, ...),因为它们的API虽然相似,但并非完全一致,且底层驱动对象完全不同。

2.1 设计模式选择:工厂模式 + 适配器模式

这是整个框架的基石。我们将采用两种经典设计模式的组合。

  • 工厂模式 (Factory Pattern):用于创建驱动实例。根据配置(如platform=webplatform=mobile),工厂类决定是实例化一个Selenium的WebDriver,还是一个Appium的AndroidDriver/IOSDriver。
  • 适配器模式 (Adapter Pattern):这是统一PO代码的核心。我们定义一个统一的“元素操作接口”,然后为Selenium和Appium分别编写一个“适配器”。这个适配器将统一的接口调用,翻译成各自底层驱动的具体API调用。

这样,PO代码只与这个统一的接口交互,完全不知道背后是Selenium还是Appium在干活。

2.2 统一元素定位与操作接口

我们需要设计一个BasePage类,它提供所有页面对象都需要的基础方法,但这些方法内部调用的是我们定义的统一接口。这个接口需要涵盖自动化测试中最常用的操作:

  1. 元素查找find_element(locator),find_elements(locator)
  2. 元素操作click(element),send_keys(element, text),get_text(element),get_attribute(element, name)
  3. 等待机制wait_for_element(locator, timeout)
  4. 平台无关的定位器:我们需要一种方式来表达“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元素都使用了相同的idtest-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(options=options) # 隐式等待等通用设置 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_executor=config[“appium_server”], options=options) return driver else: raise ValueError(f“Unsupported platform: {platform}”)

3.3 核心适配器与BasePage类

这是最核心的部分。我们创建一个DriverAdapter抽象基类,定义统一接口,然后实现SeleniumAdapterAppiumAdapter

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, timeout=10): # 这里可以调用适配器封装的显式等待,或者自己实现轮询 # 简单示例:使用隐式等待后的查找(不推荐用于显式条件) # 更好的做法是在适配器里实现一个 `wait.until` 的封装 pass

3.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)

关键点LoginPagelogin方法完全不知道自己在操作浏览器还是手机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 --platform=web 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

看到了吗?TestLoginTestAppLogin里的测试逻辑完全一样。唯一的区别是@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, duration=0): “”“Appium特有的滑屏操作”“” action = TouchAction(self._driver) action.press(x=start_x, y=start_y).wait(duration).move_to(x=end_x, y=end_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-id(Android)或accessibility_id(iOS),而Web端就是id。即使都是id,值也可能不同。
  • 解决思路
    1. 推动开发规范:在项目初期,与开发团队约定,为可测试性添加统一的测试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的初始化中,根据当前平台加载对应的定位器字典。
    2. 条件定位:在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.appiumbyselenium.webdriver.support.expected_conditions的混合)。另外,移动端加载和动画更多,等待时间可能需要调整。

  • 问题:直接使用Selenium的EC.visibility_of_element_located在Appium上可能不奏效,或者移动端需要更长的超时时间。
  • 解决思路
    1. 统一等待工具类:封装一个自己的Wait类,内部根据平台选择导入不同的expected_conditions模块,并提供一套通用的等待条件(如element_visible,element_clickable)。
    2. 参数化等待时间:在配置文件中为不同平台设置不同的默认超时和轮询间隔。
    3. 自定义等待条件:为移动端特有的状态(如Toast提示出现消失、页面切换动画结束)编写自定义的等待条件,并集成到统一的等待工具中。

5.3 驱动生命周期管理

Web测试通常每个用例或类初始化一个driver。而Appium测试,为了节省启动App的时间,有时会用一个driver跑多个用例。

  • 问题:如何设计fixture(如果使用pytest)来优雅地管理这两种不同生命周期的driver?
  • 解决思路
    1. 使用scope参数:在conftest.py中,可以根据配置决定driver_adapterfixture的作用域。
      @pytest.fixture(scope=config.get(“driver_scope”, “function”)) def driver_adapter(config): # ... 创建逻辑 ...
      在Web配置中设置driver_scope: function,在App配置中设置driver_scope: session(需注意用例间的状态隔离,如退出登录)。
    2. 重启策略:对于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)呢?

  • 解决思路:我们的框架设计是开放的。只需要:
    1. 为新的桌面驱动创建一个新的适配器类(如WinAppDriverAdapter),实现DriverAdapter接口。
    2. DriverFactory中添加创建WinAppDriver的逻辑。
    3. 准备一套对应的定位器配置(桌面应用可能用accessibility_idname)。 原有的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_xpathfind_element(AppiumBy.XPATH, ...)的语法差异。

我个人最大的体会是:统一框架的成功,30%在于技术实现,70%在于团队协作和规范。你必须和开发、产品同学沟通,推动为UI元素添加可测试的标识。你必须编写清晰的文档,让团队其他成员理解这套模式。你必须在代码评审中坚持使用统一的BasePage和适配器接口。

一开始可能会觉得有点“重”,不如直接写脚本快。但当你看到第一个业务需求变更,只需要修改一个PO文件,然后Web和App的所有相关测试用例都自动通过时,那种成就感和效率提升是无可比拟的。这套框架不仅统一了代码,更在某种程度上统一了Web和移动端的测试思维,让质量保障变得更加高效和优雅。

最后一个小技巧:在框架开发的早期,可以先针对一个最简单的用户流程(如登录)实现端到端的打通。用这个“最小可行产品”去验证框架设计的可行性,并获得团队的初步反馈。之后再逐步将其他页面和功能迁移进来,这样迭代的风险和阻力会小很多。