ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Selenium Web自动化测试实战:从环境搭建到CI稳定运行

Selenium Web自动化测试实战:从环境搭建到CI稳定运行 Selenium 这个 Web 自动化测试框架我第一次正眼瞧它是周五晚上六点。那天产品临时通知周一要大版本上线回归用例拉了 120 条全得在浏览器里手工点完。我点了四个小时的页面点完还要在 Excel 里一条条标结果标到一半眼皮开始打架。周一上线前又有几条用例要重跑我当时只有一个想法这活儿要是不能自动化我这双手迟早要废在回归测试上。后来我把 Selenium 从头啃了一遍过程不算顺畅踩过的坑攒起来能写满一篇长文。这篇就是我整理出来的完整实操记录不绕弯子直接讲清楚为什么选它、浏览器驱动那层黑盒是什么、环境怎么搭、定位和等待怎么写才稳、脚本怎么变成能交给 CI 跑的测试工程最后附一份高频报错排查表。不管你是准备转自动化测试的 QA、被手工回归折磨到崩溃的测开还是后端被拉来写 UI 用例的研发这份记录应该能帮你省下不少半夜加班的力气。1. 先掰扯清楚Selenium 到底解决什么问题1.1 手工回归真正的痛点不是慢是不可重复很多人以为自动化测试的价值在于跑得比人快其实没那么简单。手工回归最坑的地方在于人的操作每一次都不一样这次忘了勾选一个 checkbox上次弹窗点错了顺序测到一半同事问了一句那个需求改了吗你手里的步骤就断了。自动化脚本的好处不是快而是它每一次都严格按同一套步骤执行还能源源不断地跑几十遍不抱怨。另一个容易被低估的点是回归成本。前端改一个公共组件影响的是所有引用了它的页面。手工验证时你得把每个受影响页面的主流程重新点一遍这种验证没有技术含量只有重复。如果公司一周发两个版本这件事就变成每周的固定开销而且是纯人力开销。Selenium 这种 Web 自动化测试框架本质上就是把这块开销用代码替代掉让机器去点那些你点过一百遍的按钮。1.2 横向对比下来我为什么还是选它2024 到 2025 年这个节点Web 自动化工具早就不止 Selenium 一家了。我简单对比过 Playwright、Cypress还有各种录制回放工具它们各有各的亮眼之处但最后我仍然把 Selenium 作为主力原因很实在。维度SeleniumPlaywrightCypress录制回放工具语言支持Java / Python / C# / JS / Ruby / KotlinJS / Python / Java / .NETJavaScript 为主基本不涉及代码浏览器覆盖Chrome / Edge / Firefox / Safari跨浏览器矩阵完整Chrome / Firefox / WebKit主要在 Chrome 系取决于录制器实现协议基础W3C WebDriver 标准、部分 CDP 支持CDP 为主原生运行在浏览器内私有机制生态与资料历史最久、社区最大、疑难杂症一搜就有年轻但发展快、微软背书前端团队上手顺一次性的演示工具学习曲线平缓知识点散但资料全稍陡但 API 设计现代前端开发者友好简单但上限极低Playwright 确实香API 写起来很顺手如果你的团队全是前端、项目又是新建的 SPA可以认真考虑。Cypress 在纯前端项目里体验很好但它的执行模型跑在浏览器内部处理多标签页、跨域这类场景时不如 Selenium 完整。至于录制回放我试过一两次就放弃了录出来的脚本极其脆弱页面一改版就崩只适合拿去糊弄演示。Selenium 最大的底气在于什么都能接语言绑定多团队里哪怕是写 Java 的老同事也能接力维护WebDriver 已经是 W3C 标准协议意味着它不是某家公司的私有方案整个行业都围着它转资料多到溢出你遇到的奇怪报错大概率前人早就踩过并且写了解法。说句实话选型这件事在多数公司里不是哪个最好而是哪个最不容易卡住别人。1.3 它能做什么以及它不适合做什么Selenium 最典型的场景是这三类跨浏览器回归测试、冒烟测试关键路径、重复性强的数据录入流程验证。我把自动化用例跑在发版前的流水线里既能当门禁卡又能把手工从机械劳动里解放出来。但它不是万能的。视觉回归像素级对比截图、性能压测、弱网模拟这些领域它做不了或者做得很勉强得配合专门工具。移动端原生 App 的自动化虽然理念同源但要走 Appium 那套体系是另一套工程。还有一点我特别想提醒如果你只是想验证后端接口返回的数据对不对别用 Selenium 去 UI 层点来点去先把接口自动化做起来UI 层只覆盖那些真正依赖浏览器交互的关键路径。把边界想清楚后面进入 WebDriver 的黑盒思路就不会乱。2. WebDriver 协议与 driver 版本匹配自动化跑不起来的第一道坎2.1 浏览器为什么不让你直接遥控很多人第一次写 Selenium 脚本时有个疑问我明明装了 Chrome为什么代码里还要装一个 chromedriver这东西到底是干嘛的原因是浏览器出于安全和稳定性的考虑从来不对外界开放直接操作自身的接口。你想要它打开页面、点击按钮、输入文字它不会让你绕过它的内部机制胡来。各家浏览器实际上只开放了一种受控的调试通道Chrome 和 Edge 走的是 CDPChrome DevTools ProtocolFirefox 走的是 Marionette 协议。这些协议能理解底层指令但格式复杂、各家的还都不一样。WebDriver 规范做的事情就是把这一堆私有协议抽象成一套统一的 HTTP 接口创建会话、导航网址、查找元素、点击、输入、切窗口、截屏全都定义成标准端点。chromedriver 就是 WebDriver 协议和 Chrome 之间翻译官它接收来自测试脚本的 HTTP 请求转成 CDP 指令发给 Chrome再把结果转回给脚本。你可以这么理解测试脚本是拿着遥控器的人chromedriver 是遥控器里的红外发射器浏览器是电视机。遥控器本身没有直接插进电视机的线路但它通过发射器能指挥电视机干活。Selenium 本身并不直接碰浏览器它只负责把遥控器按键翻译成标准指令。2.2 chromedriver 版本错配的现场与解法几乎每个入门的人都会碰到这个报错我帮同事排查过无数回了selenium.common.exceptions.SessionNotCreatedException: Message: session not created: This version of ChromeDriver only supports Chrome version 114 Current browser version is 122.0.6261.94原因是 Chrome 迭代速度很快而 chromedriver 跟浏览器版本是强绑定的。Chrome 一升级旧 driver 就不认账。解法分三步第一确认本机浏览器版本Chrome 地址栏输入chrome://version就能看到完整版本号第二去 chromedriver 的官方下载源找对应主版本的驱动不用精确到小版本比如 Chrome 130.x 配 chromedriver 130.y.z 就行第三把下载的驱动解压后放进系统 PATH或者直接在代码里指定路径。from selenium.webdriver.chrome.service import Service service Service(/Users/me/bin/chromedriver) driver webdriver.Chrome(serviceservice)我不太建议把驱动文件扔在项目目录就完事因为脚本在 CI 上跑时路径对不上照样会挂。放进统一目录、写进 CI 的基础镜像才是可维护的做法。2.3 Selenium Manager 出来之后要不要手管 driverSelenium 4.6 之后引入 Selenium Manager默认情况下如果你不指定 driver 路径它会自动探测浏览器版本下载对应驱动省掉了手动配版本这一步。对新手来说这简直是救命功能——第一次跑通脚本的阻力瞬间小了一半。但 Selenium Manager 在我实际使用中不是万能的。它在某些网络受限的环境下会卡住报类似Can not download driver的错误尤其在公司内网或者某些容器里。这时候反而要回到手动放驱动文件的方式把指定版本的 driver 打进镜像代码里用 Service 显式指定路径可控性最高。我的习惯是本地开发图省事让 Selenium Manager 自动管CI 镜像里一定是手动固化版本。还有个跟这章相关的细节容器里跑 Chrome 经常报unknown error: DevToolsActivePort file doesnt exist。这不是驱动版本问题是容器里缺进程通信环境加两个参数基本能解决options.add_argument(--no-sandbox) options.add_argument(--disable-dev-shm-usage)3. 环境搭建与第一个可运行脚本先解决能不能跑起来3.1 环境清单与安装先把环境准备齐我推荐的路子是装 Python 3.8 以上版本并建一个干净的虚拟环境别直接往系统 Python 里塞依赖。执行pip install selenium装上最新版 Selenium 4。装 Chrome 或 Edge。Firefox 也可以但多数国内项目还是 Chrome 系更常见。驱动方面Selenium 4.6 以上会自动处理不用手动下载如果网络受限就按第 2 章的方法手动放。这一步有个坑要提醒很多老教程还在用webdriver.Chrome(executable_path...)这种写法到了 Selenium 4 已经废弃了照抄会报TypeError: __init__() got an unexpected keyword argument executable_path。正确做法是用Service指定路径或者干脆不指定让 Selenium Manager 自己搞定。3.2 最小脚本逐行拆解先跑通一个最小脚本把感觉建立起来from selenium import webdriver from selenium.webdriver.common.by import By driver webdriver.Chrome() driver.get(https://www.baidu.com) print(driver.title) search_box driver.find_element(By.ID, kw) search_box.send_keys(Selenium Web 自动化) search_box.submit() print(driver.title) driver.quit()逐行说几个容易忽略的点。webdriver.Chrome()不传参数在 Selenium 4.6 环境中会自动拉起 Chrome新手阶段不用想太多。driver.get()会等待页面 DOM 加载完成才返回但注意它不等 Ajax 接口数据回来所以后面紧跟的元素查找如果撞上异步加载就会引出第 5 章的等待问题。search_box.submit()对输入框按回车提交表单比click()搜索按钮在某些场景下更不容易被遮挡。最后driver.quit()是关掉整个浏览器进程而driver.close()只关当前标签页。如果只 close 不 quit后台会残留一堆 Chrome 进程用完必须 quit这是最容易忽视的习惯问题。3.3 跑通之后我立刻补上的两个配置脚本跑通了先别急着写用例我建议立刻做两件小事。第一给浏览器加启动参数隐藏Chrome 正受到自动测试软件的控制的提示条并最大化窗口options webdriver.ChromeOptions() options.add_experimental_option(excludeSwitches, [enable-automation]) options.add_argument(start-maximized) driver webdriver.Chrome(optionsoptions)第二把截图功能养成肌肉记忆。自动化测试跑失败了最怕的是连现场都没留下。脚本一挂浏览器一关什么线索都没了。每次重要步骤可以顺手截一张图出问题时第一件事也是截图driver.save_screenshot(debug.png)这一步成本极低但调试效率提升是立竿见影的。我的规矩是脚本里任何except分支里都带driver.save_screenshot()至于等待怎么配合下一章专门讲。4. 元素定位决定脚本活一个版本还是活三年4.1 八种定位方式的实战速查Selenium 里最常用的定位方式一共有八种我整理成了一张速查表定位方式代码写法典型场景IDBy.ID, kw页面里有明确 id 的输入框、按钮最稳NameBy.NAME, wd表单字段通常有 name 属性Class NameBy.CLASS_NAME, btn元素 class 简单且唯一时用Tag NameBy.TAG_NAME, button想批量拿一类标签时用Link TextBy.LINK_TEXT, 新闻链接的完整文本已知且固定Partial Link TextBy.PARTIAL_LINK_TEXT, 新闻只记得链接文本一部分CSS SelectorBy.CSS_SELECTOR, #kw结构性强、层级关系清楚时首选XPathBy.XPATH, //input[idkw]其他方式都不好使时的兜底方案4.2 我常用的优先级选择逻辑实际项目里定位方式的优先级比会用八种重要得多。顺序大概是这样首选 id。id 在 HTML 规范里要求页面内唯一是天然稳定的钩子。但有个现代前端特有的大坑组件库会自动生成随机 id比如 antd 里可能看到field_eyJhbGciOiJIUzI1NiJ...这种又长又会变的 id绝对不能用于定位。遇到这种情况去寻找>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, 10) login_btn wait.until(EC.element_to_be_clickable((By.ID, loginBtn))) login_btn.click()常用的 EC 条件就那几个presence_of_element_located只等元素进 DOMvisibility_of_element_located等元素可见element_to_be_clickable等元素可见且可点点击类操作我基本都用它text_to_be_present_in_element等某个文本出现适合验证提交后返回的提示。5.2 实际项目里我推荐的等待组合我在项目里的做法是全局不在conftest.py里设隐式等待或者只设一个很短的 5 秒兜底。所有关键操作全部用显式等待。原因是这样能让报错可解释。如果用全局隐式等待 10 秒脚本卡住时你很难判断到底卡在哪一次 find_element而显式等待超时抛出TimeoutException时异常信息里直接带定位表达式和超时时长一眼就能看到是哪个元素等了 10 秒没出现。调试效率完全不是一个级别。另外提醒一个混用问题显式等待内部轮询时也会调用find_element系列底层方法如果你同时设了很长的隐式等待两个超时会叠加整个流程慢到怀疑人生。我的原则就是要么全显式要么隐式兜底设极短别把两个都设成 10 秒以上。5.3 StaleElementReferenceException元素过期的经典场景这类异常在翻页、刷新后尤其常见很多新人看见就懵。错误信息是StaleElementReferenceException: stale element reference: element is not attached to the page document。翻译成人话你手里拿的那个元素对象是页面刷新之前找到的旧人现在页面已经更新旧引用失效了。典型场景是循环翻页采集数据# 错误示范只找一次元素引用页面刷新后继续用 items driver.find_elements(By.CLASS_NAME, item) for i in range(10): items[i].click() # 点击后页面刷新下一轮循环这个引用大概率已失效 driver.back()正确做法是每轮循环里重新查找元素或者用 while 控制当前页状态。另一个高频场景是提交表单后页面返回成功提示——元素在提交过程中被替换了随后再去拿老元素就失效。解法还是那一条重新定位别攥着旧引用不放。这一段是等待机制里最容易被忽视但实战发生率极高的点。页面动了一下你的引用就过期了这不是玄学是 DOM 生命周期问题。理解了这一点你基本就把 Web 自动化的时间感建立起来了。6. 从裸脚本到测试工程Page Object、pytest 与 CI 落地6.1 Page Object把元素定位从用例里赶出去脚本一旦多起来散落在各处的定位表达式就是一场灾难。今天只写 10 条用例定位表达式重复 30 处你手动改还能忍等用例涨到 300 条前端换一个 class 名你就得满世界搜索替换改到怀疑人生。Page Object 模式PO的核心思想是一个页面对应一个类把页面上的定位表达式和操作行为收进类里。测试用例只描述业务不描述实现——用例里写的是登录成功不是在 id 为 username 的输入框输入文字再点 id 为 loginBtn 的按钮。from selenium.webdriver.common.by import By class LoginPage: def __init__(self, driver): self.driver driver self.username_input (By.ID, username) self.password_input (By.ID, password) self.login_btn (By.CSS_SELECTOR, button[data-testidlogin]) def login(self, username, password): self.driver.find_element(*self.username_input).send_keys(username) self.driver.find_element(*self.password_input).send_keys(password) self.driver.find_element(*self.login_btn).click()页面改版了只需要改这一个类用例本身一行都不用动。我见过太多项目测试代码比业务代码还难维护表面上自动化了实际上改一次页面就要花一天调脚本。PO 不复杂但它把一个工程问题消灭在萌芽里。6.2 pytest fixture 与数据驱动的组合到了框架层面Python 生态里最常用的测试框架是 pytest。它跟 Selenium 配合起来的体验非常顺。fixture 用来管理 driver 生命周期import pytest from selenium import webdriver pytest.fixture def driver(): options webdriver.ChromeOptions() options.add_argument(--headless) options.add_argument(--window-size1920,1080) d webdriver.Chrome(optionsoptions) yield d d.quit()用例里直接声明参数driverpytest 会自动注入。yield前面是初始化后面是清理用例跑完浏览器自动关。这样一来每条用例都不会因为上一个用例没关浏览器而被污染。数据驱动的价值在于同一套流程多组数据跑一遍。登录功能往往要验证多组账号密码和提示用 pytest 的参数化可以避免复制粘贴一堆重复用例pytest.mark.parametrize(username,password,expected_text, [ (tester01, 123456, 登录成功), (tester02, wrong-pwd, 密码错误), (, 123456, 请输入用户名), ]) def test_login(driver, username, password, expected_text): LoginPage(driver).login(username, password) result driver.find_element(By.ID, loginMsg).text assert expected_text in result这样一组参数跑一个循环数据、步骤、断言都分开了后面加新账号不需要再动用例逻辑。6.3 无头模式跑 CI失败时留证据本地调试完最终目标是把这套用例接到 CI 里让每次发版前自动跑一遍。CI 机器没有显示器Chrome 得跑无头模式。加上这行配置脚本就不用开可见窗口options.add_argument(--headless) options.add_argument(--disable-gpu)但无头模式有个磨人的特点它和有头模式偶尔行为不一致比如某些下拉框选项加载不出来、某些元素尺寸异常。遇到这种问题不要跟无头死磕先在本地非无头模式跑一遍对比确认脚本本身没问题再切回去。CI 里跑失败最怕连现场都没留。所以我的规矩是用例的finally或者 pytest 的钩子里一定截图。简单方案是出异常时driver.save_screenshot(failure.png)并保存driver.page_source这两样东西足够你复现和分析绝大多数失败。try: LoginPage(driver).login(tester01, 123456) except Exception: driver.save_screenshot(res/failure.png) with open(res/failure.html, w, encodingutf-8) as f: f.write(driver.page_source) raise再往上升级用例多了以后可以考虑pytest-xdist并行或者上 Selenium Grid 做多浏览器矩阵。但我的建议是前期别铺太大先把 20 条核心用例在 CI 里跑稳定再考虑分布式否则你会在基建上投入过头回头用例没几条。7. 高频报错速查与实战排错记录7.1 最常见的报错和对应解法把高频报错整理成一张表直接当排查手册用报错信息根因解决方向SessionNotCreatedExceptiondriver 和浏览器版本不匹配按第 2 章方法重新下载对应版本驱动NoSuchElementException定位方式变了、元素没加载、在 iframe 里截图看现场按 4.3 的顺序排查ElementClickInterceptedException目标元素被弹窗/遮罩挡住等遮罩消失或 JS 强制点击ElementNotInteractableException元素隐藏、禁用或尺寸为 0先等可见再操作检查是否 require 前置条件StaleElementReferenceExceptionDOM 刷新旧引用失效重新定位元素别保存长生命周期引用TimeoutException显式等待超时检查条件写错没、定位表达式错没、前端真的响应慢WebDriverException: cannot find Chrome binary找不到 Chrome 可执行文件在 options 里binary_location指定路径InvalidSelectorExceptionXPath / CSS 选择器语法写错去 DevTools 里验证表达式再放回代码7.2 三个资料里不常写但实战高频的坑第一个坑元素明明在页面上点击却被遮住了。报ElementClickInterceptedException最常见的原因是页面上浮了一层广告弹窗、Cookie 同意提示或者加载中的遮罩。我处理这类问题分两步走第一步等遮罩消失用EC.invisibility_of_element_located等 loading 元素完全看不见第二步才是用 JS 强制点击绕过遮挡element driver.find_element(By.ID, submit) driver.execute_script(arguments[0].click();, element)但我对 JS 强制点击比较谨慎因为它直接绕过真实用户路径能不用就不用。只有遮罩确实无法消除、等待策略也试过的情况下才用它并且要在注释里写明原因。第二个坑断言输入框的内容时用了.text永远拿不到值。input 元素的值不在 innerText 里要用element.get_attribute(value)。我见过有人写assert 管理员 in input_box.text怎么跑怎么挂因为.text在 input 上返回空字符串。同理checkbox 的选中状态要看is_selected()下拉框当前值要用Select类处理from selenium.webdriver.support.ui import Select select Select(driver.find_element(By.ID, city)) select.select_by_visible_text(北京)第三个坑无头模式下的行为差异。我在 headless 里跑一个带文件下载的用例发现下载的文件从来没出现在预期目录。后来确认是下载目录设置必须单独写进 prefs否则 headless 会用默认临时目录用例一结束就清掉。options.add_experimental_option(prefs, { download.default_directory: /tmp/auto_downloads, download.prompt_for_download: False, })这类问题排错时间很长因为脚本本身没报错只是产物失踪。我的建议是无头模式脚本写完后至少随机挑几个用例在非无头模式验证一遍别默认两种模式表现完全一致。说到这我最后想分享的其实是一个工作习惯我现在写新用例时固定三件事——想清楚元素有没有更稳的定位钩子、等它要用什么条件、失败时截图落在哪个目录。这套习惯比任何一个具体 API 都管用。Selenium 的门槛真不高难的是把一套用例在一个快速迭代的项目里维护住。如果你也在手工回归的苦海里别想着一步到位做得全公司最牛明天上班挑出 10 条最高频的用例开始转先让它们稳定跑起来比什么都强。
返回列表