做Web自动化测试最怕什么不是写不出脚本而是写出来的东西没人敢维护。我刚入行那会儿靠录制回放给自己攒了一套“能跑”的用例——功能点倒是全覆盖了可产品一改版定位符全都换了一遍光修脚本就修了两周。后来才想明白问题不在录制工具而在于我没给测试代码搭骨架。所谓骨架是指一套稳定、分层、可复用的测试代码组织方式。本文基于 Selenium pytest Page Object 模式从零搭建一个 Web 自动化测试的核心骨架覆盖目录划分、页面对象封装、用例编写、数据驱动、报告和失败现场等关键环节。适合刚接触自动化测试、写过脚本但感觉维护困难的同学参考。按照这套思路走下来你会拿到一份能直接落地到项目里的初始框架。1. 骨架设计思路先搞清楚为什么要分层1.1 录制回放为什么走不通录制回放就是录下你手动操作浏览器的路径然后自动重放。它上手确实快几分钟就能“跑”起来一版。但它最大的问题在于录出来的脚本里每个元素定位、每次点击顺序都直接和业务操作耦合在一起没有独立性也没有复用性。打个比方录制回放就像按着别人在你手里画好的交通路线开车。路线通的时候没问题一旦路改了页面改版整条路线就断了。你还要在那堆代码里翻找到底哪一步对应的是页面哪个控件。你费劲找到了改完了下一次改版又来了循环往复。更麻烦的是录制的脚本通常带着大量隐式等待、绝对路径甚至还有坐标点击。换台机器跑分辨率变了元素位置变了直接挂掉。这种脚本的维护成本干过的人都懂。因此在真正的项目里我更倾向于从零写代码、按层级组织把元素定位、操作逻辑、业务步骤拆开。1.2 四层结构的划分逻辑我搭的这个骨架是标准四层结构。每一层只关心自己该关心的事互不越界。层级核心职责你关心什么用例层描述业务场景编排步骤与断言做什么、期望结果是什么页面层封装页面元素与操作页面长什么样、控件怎么操作数据层提供测试数据与配置数据从哪来、怎么隔离公共层浏览器驱动、日志、报告、工具函数基础设施怎么运转层级之间单向依赖用例层调用页面层页面层依赖公共层的驱动数据层向用例层提供数据。这样改动被限制在局部。比如页面按钮从文字变成了图标只需要动页面层用例层完全无感知比如换了测试环境只需改配置文件代码一行不动。这个设计不是凭空想出来的而是直接在项目里被验证过。实测下来在改版频繁的项目里用例层的改动量能减少七成。一开始很多人觉得多写一个页面类很麻烦但半年后你看版本历史就明白这份初期的“麻烦”是后面维护轻松的根本原因。1.3 为什么选 Selenium pytest POM 这个组合说实话现在也有很多新工具比 Selenium 更现代、更快。但作为核心骨架我仍然推荐 Selenium pytest Page Object 的组合原因有三个。第一生态成熟。Selenium 支持的浏览器最全遇到问题基本搜索就有答案新工具的学习资料远没有这么丰富。第二pytest 是目前 Python 测试框架里功能与生态平衡得最好的一个fixture、参数化、插件机制覆盖日常九成场景。第三Page Object页面对象是社区验证了多年的设计模式团队里的新人也能很快理解。就算项目未来迁到其他工具这套分层思想照样能平移。骨架的价值不在于用最新的工具而在于让每个技能水平的成员都能读懂、敢改。我见过不少团队上来就追新结果文档稀缺、问题没人回答最后又退回老路。工具可以升级思路必须稳定。2. 环境准备与目录结构把地基打牢2.1 工具链与版本选择先把环境列一下。我的推荐组合Python 3.9 以上我直接用 3.11日常够用Selenium 4.x4.6 开始内置了自动管理驱动的能力方便很多pytest 7.x稳定且生态成熟pytest-html 或 allure-pytest报告用二选一webdriver-manager自动匹配浏览器驱动版本安装命令一句话pip install selenium pytest pytest-html webdriver-managerSelenium 4 之后我不建议再手动下载驱动文件了。理由很简单浏览器版本升级太频繁手动匹配驱动每天都在踩“Driver mismatch”的坑。用 webdriver-manager它会自动检测你本机浏览器的版本然后下载匹配的驱动环境卫生很多。2.2 目录结构设计我常用的目录结构长这样web_auto_test/ ├── config/ │ └── config.yaml ├── pages/ │ ├── __init__.py │ ├── base_page.py │ ├── login_page.py │ └── home_page.py ├── tests/ │ ├── __init__.py │ ├── conftest.py │ ├── test_login.py │ └── test_home.py ├── data/ │ └── login_data.yaml ├── utils/ │ ├── __init__.py │ ├── driver_factory.py │ └── logger.py ├── logs/ ├── reports/ ├── screenshots/ └── requirements.txt每个目录的职责很清晰config全局配置切换环境靠它pages页面对象一个页面一个类tests测试用例文件名统一test_*.pydata测试数据与代码分离utils公共工具驱动初始化、日志封装logs、reports、screenshots运行产物统一落盘这套结构的好处是新成员进来扫一遍目录就明白该去哪改。页面元素变了去pages需求变了去tests环境变了去config。不需要读完全部代码才敢动手目录本身就是文档。2.3 全局配置与基础入口配置文件示例# config/config.yaml base_url: http://localhost:8080 browser: chrome headless: false timeout: 10读取配置不一定要用 yaml你可以用 json甚至直接在config.py里写变量。关键是保证“环境相关的东西不要散落在测试用例里”。我见过很多人把http://localhost:8080直接写在用例代码里结果换环境时满项目地找字符串替换那滋味很不好受。driver_factory.py的简单实现from selenium import webdriver from webdriver_manager.chrome import ChromeDriverManager from selenium.webdriver.chrome.service import Service def create_driver(browserchrome, headlessFalse): if browser chrome: options webdriver.ChromeOptions() if headless: options.add_argument(--headlessnew) service Service(ChromeDriverManager().install()) return webdriver.Chrome(serviceservice, optionsoptions) raise ValueError(fUnsupported browser: {browser})这里把驱动初始化收敛到一个函数里后续想接 Firefox、Edge加分支就行。还有个细节--headlessnew是 Chrome 109 之后的新无头模式比旧版更接近真实浏览器遇到一些依赖浏览器驱动的场景不容易出幺蛾子。3. 页面对象层封装核心中的核心3.1 一个标准页面对象的写法Page Object 的核心思想是把页面元素和操作封装到类里测试用例不直接接触定位符。这样定位符改了用例不用跟着改。先写基类from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC class BasePage: def __init__(self, driver): self.driver driver self.wait WebDriverWait(driver, 10) def open(self, url): self.driver.get(url) def find(self, locator): return self.wait.until(EC.presence_of_element_located(locator)) def click(self, locator): self.find(locator).click() def input(self, locator, text): element self.find(locator) element.clear() element.send_keys(text) def get_text(self, locator): return self.find(locator).text然后写登录页from selenium.webdriver.common.by import By from pages.base_page import BasePage class LoginPage(BasePage): username (By.ID, username) password (By.NAME, password) login_btn (By.CSS_SELECTOR, button[typesubmit]) error_tip (By.CLASS_NAME, error-tip) def login(self, user, pwd): self.input(self.username, user) self.input(self.password, pwd) self.click(self.login_btn) def get_error_tip(self): return self.get_text(self.error_tip)测试用例里你就不会看到find_element(By.ID, username)这种写法了。用例只描述“登录、断言、异常提示”清爽得像在写业务需求。有人会问那要不要每个页面都包一堆 getter/setter没必要。类里面多放几个业务方法比如login比暴露一堆 getter 实用得多因为页面对象的本质是“业务操作”不是“元素仓库”。把登录这个动作封装成一个方法后面所有用例都能复用这才是省钱的地方。3.2 等待策略显示等待为主强制等待只做兜底等待这块是新手最容易踩坑的地方。我看到过很多人一上来就在脚本里写time.sleep(3)脚本跑多了整个用例又慢又不稳定。少睡一秒就失败多睡一秒就白等三秒。正确的思路是能用显示等待就不要用强制等待。显示等待是轮询等待某个条件成立条件一满足立刻继续既不浪费时间也不受网络波动影响。WebDriverWait(driver, 10).until( EC.element_to_be_clickable((By.ID, submit)) )常见的条件有presence_of_element_located元素在 DOM 里、visibility_of_element_located元素可见、element_to_be_clickable元素可点击。该用哪个取决于下一步操作。点击之前用 clickable读取文案之前用 visibility。那为什么不靠全局的隐式等待implicitly_wait解决因为它对某些条件并不生效而且一旦和显式等待混用等待时长会被叠加定位失败时白白多等两倍时间。我一般只在极少数场景保留隐式等待其余一律显式。3.3 元素定位的优先级与维护技巧定位符的优先级我的经验是id 优先唯一且稳定的话name />import pytest from utils.driver_factory import create_driver from pages.login_page import LoginPage pytest.fixture(scopefunction) def driver(): driver create_driver() yield driver driver.quit() pytest.fixture(scopefunction) def login_page(driver): page LoginPage(driver) page.open(http://localhost:8080/login) return pagescopefunction表示每个用例独立起一个浏览器执行完自动退出。一是避免用例之间互相残留状态二是失败的浏览器截图和现场保留都比较干净。代价是慢但自动化测试稳定优先速度问题可以通过并发执行来弥补。有个细节fixture 里用yield而不是return是为了后续清理。这个写法在测试结束后一定会执行driver.quit()哪怕用例抛了异常浏览器也不会泄漏。泄漏的浏览器进程多了整个 CI 环境会越来越卡最后所有人都莫名其妙地失败。4.2 pytest 用例的编写规范用例文件风格示例class TestLogin: def test_login_success(self, login_page): login_page.login(testuser, 123456) assert 首页 in login_page.driver.title def test_login_wrong_password(self, login_page): login_page.login(testuser, wrong) assert 用户名或密码错误 login_page.get_error_tip()看下来用例就是“业务动作 断言”。这也是骨架的目的让不懂代码的业务同学也能看懂用例在验证什么。用例编写有几条原则值得坚持每一条用例只验证一个核心业务场景断言要具体不要只写assert True用例之间不要有执行顺序依赖能单独跑、也能一起跑不要在一个用例里塞十几步操作否则失败时根本不知道断在哪一步我见过不少团队用例越写越长最后一条用例跑 10 分钟涉及七八个页面跳转。这种用例失败一次定位问题就要花半天。用例分解得细一点虽然看着数量多但每个都像一个独立的检查点出了问题一目了然。4.3 数据驱动参数化和外部数据很多场景要用多组数据跑同一个流程比如登录用例正常、密码错、用户不存在、账号锁定。最直接的方式是用 pytest 参数化import pytest pytest.mark.parametrize(user, pwd, tip, [ (testuser, 123456, 登录成功), (testuser, wrong, 用户名或密码错误), (, 123456, 请输入用户名), ]) def test_login_scenarios(self, login_page, user, pwd, tip): login_page.login(user, pwd) assert tip in login_page.get_tip()维护量大或者是动态数据时把数据挪到 yaml# data/login_data.yaml cases: - user: testuser pwd: 123456 tip: 登录成功 - user: testuser pwd: wrong tip: 用户名或密码错误再写个读取函数把 yaml 变成列表喂给参数化。这样用例代码不变数据和代码分离改数据时不需要动代码。值得注意一个坑参数化用例的 id 默认是一串下标跑挂了你都不知道是哪组数据。加一个ids参数pytest.mark.parametrize(user, pwd, tip, cases, ids[success, wrong_pwd, empty_user])报告里一眼就能看出是哪个场景失败了省去对着一堆数字猜场景的功夫。4.4 用例分层冒烟、回归怎么区分测试用例是会不断变多的全量跑一遍可能就要一两个小时。所以骨架里一定要支持用例分级。pytest 的 marker 就是干这个的。pytest.mark.smoke def test_login_success(self, login_page): ...在pyproject.toml或pytest.ini里注册[pytest] markers smoke: 冒烟测试用例 regression: 回归测试用例执行时就可以只跑冒烟pytest -m smoke发布前跑冒烟每天定时跑回归这是非常常见的节奏。我在实际项目里还会加一个“新功能验证”标记专门给本轮迭代的用例打标发版那天重点盯这一批效果很好。5. 报告、截图与日志失败不是末日是线索5.1 报告插件选型报告是自动化测试的“脸面”。尤其当用例失败时一张清楚标注失败步骤的报告比十行命令行输出有用得多。pytest-html 和 allure 是两种主流选择。我个人的选型逻辑特性pytest-htmlallure上手成本低装完就有报告中需要额外装 allure 命令行报告展示单页、直观交互丰富、支持历史趋势截图集成需要额外写代码通过附件机制原生支持适合场景中小团队快速落地需要深度分析和长期沉淀如果只是团队内部看pytest-html 就够了。如果要做成平台的长期沉淀别犹豫上 allure。我这边项目最开始用的是 pytest-html后来接入持续集成时想加历史趋势很自然地切到了 allure。pytest-html 的用法pytest --htmlreports/report.html --self-contained-htmlallure 的用法pytest --alluredirreports/allure-results allure serve reports/allure-results5.2 失败自动截图和浏览器现场用例失败时最让人头疼的是不知道浏览器当时是什么状态。光给一个报错堆栈没用页面上到底出了什么才能帮助定位是元素问题还是业务问题。所以骨架里必须加上失败自动截图。利用 pytest hook在用例失败时把当前页面截图保存下来import time import pytest pytest.hookimpl(tryfirstTrue, 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 is not None: timestamp int(time.time()) driver.save_screenshot(fscreenshots/{item.name}_{timestamp}.png)这段 hook 的关键在于tryfirstTrue保证它在其他插件之前拿到失败的最终报告。另外要注意item.funcargs.get(driver)拿到的是当前用例的 fixture 实例如果用例没使用 driver fixture直接取值会拿不到所以先判断非空。想再进一步可以在失败时通过driver.get_log(browser)抓取浏览器控制台报错信息一并输出到报告。很多前端报错页面看起来没问题但 JS 已在控制台报了一堆错。有了这些信息测试失败到底是谁的锅判断起来准确很多。5.3 日志怎么埋才不白埋日志是运行时排查问题的另一只眼。我见过不少项目自动化代码里没有一行日志出了问题全靠猜。我的做法是在公共层建一个 logger然后页面层的关键操作和用例层的核心步骤都打日志。比如logger.info(开始登录) logger.info(输入用户名: %s, user) logger.info(点击登录按钮)注意日志不是越多越好。敏感信息不要打比如密码必须脱敏。日志格式建议包含时间、级别、所在模块、具体消息方便按时间线复盘。import logging def get_logger(name): logger logging.getLogger(name) logger.setLevel(logging.INFO) handler logging.FileHandler(logs/test.log, encodingutf-8) fmt logging.Formatter(%(asctime)s %(levelname)s %(name)s %(message)s) handler.setFormatter(fmt) logger.addHandler(handler) return logger配合上面的失败截图任何一个用例失败了你能同时拿到代码堆栈、页面截图、关键操作日志。排查效率一下子提升很多。我后来定位问题基本都靠日志找时间线再配合截图确认视觉状态基本不用像以前那样反复重跑用例。6. 常见问题与排查技巧实录6.1 元素定位不稳定这是自动化投入运行后最常反馈的问题。现象是刚才还在跑的用例突然某个元素找不到了。典型原因和解决思路如下现象原因排查方向解决报 NoSuchElementException定位符失效或页面还没加载完成先手动打开浏览器检查元素是否存在改用更稳定的定位符或加显示等待间歇性成功失败页面使用异步加载元素出现时间不固定观察网络请求与元素出现时间点显示等待对应条件不要改大 sleep换机器就失败依赖分辨率 / 窗口大小检查是否用了绝对路径或坐标点击用相对定位符窗口固定最大化处理这类问题有一条铁律先手动复现再谈脚本。很多同学一看到自动化报错就直接去改代码。其实应该先开浏览器手动走一遍同样的路径往往一眼就能看出是页面本身还没渲染完而不是脚本问题。我在团队里立了一个规矩报错先复现复现不出来就不准动代码。6.2 浏览器驱动与版本不匹配这个报错做过 Selenium 的人应该都见过Message: session not created: This version of ChromeDriver only supports Chrome version ...原因就是驱动版本和浏览器版本对不上。用了 webdriver-manager 之后这种问题基本绝迹因为它会自动检测浏览器大版本并下载匹配的驱动。如果你没有用唯一的办法就是按报错里的版本提示去下载对应版本用完一次浏览器升级又得来一遍。经验之谈在公司项目里如果所有成员环境不一致建议在公共的driver_factory里统一用 webdriver-manager并把驱动缓存纳入团队规范不要在个人机器上手工管理驱动。6.3 用例之间的状态串联问题有个真实情况我印象很深某个项目首页有登录态用例 A 先登录了用例 B 没登录直接跑却莫名其妙通过了。原因是 pytest 用例默认按文件顺序执行B 复用了 A 的登录态。后来单独跑 B立刻失败。这就是用例之间耦合了状态。解决办法很简单每个用例必须是独立的。登录态通过 fixture 在每个用例执行前建立数据也单独准备。如果需要共享数据比如同一个账号宁可加锁也不要指望前面的用例“带出状态”。如果你跑完一批用例再用pytest --lf只重跑失败的结果当初失败的用例却不失败了那基本就是状态耦合造成的。出现这种情况优先排查用例是否共用可变数据。6.4 pytest-xdist 并发执行要注意什么用例多了之后单线程跑太慢大家自然而然会想到 pytest-xdist。这个插件确实好用但它不是简单地“多进程跑起来”就行。每个进程都有独立的 driver资源不会竞争但互相之间不能共享登录态断言里如果用时间戳或随机数命名文件注意并发下不要产生同名字段如果用例会写数据库或生成数据要考虑并发下的数据隔离我用 xdist 的经验是先保证用例完全独立再上并发。用pytest -n auto可以自动开 CPU 核数但追求稳定的话建议先指定-n 4跑几轮看有没有 flaky 现象。另外xdist 跑的时候每个 worker 都会调用 driver_factory如果项目有全局唯一的资源比如固定的测试账号就容易出现并发抢占。这时候可以用 pytest 的并行锁机制或者干脆给这些用例打一个serial标记放到单独一个 worker 里串行跑。这个细节很多团队都是吃了几次亏才想明白。这个骨架我前后迭代了好几版。最初也没有现在这么清晰的四层结构也是从一坨脚本慢慢成长起来的。最有感触的一点是自动化测试不是写完就结束它和业务代码一样要维护、要演进。骨架的收益前三个月不明显半年后你会真切感受到它的价值——改版时改一处而不是满世界找脚本改。如果你现在正准备搭一套 Web 自动化测试骨架我的建议是先按这篇文章的目录跑通最小的登录场景然后逐步叠加页面对象、数据驱动、报告这几层。不要一开始就把所有功能都加上骨架是瘦的随着项目需要再长肉。最后分享一个小技巧把你自己写出的固定写法沉淀成模板比如新建页面类、新建测试文件的模板团队协作时效率提升非常明显。模板这东西平时感觉不到价值等你要一口气写十几个页面类的时候就知道有多香了。