ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Selenium自动化测试实战:从环境配置到pytest集成与稳定运行

Selenium自动化测试实战:从环境配置到pytest集成与稳定运行 1. 为什么还在选Selenium一个老牌工具的不可替代性很多人一听到自动化测试第一反应就是我到底该学 Selenium还是直接上 Playwright 或者 Cypress。这个纠结我太理解了三年前我还在用 Selenium 做项目时身边就有同事劝我换 Cypress说它 API 简洁、自带等待机制、调试体验好。但我一直没放弃 Selenium反而越用越顺手。原因其实很现实Selenium 的生态太成熟了。它支持 Python、Java、C#、Ruby 这些主流语言只要团队会用 Python就能在半小时内搭出第一套可运行的环境。它可以直接驱动 Chrome、Firefox、Edge、Safari这在做跨浏览器兼容性测试时几乎是唯一的选择。Cypress 目前对多浏览器支持还在追赶Playwright 虽然多浏览器已经很成熟但它们在真实用户操作模拟、分布式执行、和 CI 系统集成这些老领域里还是不如 Selenium WebDriver 协议沉淀得深。还要提一点市面上绝大多数自动化测试岗位 JD 上写的都是 Selenium 经验。你把这个工具吃透面试、换项目、接外包都有底气。我的建议是新手入门时别盲目崇拜新框架先把 Selenium 这套 WebDriver 模型弄明白——定位元素、等待策略、上下文切换、并发执行这些底层能力放到 Playwright 或者 Appium 里一样通用。框架会迭代底层的自动化思维不会过时。这篇文章我会围绕一个完整的实战场景来写用 Python Selenium 从零搭建一套可维护的 Web 自动化测试流程。包括环境配置、元素定位策略、稳定等待方案、pytest 集成以及在真实项目中我踩过的那些坑。内容偏向实操适合刚接触自动化测试的测试工程师也适合想转型自动化方向的开发同学。项目中用的代码都以 Selenium 4.x Python 3.10 为例大家在自己电脑上最好保持版本接近。2. 环境搭建的三个深坑版本匹配、驱动位置、pip 装错包这块看起来是最简单的但我见过太多同事在第一步就被卡住。Selenium 装完一运行浏览器弹不出来或者报个WebDriverException就完事了。原因集中在三个地方驱动版本和浏览器不匹配、驱动没放到系统 PATH 里、pip 装成了某个同名的不正规库。2.1 WebDriver 与浏览器的版本必须精确匹配Selenium 本身只是发指令的遥控器真正去操作浏览器的是浏览器自己配套的 WebDriver比如 ChromeDriver。Chrome 每次升级ChromeDriver 也必须跟着换。报错信息一般长这样selenium.common.exceptions.SessionNotCreatedException: Message: session not created: This version of ChromeDriver only supports Chrome version 114 Current browser version is 119.0.6045.105 with binary path这个错说明 ChromeDriver 是 114 版本而你的 Chrome 已经到了 119。解决办法很直接查当前 Chrome 版本去对应渠道下载同版本或相近版本的 ChromeDriver。Chrome 的版本号可以在地址栏输入chrome://version看到。Firefox 搭配的是 geckodriverEdge 配 msedgedriver逻辑一样。我建议把浏览器自动更新这功能关掉或者至少在做自动化测试的专用测试环境里关掉。不然今天脚本跑得好好的明天浏览器偷偷升了级ChromeDriver 还是老版本整个 CI 流程直接崩。这在团队协作里特别容易引发怎么昨天还好好的这种灵异事件。2.2 驱动放哪里PATH 配置与 Service 参数ChromeDriver 下载回来后是个可执行文件。之前很多人习惯把它丢到/usr/local/bin或者C:\Windows\System32这确实省事但污染了系统目录。而且一旦团队里多个人共用一套代码每个人的驱动路径都不一样脚本就没法统一。更规范的做法是把驱动放到项目里的独立目录比如drivers/然后通过Service对象指定路径from selenium import webdriver from selenium.webdriver.chrome.service import Service service Service(executable_pathrdrivers/chromedriver.exe) driver webdriver.Chrome(serviceservice)这样整个项目只需要维护一个 drivers 目录CI 环境里把驱动路径配到环境变量里就行不用改代码。Selenium 4 还能用 Selenium Manager 自动处理部分驱动下载但那个在国内网络环境下不一定每次都稳定我不建议把这种方式作为团队默认方案。手动管理驱动路径虽然土但可控性强出问题能立刻找准方向。2.3 pip install 的安装陷阱Python 生态里存在一些名字蹭热度的库。Selenium 官方库在 PyPI 上的名字就是selenium除此之外那些selenium-manager、selenium-webdriver之类名字除非是明确依赖否则不要随便装。装错了轻则 import 报错重则装上带恶意代码的脚本这是供应链安全里很现实的风险。标准安装命令就一条pip install selenium pytest pytest-selenium装完以后验证一下环境是否通畅。写一个最小的测试脚本只要能打开一个页面、拿到标题、然后退出就算环境通了from selenium import webdriver from selenium.webdriver.chrome.service import Service service Service(executable_pathrdrivers/chromedriver.exe) driver webdriver.Chrome(serviceservice) driver.get(https://example.com) print(driver.title) driver.quit()跑这个脚本时我强烈建议一开始用有头模式也就是浏览器窗口正常弹出来。这样你能直观地看到脚本对浏览器的控制过程。等所有逻辑都稳定了再换无头模式放到 CI 里跑否则排查问题时什么都看不见效率会非常低。3. 元素定位的正确姿势别让 XPath 成为你的拐杖元素定位是 UI 自动化的地基。脚本 90% 的稳定性问题都出在元素找不到或者找到了但操作的不是想要的元素这两个方向上。Selenium 提供了 8 种 By 策略ID、Name、Class Name、Tag Name、CSS Selector、XPath、Link Text、Partial Link Text。实际项目里我会给自己定一个优先级原则。3.1 定位优先级能用语义属性就别用 XPath 硬写我一直遵循的顺序是ID Name CSS Selector XPath。原因很简单ID 是页面上的唯一标识不存在歧义Name 很多时候也保持了唯一性CSS Selector 语法简洁性能好XPath 是最后手段因为它能处理相对路径和文本匹配但代价是脆弱——页面结构稍微一调整XPath 就断了。举个例子我要定位一个登录按钮。如果页面结构里有这样的代码button idlogin-btn classbtn btn-primary登录/button直接用driver.find_element(By.ID, login-btn)就行干净又稳定。但如果这个按钮没有 ID只有一串动态生成的 class那就用相对 XPath 加文本匹配driver.find_element(By.XPATH, //button[contains(class, login) and text()登录])这里用contains(class, login)而不是classlogin是因为很多前端框架的 class 是动态拼接的精确匹配很容易失效。这个细节非常管用开发把样式从btn-login-large改成btn-login时你的定位也不会挂。3.2 元素定位不到时优先怀疑 iframe 和新开的窗口这是新手最容易卡死的问题之一。明明元素就在页面上肉眼都看得到但是find_element就是报NoSuchElementException。这时候大概率元素在一个 iframe 里。iframe 是嵌入在当前页面里的另一个独立文档Selenium 默认只会在当前所在的文档里找元素不主动钻进去。处理方式是先用switch_to.frame()切进去操作完再切回来from selenium.webdriver.common.by import By # 先切到 iframe driver.switch_to.frame(iframe-name) textarea driver.find_element(By.ID, content) textarea.send_keys(测试内容) # 操作完回到主文档 driver.switch_to.default_content()还有一种情况是点击一个元素后浏览器新开了标签页目标元素在新页面里。这时候要切换窗口句柄handles driver.window_handles driver.switch_to.window(handles[-1])处理完新页面记得再切回原来的窗口。这两个切换问题我后面还会专门展开讲。3.3 页面元素被遮挡滚动与 ActionChains 的真实解决方案有时候元素存在也定位到了但点击时报ElementClickInterceptedException。这种情况常见于页面弹出了悬浮广告、新增了 Cookie 确认条、或者登录框被顶部固定导航栏遮挡。很多人第一反应是硬点结果越点越气。正确思路是先判断遮挡物是什么再决定是滚动、等待还是用 JS 强制执行。滚动到位是最温和的解决方案element driver.find_element(By.ID, submit) driver.execute_script(arguments[0].scrollIntoView();, element) element.click()如果滚动后还是被固定元素遮挡可以临时用 JavaScript 直接点击driver.execute_script(arguments[0].click();, element)但我要提醒一句JS 点击是绕过用户真实操作路径的能不用就不用。因为它会掩盖真实页面的交互问题——如果开发那有个事件绑定逻辑依赖真实鼠标事件你用 JS 触发就不会生效测试结果不准。更稳的做法是用ActionChains模拟真实的移动和点击from selenium.webdriver.common.action_chains import ActionChains actions ActionChains(driver) actions.move_to_element(element).pause(0.2).click().perform()这个方案最接近真实用户操作在徽章、菜单、hover 类场景里尤其好用。4. 等待机制的真相强制 sleep 为什么会被所有人唾弃如果说定位是 UI 自动化的地基那等待策略就是承重墙。很多脚本一开始写得顺风顺水一上 CI 就挂十有八九是等待问题。新手最常见的写法是time.sleep(3)这个方式有两个致命缺陷网络快的时候白白等 3 秒网络慢的时候 3 秒根本不够。于是脚本要么慢如蜗牛要么时不时光荣牺牲。4.1 显式等待等你在等的那个条件正确的做法是用WebDriverWait配合expected_conditions明确指定等到什么条件满足再继续。等元素可点击、可见等特定文本出现等某个元素消失都能精准表达。from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By wait WebDriverWait(driver, timeout10, poll_frequency0.5) login_button wait.until( EC.element_to_be_clickable((By.ID, login-btn)) ) login_button.click()这段代码的意思是最多等 10 秒每 0.5 秒检查一次直到按钮可点击为止。这样脚本的执行时间会跟随页面实际情况自动调整快了不用等慢了也扛得住。poll_frequency默认是 0.5 秒被测试页面加载特别慢时可以把 timeout 拉到 20但轮询频率不用改太频繁否则对服务端压力会比较大。4.2 隐式等待的边界不是所有场景都适合全局设置隐式等待是对整个 WebDriver 实例生效的默认等待时间driver.implicitly_wait(10)它在每次find_element时都会生效找不到元素时会在超时时间内反复查找。看起来方便但在等待条件复杂的场景里帮不上忙——它只能等元素出现在 DOM 里等不了元素可点击、可见、文本变化这些状态。所以我的建议是隐式等待可以设一个 5 到 10 秒的兜底值但真正关键的交互步骤全部用显式等待来精准控制。还有很重要的一点不要在同一个脚本里混用隐式等待和长时间的显式等待。因为隐式等待是全局的显式等待内部也会受它干扰容易出现等待时间叠加、脚本变慢的情况。这也是我踩过的一个坑后面展开讲。4.3 自定义等待条件从能跑到跑得稳有时候内置的 expected_conditions 不够用。比如某个页面加载完成后会出现一个 loading 遮罩层遮罩层消失才能操作下面的表格。这种等待元素消失没有内置通用方法写起来也简单def wait_for_loading_disappear(driver, timeout15): wait WebDriverWait(driver, timeout) wait.until( EC.invisibility_of_element_located((By.CLASS_NAME, loading-mask)) )还有等待多个元素中的任意一个出现、等待页面标题变化、等待 URL 包含某关键字这些场景都能通过expected_conditions组合实现。把常用的等待逻辑封装成一个个函数测试用例写起来会非常简洁。这是从能跑走向跑得稳的关键一步也是项目后期维护工作量大小的重要分水岭。5. 从脚本到框架用 pytest 组织你的自动化用例能跑通单个脚本只是起点。真实项目里产品功能有几十个甚至上百个测试用例都要长期维护。如果每次都是单独写一个 python 文件跑完一个删一个那就完全没有积累效应。我习惯用 pytest 做用例组织、数据驱动、执行控制和失败重试。这套组合在测试开发岗位里用到今天还是很主流。5.1 conftest.py 与 fixture把浏览器启停统一收口pytest 里最核心的机制就是 fixture。我习惯把启动浏览器、访问环境、退出浏览器这些动作做成 fixture所有用例自动复用不用在用例里反复写初始化代码。# conftest.py import pytest from selenium import webdriver from selenium.webdriver.chrome.service import Service from selenium.webdriver.chrome.options import Options pytest.fixture(scopefunction) def driver(): options Options() options.add_argument(--headlessnew) options.add_argument(--window-size1920,1080) service Service(executable_pathrdrivers/chromedriver.exe) drv webdriver.Chrome(serviceservice, optionsoptions) drv.implicitly_wait(5) yield drv drv.quit()fixture 的scope有多个取值。function是每个用例都启停一遍浏览器隔离性最好但速度慢module是一个模块共用一个浏览器session是整个测试会话共用一个浏览器速度快但用例之间的状态容易污染。我的建议是核心用例用function回归执行时按模块拆分用module别一股脑全上session。5.2 测试用例的结构化写法Given-When-Then 思路一条测试用例本质上是一次状态转换的验证。我会把用例分成三段写用空行隔开准备数据、执行操作、断言结果。这样不管谁来看测试代码都能一眼看清用例在测什么。def test_login_success(driver): # Given 访问登录页 driver.get(https://example.com/login) # When 输入正确账号密码并提交 driver.find_element(By.ID, username).send_keys(test_user) driver.find_element(By.ID, password).send_keys(Pssw0rd) driver.find_element(By.ID, login-btn).click() # Then 跳转到首页并显示用户昵称 WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CLASS_NAME, user-nickname)) ) assert 欢迎回来 in driver.page_source断言的力度很重要。很多人习惯断言page_source包含某文案这个方式虽然简单但对页面整体渲染变化过于敏感。更稳的是抓取目标元素的文本或属性做精确断言nickname driver.find_element(By.CLASS_NAME, user-nickname) assert nickname.text test_user精确断言的好处是出错时能快速定位是哪一部分逻辑引起的。你看到一条断言失败的日志时能直接分辨是定位失效、页面渲染慢、还是业务逻辑本身有变化。page_source那种粗粒度断言则容易让排查靠猜。5.3 数据驱动一套用例跑多组数据登录功能往往要测多组账号密码组合。把数据从用例里剥离出来用pytest.mark.parametrize驱动import pytest pytest.mark.parametrize(username,password,expected, [ (test_user, Pssw0rd, 登录成功), (test_user, wrong_pass, 密码错误), (, Pssw0rd, 用户名不能为空), ]) def test_login_param(driver, username, password, expected): driver.get(https://example.com/login) driver.find_element(By.ID, username).send_keys(username) driver.find_element(By.ID, password).send_keys(password) driver.find_element(By.ID, login-btn).click() toast WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CLASS_NAME, toast)) ) assert expected in toast.text这样以后产品提了一组新的异常输入你只需要在数据列表里加一行用例就会自动扩展。测试数据和测试逻辑彻底分离维护成本肉眼可见地下降。如果数据量特别大可以放到 CSV 或 JSON 文件里用 pytest 的读取工具加载但核心思路不变。5.4 失败重试与报告输出让 CI 跑出可信的结论自动化测试在 CI 里跑的时候偶尔会因网络抖动、第三方服务超时这些非业务原因挂掉。不分青红皂白地全部判定为失败会让团队对测试结果失去信任。我给 pytest 配了pytest-rerunfailures做失败重试再配合pytest-html生成报告。pip install pytest-rerunfailures pytest-html执行的时候带上参数pytest -v tests/ --reruns 2 --htmlreport.html --self-contained-html这样的执行逻辑是用例先跑一遍失败的话再重试两次三次都不是因为偶然因素才真正判失败。注意重试不能无脑设置太多否则这些failed 转 passed的用例会掩盖真实的问题。一般 2 次就够。同时我会在 conftest.py 里加一个失败截图钩子用例失败时自动把当前页面截图保存到screenshots/目录命名带上用例名和时间戳。这样查看 HTML 报告时能看到失败那一刻的页面状态比纯看日志有用太多。pytest.hookimpl(tryfirstTrue, hookwrapperTrue) def pytest_runtest_makereport(item, call): outcome yield report outcome.get_result() if report.failed: driver item.funcargs.get(driver) if driver: import datetime timestamp datetime.datetime.now().strftime(%Y%m%d_%H%M%S) driver.save_screenshot(fscreenshots/{item.name}_{timestamp}.png)6. 踩坑实录iframe、窗口句柄、无头模式下的诡异现象这一部分我想系统梳理几个高频的坑。这些坑我几乎在每个项目里都遇到网上讨论也多但讨论得零散没有一个完整的排查链路。我按现象、原因、排查过程、解决方案的顺序写清楚。6.1 明明元素存在却一直定位不到现象脚本打开页面find_element一直报NoSuchElementException但打开无头模式的截图看元素就在那。这个坑的排查顺序我已经形成了肌肉记忆先从三层检查入手第一检查是不是在 iframe 里。用driver.switch_to.frame()逐一尝试如果页面有多个 iframe可以先打印出所有 iframe 的 id 或 nameframes driver.find_elements(By.TAG_NAME, iframe) for f in frames: print(f.get_attribute(id), f.get_attribute(name), f.get_attribute(src))第二检查元素是不是在 Shadow DOM 里。现在很多前端组件库比如某些表单控件会把组件内部结构塞进 Shadow DOM常规find_element是找不到的。这需要用shadow_root属性穿透host_element driver.find_element(By.CSS_SELECTOR, custom-input) shadow_root host_element.shadow_root inner_input shadow_root.find_element(By.CSS_SELECTOR, input) inner_input.send_keys(测试文本)第三检查元素的属性是不是动态变化的。比如开发给按钮写了一个随机数属性的 id每次刷新页面都变。这种情况用属性定位必挂只能退回到contains(class, xxx)或根据文本路径定位。排查的时候可以打断点在浏览器开发者工具里用document.querySelectorAll去试各种策略确定哪种最稳定再写进脚本。6.2 窗口句柄切换的竞态问题现象点击一个打开新窗口的链接后driver.window_handles返回的列表里可能暂时还是只有一个句柄切换失败。原因在于浏览器真正创建新窗口需要一点时间Selenium 点击操作返回时窗口可能还没创建完成。老老实实轮询等待是唯一解法from selenium.webdriver.support.ui import WebDriverWait def switch_to_new_window(driver, timeout10): current driver.current_window_handle def _wait_handle(d): return len(d.window_handles) 1 WebDriverWait(driver, timeout).until(_wait_handle) for handle in driver.window_handles: if handle ! current: driver.switch_to.window(handle) break return driver写完这个函数以后我再也没被窗口句柄切换失败折磨过。切完之后等 key 元素出现再继续操作避免直接断言新页面内容导致失败。这个习惯同样适用于 iframe 切换后的操作。6.3 无头模式下的滚动与字体问题无头模式headless能让代码在后台跑是 CI 的首选。但它不是浏览器开了个隐身窗口那么简单的变换无头模式会带来几个看不见但真实存在的差异。最典型的例子是懒加载。有头模式下浏览器窗口默认有高度页面会自动加载可视区域附近的图片和数据无头模式如果窗口大小设置不对有些懒加载区域的数据就不会触发直接导致断言失败。解决办法是启动时显式设置窗口大小options.add_argument(--window-size1920,1080)另外无头模式字体渲染方式不同某些页面上的字体图标比如 iconfont可能加载不出来导致某个按钮元素尺寸为 0点击时报错。这类问题排查起来比较隐蔽因为页面结构完全正确。我的经验是遇到无头模式特有失败时先保存一份无头模式的截图对比有头模式观察缺失的区域再决定是调整等待策略还是改断言方式。无头模式不是不行只是不能无脑替换有头模式跑本地的全部逻辑。6.4 隐形等待和显式等待叠加导致的超时这个坑非常隐蔽而且报错信息不直白。我之前有一个脚本driver.implicitly_wait(10)配了 WebDriverWait 显式等待 10 秒结果某些用例实际耗时超过 20 秒偶尔还会超时。研究之后才明白WebDriverWait 内部的until每轮询一次就会执行一次find_element而find_element会受到隐式等待的影响。比如一个元素一直不存在显式等待每 0.5 秒去查一次每次查都要叠加隐式等待的 10 秒导致实际查询周期远大于 0.5 秒总超时时间也被拉长。解决方案有两种全局只保留显式等待或者隐式等待设得很短比如 2 秒以内作为一个兜底不在同一个脚本里同时设两个长时间等待。我现在更倾向于彻底不用隐式等待所有关键节点用显式等待脚本的可控性会好很多。7. 稳定性的最后防线日志、截图、CI 集成一个都不能少自动化测试写得好不好关键看在无人值守时挂掉了能不能快速定位问题。脚本能跑不是目标挂了之后能快速分析原因才是。这里我分享三个真实项目里验证过有效的稳定性配置。7.1 关键步骤埋日志不是所有信息都值得打印日志不是越多越好而是要在排查问题所需信息和日志噪音之间找平衡。我会在四个时间点埋日志启动浏览器时打印当前浏览器和驱动版本每次核心操作点击、输入、跳转前打印定位策略和目标元素等待超时后打印页面的关键信息当前 URL、页面标题用例结束时打印耗时和结果。import logging logger logging.getLogger(ui_test) def click_safe(driver, locator): element driver.find_element(*locator) logger.info(fclick element: {locator}, text{element.text}) element.click()日志的主要价值在于从一根失败堆栈里分辨是定位策略挂了、还是业务流程不对、还是页面环境异常。日志记录清晰了排查像看侦探小说一样顺畅日志稀烂排查就是大海捞针。7.2 失败现场的完整快照截图 页面源码截图只能看到画面。如果页面上有个弹窗把关键内容挡住了截图甚至看不到报错信息。所以我会在失败时同时保存页面源码和截图。页面源码是 HTML任何时候都能重新分析还能从里面捞到某些被遮挡文本的线索。def make_artifacts(driver, prefix): timestamp datetime.datetime.now().strftime(%H%M%S) driver.save_screenshot(fartifacts/{prefix}_{timestamp}.png) with open(fartifacts/{prefix}_{timestamp}.html, w, encodingutf-8) as f: f.write(driver.page_source)这两份文件归档在同一个带测试用例名的目录里配合作报告效果非常好。团队里其他同事看报告时不需要重新跑脚本就能拿到一手材料。7.3 与 CI 结合从我本地能跑到大家都能跑本地跑通了只是第一步。自动化测试真正的价值在生产环境、在 CI 流水线里跑起来。我的做法是GitLab CI 上用一个独立 stage 跑 UI 测试触发方式可以是定时任务也可以是提交代码时手动选择触发。核心配置就两块测试执行命令和报告归档。ui-test: stage: test script: - pip install -r requirements.txt - python -m pytest tests/ui --reruns 2 --htmlreport.html artifacts: paths: - report.html - screenshots/ only: - schedules这里artifacts的作用是把测试报告和截图传给 CI 平台团队成员直接在流水线页面上就能看。CI 里跑的时候还要特别留意路径问题项目里用相对路径加载驱动、存放截图在跑 CI 时根目录可能会变化最好都把路径锚定到项目根目录下。我吃过这个亏因为一个简单的drivers/chromedriver.exe相对路径问题整个流水线挂了半天。最后再分享一个小技巧不管是在本地还是 CI给脚本加一个最外层的环境探活——访问被测系统首页如果首页都打不开直接就给出环境异常的结论而不是让测试脚本满屏报ConnectionError。自动化测试首先要解决是环境问题还是用例问题这个判断题不然每天都在帮环境背锅跑一段时间团队就会对这个项目失去信心。把这层探活做进去之后我管理的 UI 自动化项目长期稳定性明显上了一个台阶报告的可信度也高了很多团队也更愿意依赖这套测试结果来做发布决策。
返回列表