
最近帮团队把UI自动化测试从“脚本堆”重构成了“页面对象模型”框架恰好有好几位同学在问“框架到底怎么搭”“POM到底怎么落地”。其实UI自动化这块最难的不是写脚本而是写完脚本之后的维护成本。如果页面一改、弹窗一换用例就红一大片那这套自动化基本等于白做。今天这篇我就用一套可复现的方案带你从零开始把UI自动化测试框架搭起来重点是把页面对象模型Page Object Model简称POM的每一步都拆开讲清楚包括为什么要这么设计、代码怎么组织、踩过哪些坑。整个过程我会尽量少讲虚的直接把我在项目里验证过的方案和代码结构拿出来。无论你是刚入门测试开发还是已经在写Selenium脚本但感觉维护困难这篇都适合你。1. 先弄清为什么非用页面对象模型不可1.1 不写POM的UI自动化到底有多痛我见过不少项目开始写UI自动化时特别爽用Selenium直接定位元素一个用例从头写到尾几十行代码搞定一个场景。但等用例量从10条涨到100条时问题开始集中爆发页面结构一调整所有涉及那个页面的用例全部要改定位表达式散落在各个用例里想统一维护一个按钮的定位都非常费劲测试脚本和页面结构耦合太深写脚本的同学要去理解页面细节。这种写法本质上就是“脚本思维”。每个用例都在直接操作元素页面变成了脚本里的一堆findElement。一旦页面重构维护时间甚至比重写一遍还长。所以POM的核心思想很朴素把页面抽象成对象把页面上的元素和操作封装在独立的“页面类”里测试用例只需要关心业务流程不应该关心这个按钮在HTML里的id是什么。打个比方你在餐厅点菜不需要知道后厨怎么用燃气灶你只需要对着菜单说“来一份鱼香肉丝”。页面对象就相当于这份菜单后厨的油烟是页面内部的HTML结构。用例只跟“菜单”打交道页面改了改菜单实现点菜的人不用动。1.2 页面对象模型的分层逻辑POM的价值不只是“封装元素定位”它本质上逼着你把整个自动化框架分成清晰的层级。我习惯把它分为四层测试用例层Testcase描述业务场景比如“登录成功后进入首页”“添加商品到购物车”。这一层只关心“做什么”不关心“怎么实现”。页面对象层Pages每个页面一个类类里放这个页面独有的元素定位、操作方法、状态判断。比如登录页有输入用户名、输入密码、点击登录、提示错误信息这些元素和动作。基础封装层BasePage页面的公共能力比如元素等待、截图、日志、滚动、切换窗口等。所有页面类继承这个基类避免每个页面重复写等待逻辑。工具与配置层Utils/Config读取配置文件、连接数据库、生成测试数据、发送报告等。这样分层之后好处立竿见影页面改版只改页面对象层新增用例只写用例层公共逻辑调整只改BasePage。三个层次互不干扰代码可维护性大幅提升。可能有人觉得POM会多写不少代码确实前期会比“一把梭”多出30%的样板代码但自动化用例进入维护期后这个投入会成倍回本。2. 环境准备与项目骨架搭建2.1 技术选型与安装UI自动化的技术栈很多我这边用的是Python Selenium pytest Allure这个组合在开源生态、社区资料、招聘匹配度上都比较均衡。安装部分直接给命令# 创建虚拟环境强烈推荐避免污染全局Python python -m venv venv source venv/bin/activate # Windows下用 venv\Scripts\activate # 安装核心依赖 pip install selenium pytest pytest-xdist allure-pytest pyyaml版本方面建议固定不要用最新版尤其Selenium偶尔会有破坏性更新。我当前项目用的是Selenium 4.x对等待API和窗口管理做了很多重构整体体验比3.x友好。浏览器驱动这一块Selenium 4.6之后自带Selenium Manager可以自动匹配执行机上的浏览器版本并下载驱动不用再手动去下载chromedriver放到PATH里。这是一个很方便的更新但如果你用的是内网环境无法联网还是需要手动把驱动放到指定目录。我用的是Chrome通常在自动化项目里推荐用Chrome因为Chromedriver的兼容性和更新频率都更有保障。2.2 初始化项目目录结构项目目录结构直接决定框架的扩展性和可读性。我给出一个我常用的模板auto_ui_framework/ ├── config/ │ ├── config.yaml # 环境配置、URL、账号等 │ └── settings.py # 读取配置文件封装 ├── pages/ │ ├── base_page.py # 页面基类 │ ├── login_page.py # 登录页 │ ├── home_page.py # 首页 │ └── cart_page.py # 购物车页 ├── testcases/ │ ├── conftest.py # pytest fixture │ ├── test_login.py # 登录测试 │ └── test_cart.py # 购物车测试 ├── reports/ # 测试报告输出目录 ├── logs/ # 日志目录 ├── screenshots/ # 失败截图目录 ├── requirements.txt └── pytest.ini有人会把pages再拆一层比如每个模块建一个pages包每个模块下面可以再分页面对象、组件对象这个对于大型项目是合理的。但刚开始不建议过度设计先按页面平铺等页面数量超过20个再考虑按业务模块拆目录。这里要说一下config和page接口的分离。config层只负责配置读取pages层不能直接去读yaml文件。页面类如果需要URL通过初始化参数传入这么做是为了让页面类保持“纯”它不关心配置从哪来。3. 核心模块实现与最佳实践细节3.1 BasePage基类封装——先搭地基页面基类的设计直接决定后续写页面类时的体验。我的BasePage核心功能有四个初始化driver、统一查找元素、显式等待封装、操作与断言辅助。先看一段核心代码# pages/base_page.py import logging import time import allure from selenium.webdriver.remote.webdriver import WebDriver from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By from selenium.common.exceptions import TimeoutException logger logging.getLogger(__name__) class BasePage: def __init__(self, driver: WebDriver, timeout: int 10): self.driver driver self.timeout timeout self.wait WebDriverWait(driverself.driver, timeoutself.timeout) # 统一查找元素日志 高亮 等待可见 def find_element(self, locator: tuple): locator 格式为 (By.XPATH, //input[idusername]) self.highlight_element(locator) element self.wait.until(EC.visibility_of_element_located(locator)) logger.info(f元素 {locator} 已定位成功) return element def click(self, locator: tuple): self.wait.until(EC.element_to_be_clickable(locator)).click() logger.info(f点击元素 {locator}) def input_text(self, locator: tuple, text: str): element self.find_element(locator) element.clear() element.send_keys(text) logger.info(f输入文本 {text} 到 {locator}) def get_text(self, locator: tuple) - str: element self.find_element(locator) return element.text有个细节我要额外强调locator统一用元组。这样页面类里每个元素定义成类属性传入find_element时语义非常清晰。如果你用多个参数形式方法签名会膨胀调用时容易搞混。下面是一个很关键的highlight_element方法它在定位元素前把元素高亮一下这样最后看失败截图时能一眼看出代码操作的是哪个元素def highlight_element(self, locator: tuple, color: str red): try: element self.driver.find_element(*locator) self.driver.execute_script( arguments[0].style.border3px solid {}.format(color), element ) except Exception as e: logger.debug(f高亮元素失败继续执行: {e})这里要说明高亮失败不能报错中断所以用try包裹记录debug日志即可。高亮本身是个辅助功能肉眼看不见的时候毫无影响。再一个是等待策略。我推荐不用time.sleep硬等而是用WebDriverWait配合expected_conditions。在BasePage里我把等待封装在find_element和click方法内部页面类层面就不用感知等待逻辑了。但有时候页面有特殊的等待场景比如某个遮罩层消失后才可以操作这种我建议在基类里再补一个wait_element_invisible方法供页面类按需调用def wait_element_invisible(self, locator: tuple): self.wait.until(EC.invisibility_of_element_located(locator)) logger.info(f等待元素 {locator} 不可见)3.2 页面类设计与元素定位策略有了BasePage写页面类就比较省事了。以登录页为例# pages/login_page.py from selenium.webdriver.common.by import By from pages.base_page import BasePage class LoginPage(BasePage): # 元素定位统一放最前面方便维护 username_input (By.XPATH, //input[placeholder请输入用户名]) password_input (By.XPATH, //input[placeholder请输入密码]) login_button (By.XPATH, //button[contains(text(),登 录)]) error_tips (By.XPATH, //div[classel-form-item__error]) def login(self, username: str, password: str): self.input_text(self.username_input, username) self.input_text(self.password_input, password) self.click(self.login_button) from pages.home_page import HomePage return HomePage(self.driver)这里有两个值得学习的细节。第一login方法返回首页的对象。这是一个比较常见的设计让用例可以链式调用页面对象。当然要避免循环导入问题所以在函数内部导入而不是文件顶部导入。第二元素定位表达式用元组这些表达式尽量语义化并且要约定优先级优先id其次name再次类名然后CSS选择器最后才用XPath。只有元素确实没有合适的id和class时才用XPath而且XPath尽量写有语义的表达式比如根据placeholder、文本内容来定位避免使用从根节点开始的绝对路径比如//html/body/div[1]/div[2]这种页面结构稍微一动就废了。再看一个登录后跳转到首页的例子# pages/home_page.py class HomePage(BasePage): user_avatar (By.CLASS_NAME, user-avatar) logout_button (By.XPATH, //span[contains(text(),退出登录)]) def is_logged_in(self) - bool: return self.find_element(self.user_avatar).is_displayed() def logout(self): self.click(self.user_avatar) self.click(self.logout_button) from pages.login_page import LoginPage return LoginPage(self.driver)页面类之间互相返回对象这个设计的好处是用例读起来像在描述业务流程home login_page.login(test, 123456) assert home.is_logged_in() home.logout()非常接近自然语言。3.3 数据驱动与配置文件管理UI自动化的一个难点是测试数据多样化和环境切换。我的方案是yaml配置 pytest参数化。config.yaml示例# config/config.yaml base_url: https://example.com timeout: 10 browser: chrome accounts: admin: username: admin01 password: Test123 normal: username: user01 password: User123配置文件读取我专门写一个settings.py# config/settings.py import os import yaml def load_config(): config_path os.path.join(os.path.dirname(__file__), config.yaml) with open(config_path, r, encodingutf-8) as f: return yaml.safe_load(f) CONFIG load_config()用例里怎么用一种方式是直接通过fixture注入# testcases/conftest.py import pytest from selenium import webdriver from config.settings import CONFIG pytest.fixture(scopefunction) def driver(): browser CONFIG[browser] if browser chrome: options webdriver.ChromeOptions() options.add_argument(--start-maximized) options.add_argument(--ignore-certificate-errors) driver webdriver.Chrome(optionsoptions) else: raise ValueError(f暂不支持的浏览器: {browser}) driver.implicitly_wait(3) yield driver driver.quit() pytest.fixture() def login_page(driver): from pages.login_page import LoginPage lg LoginPage(driver) lg.driver.get(CONFIG[base_url]) return lg pytest.fixture() def logged_in_home(login_page): account CONFIG[accounts][normal] return login_page.login(account[username], account[password])fixture是pytest里非常实用的机制可以管理driver的创建和销毁还能通过依赖关系把用例准备阶段抽出来。上面的logged_in_home依赖于login_page这样每个需要登录状态的用例直接把这个fixture当参数接进来代码复用台非常顺畅。数据驱动方面如果业务数据比较多我建议配合pytest的parametrize使用pytest.mark.parametrize( username,password,expected_error, [ (admin01, wrong_pw, 用户名或密码错误), (wrong_user, Test123, 用户名或密码错误), (admin01, Test123, ) ] ) def test_login_result(login_page, username, password, expected_error): home login_page.login(username, password) if expected_error: assert login_page.get_text(login_page.error_tips) expected_error else: assert home.is_logged_in()这里每对参数都是一个独立的测试用例pytest会自动展开成多个用例测试报告里能看到每一组数据的结果方便快速定位是哪组数据挂了。如果用例量非常大推荐把测试数据放在独立的yaml文件里然后写一个工具函数读取后传入parametrize。好处是测试用例代码里没有硬编码数据改数据不动代码。3.4 测试用例组织与fixture设计testcases下面我通常会按业务模块分文件每个文件内部按功能分测试类。比如test_login.py里放登录的所有场景test_cart.py里放购物车的场景。类名用Test开头方法名用test_开头这是pytest的默认发现规则。fixture的scope设置是我见过很多团队容易搞错的点。driver的scope一般用function因为UI用例要求互不干扰每个用例一个干净的浏览器会话。如果为了追求速度使用module级别一个文件里多个用例共用一个浏览器那么上一个用例如果没跳回正常页面下一个就会开始就挂。想做用例复用可以用pytest-xdist的并行执行来提速度而不要牺牲隔离性。同时建议在conftest里给命令行增加一个--env参数用来切换测试环境。比如测试环境和预发布环境的地址不一样账号也不一样。实现方案是自定义pytest_addoptiondef pytest_addoption(parser): parser.addoption(--env, actionstore, defaulttest, help测试环境: test/staging/prod) pytest.fixture(scopesession) def env(request): return request.config.getoption(--env)这样同样是跑用例命令行指定--envstaging就能切换一套配置非常简单。4. 测试报告、日志与持续集成落地4.1 日志记录与Allure报告接入没有日志的UI自动化等于裸奔。我习惯在BasePage里已经加了很多logger.info但还需要在用例失败的时候自动记录截图和页面源码这样才能定位问题。通常在conftest里做一个pytest钩子# testcases/conftest.py import os import time import logging import allure import pytest from selenium.webdriver.remote.webdriver import WebDriver logger logging.getLogger(__name__) pytest.hookimpl(hookwrapperTrue) def pytest_runtest_makereport(item, call): outcome yield report outcome.get_result() if report.when call and report.failed: driver item.funcargs.get(driver) if driver: timestamp time.strftime(%Y%m%d_%H%M%S) screenshot_dir screenshots os.makedirs(screenshot_dir, exist_okTrue) screenshot_path os.path.join(screenshot_dir, f{item.name}_{timestamp}.png) driver.save_screenshot(screenshot_path) logger.error(f用例失败截图保存至: {screenshot_path}) try: allure.attach.file(screenshot_path, name失败截图, attachment_typeallure.attachment_type.PNG) except Exception as e: logger.warning(fAllure截图附件失败: {e})这个hook的意思是每个用例执行完都会回调当report.failed为True时取到driver并截图。这里有一个细节如果driver在测试中途已经因为异常退出了那截图也会失败所以需要加一层保护判断。Allure报告是UI自动化里的标配生成报告的操作流程如下# 测试执行--alluredir指定生成原始结果的目录 pytest testcases/test_login.py --alluredirreports/allure-results # 生成并打开HTML报告 allure generate reports/allure-results -o reports/allure-report --clean allure open reports/allure-report在用例和页面方法上可以加allure.step装饰器和allure.title装饰器这样报告里就能显示每个步骤的标题和结果比如页面上点击登录按钮报告里会显示“点击登录按钮”这个步骤节点。这对评审和排查都很有价值。有一点要提醒Allure生成报告依赖系统安装allure命令。如果用CI建议在docker里做一个带allure的环境镜像不要在每次构建时重新安装。4.2 把测试接入CI流程UI自动化不跑在CI里价值就少了一大半。这里给出一个Jenkins流水线阶段示例其他CI平台思路类似pipeline { agent any stages { stage(环境准备) { steps { sh python -m venv venv sh source venv/bin/activate pip install -r requirements.txt } } stage(UI自动化测试) { steps { sh source venv/bin/activate pytest testcases --alluredirreports/allure-results --clean-alluredir } } stage(生成报告) { steps { sh allure generate reports/allure-results -o reports/allure-report --clean } } stage(归档报告) { steps { publishHTML(target: [ reportDir: reports/allure-report, reportFiles: index.html, reportName: UI自动化测试报告 ]) } } } }CI接入后会面临一个非常现实的问题UI自动化跑在无头环境。大部分CI服务器没有图形界面所以要给浏览器加headless参数。以Chrome为例options.add_argument(--headless) options.add_argument(--no-sandbox) options.add_argument(--disable-dev-shm-usage)这里的--no-sandbox和--disable-dev-shm-usage是容器环境里非常关键的参数不加的话浏览器经常莫名其妙崩溃。headless模式还有一个坑某些元素的尺寸和位置与有头模式有差异如果断言依赖元素位置在headless里可能失败所以建议平时的调试在有头模式跑CI里用headless两套模式切换可以通过环境变量或者配置文件控制。5. 常见问题与排查技巧实录5.1 元素定位不稳定的典型问题这是UI自动化里出现频率最高的问题没有之一。我把常见情况整理成一个速查表症状常见原因解决方案偶尔能点击偶尔超时页面采用异步渲染按钮在DOM里但不可点击使用element_to_be_clickable等待替代visibility定位元素被遮挡导致点击失败弹窗遮罩层未关闭或者固定悬浮按钮遮挡先关闭遮罩层或点击时用force执行JS点击iframe里面的元素定位不到没有切换到iframe上下文使用driver.switch_to.frame操作完再切回默认元素属性动态变化每次刷新后id或class会变优先用相对稳定的文本、层级定位不要用动态id新窗口打开后定位失败driver还停留在旧window句柄使用driver.window_handles切换注意新窗口的index举一个实际案例某项目的登录按钮点击后约1.5秒后才会弹出“登录成功”的提示如果点击后马上断言提示信息基本都会失败。我的做法是先等提示元素可见再做断言。这是在BasePage里封装wait_element_invisible之外另一个常用等待wait_element_visible并返回文本再与预期比较。还有一个细节是使用JS点击。当元素被遮挡时WebDriver的click会抛ElementClickInterceptedException。一种规避方法是直接执行JS点击但要明白这属于“绕过模拟用户”如果有些前端框架在JS点击时不会触发某些事件结果可能不真实。所以正常情况下还是推荐先解决遮挡问题JS点击作为兜底方案。5.2 浏览器驱动与执行环境的坑在新环境上跑框架最常见的报错就是“SessionNotCreatedException: This version of ChromeDriver only supports Chrome version XXX”。解决方法是让driver版本和浏览器版本保持在同一个大版本。Selenium 4.6用自带的Selenium Manager自动管理后这个问题会减少但完全离线环境下还是会遇到。另外在执行机上跑定时任务时开始执行后最好加一句driver.set_page_load_timeout(30)这样可以避免某个页面加载超时导致整个任务卡住十几分钟。如果页面加载太慢也可以设置page_load_strategy为eager只等DOM就绪不等样式表和图片加载完能显著提速。这两个配置我一般放在BasePage初始化时统一处理def __init__(self, driver: WebDriver, timeout: int 10): self.driver driver driver.set_page_load_timeout(timeout 20) driver.implicitly_wait(timeout) self.wait WebDriverWait(driver, timeout)关于隐式等待和显式等待混用的问题这里再多说一句Selenium官方说明不要混用implicitly_wait和WebDriverWait做同一件事因为隐式等待会影响所有find_element的调用显式等待只影响当前等待条件。混用时可能出现总等待时间叠加的情况。我的习惯是设一个较短的implicitly_wait比如3秒WebDriverWait做核心等待这样可以兼顾简单定位和复杂条件。5.3 维护性与扩展性的几个习惯建议框架搭好只是第一步日常维护才是真正的挑战。下面几个习惯是我踩过多次坑之后养成的第一页面类方法只做页面级操作不做断言。断言放在用例里这样同一个页面操作在不同用例中有不同的断言理解时不需要改页面类代码。第二用例里不出现find_element。所有元素定位都封装在页面类中如果用例里出现driver.find_element那说明封装漏了。第三定期跑一次“静默巡检”。我每周会挑一个固定时间跑全量用例目的是发现由于前端迭代导致的页面元素变化。跑完不一定只看通过率还要关注失败用例里是否都是同一类问题如果都是同一个页面类的方法报错就说明那个页面需要优先重构。第四ui自动化并不适合覆盖所有场景。我一般只覆盖核心业务主链路比如登录、注册、下单、支付这些对用户体验影响最大的路径。次要功能、管理后台这类低频页面要评估收益再决定要不要写——自动化用例也有维护成本不是写得越多越好。写在最后的几点体会在搭建和重构这套框架的过程中我最深的体会是两个字克制。在页面对象模型里克制自己想“顺便多写点”的欲望非常关键。比如在页面类里加业务判断、在用例里写复杂逻辑、在一个方法里同时完成多个页面的操作这些做法短期内感觉很顺手但等到其他人接手时框架很快就变成一个谁也看不懂的黑盒。我现在的习惯是任何对框架的改动先问自己一句“这行代码放在这一层是否合适”。如果答案是“不确定”那就先不写或者宁可多拆一层也不要把职责模糊掉。这套原则帮团队减少了很多不必要的返工。如果你正准备从零搭UI自动化框架建议不要一上来就追求炫技先把POM这条主线吃透把BasePage、几个典型页面类、数据驱动和报告跑通再逐步迭代。框架不是一次成型的它是在一次次业务迭代和踩坑中慢慢变完整的。希望这篇内容能帮你少走一些弯路。