Python+Selenium+Pytest:从零搭建WebUI自动化测试框架
1. 整体设计思路与前因后果我在某公司做测试开发时接手了一个非常典型的项目一套B端后台管理系统功能模块超过30个每次发版前手工回归要花掉整整两天而且前端组件还在频繁迭代今天改个按钮颜色明天换个表格交互手工用例的维护成本越来越高。当时最迫切的需求就是把我每天重复点来点去的工作变成机器自动跑。于是就有了这个项目——用Python从零开始搭建一套WebUI自动化测试框架。很多测试同学刚接触自动化时第一反应是去搜“Selenium教程”然后照着demo写一堆脚本。脚本倒是能跑但这只能叫“自动化脚本”还不能称之为“框架”。我的理解是框架至少要解决三件事第一把公共能力沉淀成可复用的组件比如浏览器驱动管理、页面元素定位、等待策略、截图和日志第二把业务操作封装成独立的分层结构让用例只关心业务逻辑而不是纠缠在元素选择器里第三把用例执行、数据驱动、报告输出、持续集成串成一条完整的流水线。这个项目正是从这三件事出发逐步搭建起来的。一开始我并没有直接上手写代码而是花了两天时间把现有业务梳理了一遍。这个后台系统的核心流程包括登录、用户管理、订单查询、配置管理、报表导出大多数页面都遵循“列表表单弹窗”的典型Web后台套路。基于这个共性我确定了三个核心关键词Selenium、Pytest、PO模式。Selenium负责驱动浏览器和操作页面元素Pytest作为测试框架负责用例生命周期管理和断言PO模式Page Object负责把页面元素和操作封装成可复用的类避免用例里到处散落着丑长的CSS选择器。这套组合成熟稳定、社区资料多、出问题容易查是新项目最稳的选择。再说说项目适合谁来参考。如果你是一名手工测试工程师想转自动化但不知道从哪下手这篇可以说是完整的入门路径图如果你已经会写一些零散的Selenium脚本但感觉代码越写越乱、用例互相影响这篇里的分层设计和数据驱动方案应该能解决你的问题如果你正在为团队建设一套规范化的WebUI测试基础架构也可以参考我这里的目录结构和CI集成方案。我尽量把每一个环节的“为什么”也讲清楚而不是只给结论。2. 技术选型权衡与框架分层设计选技术栈这件事我是经过了一番对比的。当时摆在桌面上的其实有三个方向一是直接用Selenium IDE录制回放二是用PythonSelenium手写脚本三是类似Robot Framework这样的关键字驱动框架。录制回放看起来最省事但用例的稳定性和可维护性极差页面结构稍微改动脚本就废了。Robot Framework的优点是上手简单但遇到复杂断言和业务逻辑时写起来非常别扭而且排查问题很多时候还要绕回Python层去查等于多罩了一层。对于这个项目来说团队对Python都更熟悉代码化的维护方式也最灵活所以最终选择了Python Selenium Pytest。下面说说框架的整体分层。这是整个项目的地基我画过无数次目录结构图现在不用画图工具了直接在IDE里打开就是一棵树webui_autotest/ |-- pages/ # PO层页面对象类 | |-- base_page.py # 页面基类封装元素操作、等待、截图等 | |-- login_page.py # 登录页对象 | |-- user_page.py # 用户管理页对象 |-- test_cases/ # 用例层纯业务逻辑 | |-- conftest.py # 用例级别的fixture | |-- test_login.py | |-- test_user_manage.py |-- data/ # 数据层测试数据文件 | |-- login_data.yaml | |-- user_data.yaml |-- config/ # 环境配置与日志配置 | |-- settings.py | |-- log_config.py |-- utils/ # 公共工具驱动管理、YAML读取、日志封装等 |-- reports/ # 测试报告与截图输出 -- run_tests.py # 执行入口这个分层模型来自于对业务痛点的直接观察。在最早的手写脚本阶段一个用例里既有元素定位、又有业务操作、还有断言代码动辄两三百行几个用例跑下来维护就成噩梦了。后来引入PO模式后元素的定义、元素的操作、业务的校验分别沉淀到对应层用例脚本就变得非常干净。改动一个按钮的class只需要在对应页面类里改一处全项目的用例都同步生效。对于UI自动化框架来说还有一点值得重点强调稳定性设计必须在一开始就考虑进去。很多项目跑起来经常fail不是业务逻辑出问题而是元素加载慢、等待时间不够、弹窗遮挡点击等环境问题。我在base_page里统一做了一个三层等待机制隐式等待打底、显式等待关键元素、强制等待兜底。这个策略基本解决了大部分“偶发失败”的问题后面第5章节我会把坑和细节讲透。3. 环境准备与Excel级工程基础动手搭建项目前环境是第一步这里我直接把完整过程列出来照着做就行。3.1 Python环境与依赖安装我本机用的是Python 3.10理论上3.8以上都支持。建议直接新建一个虚拟环境别把依赖装到全局环境里否则以后项目一多依赖冲突起来会想砸电脑。python -m venv venv source venv/bin/activate # Windows下为 venv\Scripts\activate pip install selenium pytest pytest-html pyyaml allure-pytest这里简单解释每一个包的作用selenium浏览器自动化操作的绑定库核心中的核心pytest测试用例管理框架支持断言、fixture、参数化标记等功能pytest-html生成简洁的HTML测试报告pyyaml读取YAML格式的数据文件用于数据驱动allure-pytest用于生成漂亮的Allure报告后续会专门讲。3.2 浏览器驱动下载与路径处理Selenium需要对应的浏览器驱动来驱动浏览器。比如用Chrome就需要chromedriver。重点来了驱动的版本必须和浏览器版本严格匹配这一点踩坑率极高。你可以去官方源下载对应版本然后把驱动放到系统PATH路径下或者直接在代码里指定驱动路径。考虑到这个环节对新同学来说容易卡住我在utils/driver_manager.py里封装了一个函数自动检测当前浏览器版本去匹配驱动找不到时给出明确提示。这里只强调一点项目里不要硬编码驱动路径因为你换个电脑跑代码可能要改好几处。正确做法是把驱动路径放到配置文件里用ConfigParser或者settings.py统一管理。3.3 梳理项目配置文件配置文件是整个框架的“旋钮”比如测试地址切换、等待时长调整、浏览器类型选择都不该修改代码而应该改配置文件。我用一个settings.py来管理# config/settings.py import os BASE_URL os.getenv(TEST_ENV, https://demo.example.com) BROWSER os.getenv(BROWSER, chrome) EXPLICIT_WAIT 10 IMPLICIT_WAIT 5 SCREENSHOT_DIR os.path.join(os.path.dirname(__file__), .., reports, screenshots) LOG_LEVEL INFOBASE_URL支持从环境变量读取方便后续接入CI流水线时按环境切地址。用配置文件统一管理的另外一个好处是当公司有测试环境、预发环境时不需要改任何代码只改环境变量或者配置文件即可。4. 核心代码实现与PO模式落地框架能不能打核心代码是关键。这一章我会完整展示页面基类、登录页对象、用例三者的代码与协作方式并解释每一步的设计意图。4.1 页面基类把重复劳动一次封装BasePage是所有页面类的父类负责元素查找、点击、输入、等待、滚动、截图等基础操作。有人觉得Selenium本身就提供了click和send_keys再包一层有什么意义意义在于统一异常处理、截图、日志记录和等待策略。比如每次找不到元素时如果不封装你就得在每个用例里写try/except封装一次全项目统一生效。# pages/base_page.py import logging import time from selenium.webdriver.remote.webdriver import WebDriver from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as ec logger logging.getLogger(ui_test) class BasePage: def __init__(self, driver: WebDriver): self.driver driver self.wait WebDriverWait(driver, timeout10) def find_element(self, locator): 显式等待元素可见后再返回避免元素未加载完导致的NoSuchElementException return self.wait.until(ec.visibility_of_element_located(locator)) def click(self, locator): 强等待显式等待页面渲染慢的时候有奇效 time.sleep(0.3) element self.find_element(locator) self.driver.execute_script(arguments[0].click();, element) def input_text(self, locator, text): element self.find_element(locator) element.clear() element.send_keys(text) logger.info(f输入内容: {text}) def get_text(self, locator): return self.find_element(locator).text def take_screenshot(self, name): filename f{time.strftime(%Y%m%d%H%M%S)}_{name}.png self.driver.save_screenshot(filename) return filename这段代码里有个细节值得说click方法里我加了一个time.sleep(0.3)很多人认为sleep是万恶之源应该尽量避免。但在真实项目中尤其是一些老旧后台系统前端按钮的点击事件绑定期特别长点击太频繁反而会丢失响应。我的经验是给click加一个短的强制等待适配90%以上的情况。当然不能所有地方都用sleep替代显式等待两者要结合使用。4.2 登录页PO第一个业务页面对象登录页是全系统的入口也是每个回归测试的前置条件。把它封装成LoginPage其他用例只需要调用login方法即可不用重复写账号密码输入逻辑。# pages/login_page.py from selenium.webdriver.common.by import By from pages.base_page import BasePage class LoginPage(BasePage): username_input (By.CSS_SELECTOR, input[nameusername]) password_input (By.CSS_SELECTOR, input[namepassword]) login_button (By.CSS_SELECTOR, button[typesubmit]) error_msg (By.CSS_SELECTOR, .error-tips) def login(self, username, password): self.input_text(self.username_input, username) self.input_text(self.password_input, password) self.click(self.login_button) def get_error_message(self): return self.get_text(self.error_msg)可能有人会问为什么不直接用XPath而是用CSS选择器两种都可以但在新项目里我优先用CSS原因是XPath的高级语法如果写出问题很难排查CSS相对简洁、执行性能也好。复杂定位比如层级嵌套很深、无法通过class定位的场景再用XPath也不迟。4.3 写第一个真正有用的测试用例登录模块的用例一般包含“正确账号密码登录成功”“错误密码提示错误”“空账号提示必填”用Pytest表达起来非常清晰。# test_cases/test_login.py import pytest from pages.login_page import LoginPage pytest.mark.usefixtures(init_driver) class TestLogin: pytest.mark.parametrize(username,password,expected_tip, [ (admin, wrong_password, 用户名或密码错误), (, password123, 请输入用户名), ]) def test_login_fail(self, username, password, expected_tip): login_page LoginPage(self.driver) login_page.open(/login) login_page.login(username, password) assert expected_tip in login_page.get_error_message()看到这里你应该能理解框架为什么这么分了用例层面没有出现任何By.CSS_SELECTOR、time.sleep、driver.find_element全被封装到Page层了。跑完之后用例就像在写业务需求描述一样哪怕让不懂代码的测试同事去review用例逻辑也能基本看懂这个用例在干嘛。5. driver管理与fixture机制详解driver管理和fixture是框架的“基建”如果前面是骨架这部分就是血液循环系统。5.1 什么时候创建driver什么时候销毁我见过很多方案在测试模块顶层import时创建driver结果所有用例共用一个浏览器有的在每个用例里new一个driver结果一个用例集跑下来开十几个浏览器慢到崩溃。正确做法是使用Pytest的fixture按测试会话或模块级别驱动生命周期每个测试用例执行完截图并清理现场。我们看conftest.py的实现# test_cases/conftest.py import os import pytest from selenium import webdriver from config.settings import BASE_URL, BROWSER, IMPLICIT_WAIT, SCREENSHOT_DIR pytest.fixture(scopeclass) def init_driver(request): if BROWSER chrome: options webdriver.ChromeOptions() options.add_argument(--start-maximized) options.add_argument(--no-sandbox) driver webdriver.Chrome(optionsoptions) else: driver webdriver.Firefox() driver.implicitly_wait(IMPLICIT_WAIT) driver.get(BASE_URL) request.cls.driver driver yield driver driver.quit()这里scopeclass意味着一个测试类只启一个浏览器会话用例顺序执行极大提升效率。这也是为什么我习惯把同一个页面的用例放到同一个类里既有逻辑内聚又能减少浏览器启动销毁的开销。5.2 自动截图用例失败不留遗憾一个很实用的细节用例失败时自动截图并且把截图路径附在日志中。如果用例挂在第50步没截图根本不知道页面当时长什么样排查问题全靠猜。我在conftest里加了一个hookpytest.hookimpl(hookwrapperTrue) def pytest_runtest_makereport(item, call): outcome yield report outcome.get_result() if report.when call and report.failed: driver item.cls.driver screenshot_path os.path.join(SCREENSHOT_DIR, f{item.nodeid}_{time.time()}.png) driver.save_screenshot(screenshot_path) print(f截图已保存: {screenshot_path})这个hook让“失败截图”变成一个框架级能力任何用例、任何页面、任何fixture环节失败都会触发不需要在每个用例里显式调用。这种全局能力沉淀就是把脚本演进到框架的核心区别。踩过坑后我还会告诉你截图文件命名里一定要带时间戳否则几十个用例跑下来同名文件互相覆盖报错图上全是别的页面的画面。6. 数据驱动与YAML测试数据管理待测系统里的数据花样繁多不同角色的账号、不同状态的数据组合、各种边界值输入。如果这些硬编码到用例里用例的可读性和可维护性几乎为零。数据驱动就是把测试数据从代码中剥离出来放到独立的文件中同一套代码跑多组数据。6.1 登录数据YAML示例# data/login_data.yaml valid_user: - username: admin password: 123456 expected: 登录成功 invalid_user: - username: admin password: wrong expected: 用户名或密码错误 - username: password: 123456 expected: 请输入用户名用Pytest参数化读取这个文件一个用例会自动变成多个测试点import pytest import yaml from pages.login_page import LoginPage pytest.mark.usefixtures(init_driver) class TestLoginWithData: pytest.mark.parametrize(data, yaml.safe_load(open(../data/login_data.yaml))[invalid_user]) def test_login_invalid(self, data): login_page LoginPage(self.driver) login_page.open(/login) login_page.login(data[username], data[password]) assert data[expected] in login_page.get_error_message()这样加测试场景时只需要改YAML完全不需要碰代码。对于非开发背景的测试同事改YAML要比改Python脚本容易得多这也是我极力推广数据驱动的原因之一。数据与代码分离还有一个大好处是后续接测试管理平台时数据直接可以从平台导出而不是改代码。6.2 更复杂的业务场景数据当测试场景变复杂比如用户管理里需要“创建一个新用户-分配角色-验证列表中能看到-再删除该用户”你会发现YAML还可以描述流程级数据。我通常会把这些数据设计成一个列表每个元素对应一个完整的流程步骤。这是因为UI自动化用例的高成本催生了“一个用例尽量覆盖一条完整业务链路”的设计思想减少重复登录、重复加载页面的开销。别设计成“每做一个操作就断言一次”UI测试中过多的断言反而会让用例变得异常脆弱适当把多个内聚步骤放进同一个用例更实用。7. 断言策略与全局配置的进阶实践很多刚开始写自动化的同学会遇到一个困惑断言到底怎么设计夹在用例中随处assert看起来最简单但一旦页面稍作调整断言失败的用例比真正业务挂掉的用例还多。我在这个项目里总结了一套原则。7.1 断言的三个层次第一层是“页面是否出现关键元素”比如登录完成后判断页面是否出现用户头像这个优先级最高第二层是“业务结果数据是否正确”比如创建用户后列表里是否出现该用户名这个服务于核心业务第三层才是“展示性内容是否精确”比如某个提示文案是否完全一致这类断言最容易碎只在关键流程中用。整体优先级是从强到弱别从头到尾都是强断言。另外有个实践建议断言文本时最好用子串匹配而不是全等匹配。很多系统的提示文案会带系统时间或者动态编号做全等断言必挂无疑。7.2 多环境配置切容团队的项目往往有多套测试环境。我在配置里加入了一个环境映射表# config/settings.py ENV_CONFIG { dev: {base_url: https://dev.example.com, account: dev_user}, test: {base_url: https://test.example.com, account: test_user}, staging: {base_url: https://staging.example.com, account: staging_user}, }执行时通过命令行参数指定环境例如pytest test_cases --envtest --browserchrome用CLI传入参数而不是改配置文件的好处很明显同一套代码在CI里可以并行跑不同环境的任务而不会互相干扰。这虽然不是直接的功能需求但好的框架设计一定要“预埋”这种扩展能力否则等项目规模变大再重构成本极高。8. 测试报告集成从HTML到Allure跑完了测试不出报告等于白跑。汇报给团队、跟踪历史趋势报告是必须的。我启用了两种报告渠道pytest-html生成轻量报告Allure生成详细美观的展示型报告。8.1 pytest-html快速配置在命令行后加一个参数即可pytest test_cases --htmlreports/report.html --self-contained-html--self-contained-html参数表示把CSS和JS全部内嵌到单个HTML文件中发给同事、作为邮件附件都很方便不会被样式加载问题困扰。8.2 Allure报告集成Allure报告感染力强适合对外展示。集成步骤也不算复杂pip install allure-pytest pytest test_cases --alluredirreports/allure_results allure generate reports/allure_results -o reports/allure_html --clean在用例里可以给测试步骤和描述加标签import allure allure.feature(登录模块) allure.story(登录失败场景) allure.title(错误密码会提示错误) def test_login_wrong_password(self): with allure.step(打开登录页): ... with allure.step(输入错误密码并提交): ...Allure报告的妙处在于它的按模块、按功能聚合视图非常清晰领导或产品经理看趋势、看成功率时不会一头雾水。不过我要提醒一句Allure本身需要本地有Java环境才能启动generate命令第一次配置时容易卡在这里。8.3 报告里附带截图与日志Allure还支持把失败截图挂到报告里def allure_attach_failed_screenshot(driver, name失败截图): import allure from io import BytesIO from PIL import Image screenshot driver.get_screenshot_as_png() allure.attach(screenshot, namename, attachment_typeallure.attachment_type.PNG)在之前的conftest失败hook里调用这个函数所有失败用例自动在Allure报告里附带现场截图排查问题真的是一目了然。9. CI持续集成与定时任务脚本在本地能跑只是开始自动化测试如果不能自动调度、自动触发价值至少折半。我在这个项目里接入了CI流水线实现了三个场景Merge Request触发冒烟测试、每晚定时跑全量回归、失败后自动通知到工作群。9.1 CI流水线配置用CI平台通用的阶段式流水线来描述各家平台语法略有差异stages: - build - test test: stage: test script: - python -m venv venv - source venv/bin/activate - pip install -r requirements.txt - pytest test_cases --envtest --browserchrome --alluredirreports/allure_results artifacts: paths: - reports/ expire_in: 7 days为什么要把依赖安装放进流水线因为CI环境是干净镜像不装依赖直接跑脚本必挂。而如果每一个新成员加入都靠发“环境部署文档”来手动装环境团队成员会很难受。这套流水线我维护之后团队成员真的只需要“一键触发”就能跑完所有用例这个体验很打动我。9.2 定时任务的执行记录定时任务每晚九点跑全量回归跑完自动发结果消息。执行记录放在本地或CI平台的artifact中。我们必须明白一点定时任务跑完没人看也白搭结果推送必须“主动”触达。通知不是防御性的包装而是自动化闭环的必要组件。10. 常见问题与排查技巧实录框架跑起来之后真正消耗时间的不是写用例而是排查失败用例。我整理了一份高频问题速查表都是实战中反复出现的情况。现象可能原因排查建议NoSuchElementException元素加载过慢或定位符过时加显式等待用浏览器DevTools确认最新选择器ElementClickInterceptedException元素被弹窗、遮罩层遮挡检查页面是否有fixed定位遮罩层先关闭遮罩再点击StaleElementReferenceException元素引用在DOM更新后失效避免保存元素引用后长时间复用改用重新查找偶发超时页面异步渲染慢、网络抖动定位到具体步骤调整对应等待策略必要时事务重试多处用例相互影响用例运行顺序导致数据残留清理测试数据或用独立测试账号和随机后缀数据浏览器无法启动驱动版本与浏览器版本不匹配下载匹配版本的驱动或者用驱动管理器自动处理10.1 偶发失败的终极武器UI自动化最大的敌人就是偶发失败flaky test。我刚跑起来时一晚全量回归失败十几条点开报告发现大部分都是“点击没点上去”“元素刚出现就没了”。我最后采取的方案是在关键业务动作前增加“重试”机制底层的page操作如果发生异常就重新查找元素并重试一次。这个方案不能滥用只用于点击和输入这两个最核心的操作。通过加上这层保护全量回归的通过率从不到80%提升到了95%以上。10.2 日志设计的四个要点每个页面操作记录操作名和定位符失败时能直接看出是哪一步每次进入新页面记录页面标题或URL方便对照截图每次用例结束记录耗时方便定位执行瓶颈日志分级INFO记录正常流程WARNING记录等待重查ERROR记录失败异常。日志不是给自己看的是给未来排查问题的你或者同事看的。花15分钟设计日志内容能省下未来几十个熬夜排查的夜晚。11. 写在最后框架落地后的收益与体会这个框架从最早的一个登录用例后来迭代到覆盖核心业务链路的30多个用例每天晚上定时全量回归。我真实感受到自动化不是取代手工测试而是把重复的、机械的回归动作还给机器让人去专注探索性测试、用户体验评估、复杂业务场景设计这些机器替代不了的智力型工作。一个人维护这套框架的心得是框架不是为了炫技而是为了治愈“测试返工”的病。写代码时多加一行日志、多封装一层操作短期看是慢了一点但长期来看这套基础设施会持续放大团队的手工测试产能。如果你准备在新项目里从零搭一套我建议顺序是先跑通一条最核心的登录用例闭环然后把项目目录结构理清楚再逐步扩展PO层和数据驱动。千万不要一上来就追求大全把一个月的需求全部自动化那样你会被无尽的问题拖垮。一个小而稳的框架比一个大而全的灾难更有价值。最后分享一个我自己常用的技巧把用例跑完的耗时数据也纳入趋势分析。如果某两天用例从25分钟突变成40分钟大概率是页面性能回退了。把自动化框架当做一个持续度量的工具不只是验证正确性还能顺手做性能趋势监控属于意外收获。