ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python与Selenium:Web自动化测试从入门到工程实践

Python与Selenium:Web自动化测试从入门到工程实践 只要跟Web开发沾边就躲不开那种手指按到发酸的回归测试。上线前一天对着浏览器一遍一遍走流程填表单、点按钮、翻页、核对结果出一个问题就得从头再来。用Python实现自动化的Web测试Selenium就是把这一整套重复劳动交给脚本去干。它能打开真实浏览器、模拟用户点击、输入、滚动、断言页面内容还能在没人盯着的凌晨跑完整轮回归早上把失败用例带着截图摆到你面前。Selenium是这个领域里最老牌也最普及的自动化测试框架Python是它最好的搭档之一。这篇东西适合刚接触自动化测试的新手也适合已经写了几个脚本、正被诡异报错折磨的同学。我会把环境搭建、定位逻辑、等待机制、工程化落地以及自动化脚本被反爬机制误识别时的排查思路完整过一遍。1. 为什么自动化测试首选Python和Selenium1.1 自动化测试到底解决了谁的痛点先说人话自动化测试解决的不是“写代码的乐趣”而是“重复劳动带来的不可靠”。一个100步的流程人点10遍第7遍可能在某个下拉框上点错了最后得出完全错误的结论。脚本不会点错但脚本会老老实实把错误路径也走完、把结果记录下来这才是它真正的价值。拿我自己举例之前维护一个老后台每次发版前要验证登录、权限、录单、审核、导出五个模块手工跑一遍大概四十分钟出错重跑至少一小时。后来我把它们写成了30多个自动化用例跑一遍大约六分钟。时间省下来不是最关键的最关键的是每次跑的结果都一样不会因为今天状态差漏点某个按钮。自动化测试的定位从来不是替代手工测试而是把手工里最高频、最回归、最容易倦怠的部分接过去。1.2 Python和Selenium为什么是黄金搭档市面上的Web自动化方案不少Java、JavaScript、Ruby都能驱动Selenium但我个人仍然推荐用Python。原因有三点第一Python语法简洁写出来的用例几乎能当测试文档读团队成员上手成本低第二测试数据、报表、CI脚本都能用同一套语言串联不用来回切换技术栈第三Python社区里关于Selenium的踩坑记录最多遇到问题搜索一下基本都有答案。Selenium本身也不只是一套API。它支持Chrome、Edge、Firefox、Safari等主流浏览器而且Selenium 4开始全面走W3C WebDriver标准这意味着你写的脚本换浏览器跑时绝大多数代码可以原样复用。对各浏览器兼容性要求高的团队来说这是很现实的需求。它还有一个隐藏价值由于脚本可以驱动真实浏览器很多“网页数据抓取”的场景其实也是同一套技术栈爬虫工程师和自动化测试工程师经常互相参考代码。1.3 一场自动化测试在底层发生了什么理解Selenium的运行机制对你排查问题帮助很大。简单类比Selenium脚本就像一个“遥控器”浏览器则是被遥控的“电视”。脚本通过WebDriver协议向浏览器发送指令——打开网址、查找元素、点击、输入文字、读取页面标题。浏览器执行完后把结果再传回给脚本。你写的Python代码本质上是一连串发出去的命令和针对返回结果的断言。这个机制决定了几个排查思路如果脚本报“找不到元素”很多情况下不是脚本写错了而是命令发出时浏览器里的页面还没渲染完如果浏览器根本没启动问题大概率出在驱动和浏览器版本不匹配上如果页面能打开但点不动可能是元素被弹窗或遮罩层挡住了。万事归结到一句话你是在操作一个真实浏览器所以必须尊重页面的加载节奏和DOM状态。2. 环境准备Python、Selenium与浏览器驱动的坑2.1 把环境从零装起来配置环境这块最容易劝退新人实际上只要按顺序一步步来十分钟能搞定。第一步装Python。去Python官网下载安装包时记得勾选“Add Python to PATH”选项。很多人装完在终端敲python没反应基本都是这一步漏了。装完可以用括号里的命令验证python --version。VSCode用户还需要装Python扩展然后在命令面板里选择解释器建议直接选当前Python版本不然编辑器右下角会一直提示“未选择解释器”。python --version pip --version第二步安装Seleniumpip install selenium第三步准备浏览器驱动。Chrome浏览器就用ChromeDriverEdge浏览器就用Edge WebDriverFirefox就用geckodriver。驱动版本必须和浏览器版本匹配这是头号坑。如果你想省事Selenium 4.6以上内置了Selenium Manager它会尝试自动下载匹配的驱动。但受网络环境影响很多时候自动下载不顺畅我建议新手直接用最稳妥的方式打开浏览器的“关于”页面看版本号去对应驱动官网下载相同大版本的驱动文件解压后丢到任意目录。2.2 驱动路径与启动问题的处理逻辑有些教程会让你把驱动放到Python目录里或者配置环境变量。我现在的做法更直白用Service对象显式指定驱动路径这样一眼就能看出脚本在用哪个驱动出问题也好排查。from selenium import webdriver from selenium.webdriver.chrome.service import Service service Service(executable_pathD:/tools/chromedriver/chromedriver.exe) driver webdriver.Chrome(serviceservice) driver.get(https://example.com) print(driver.title) driver.quit()如果启动时报SessionNotCreatedException九成是这个提示的下一行里写了“This version of ChromeDriver only supports Chrome version xxx”翻译过来就是驱动和浏览器版本对不上。解决办法不是去改代码而是重新下载匹配版本的驱动文件。这一点值得反复强调自动化测试里很多玄学问题最后都是环境问题不是代码问题。2.3 第一个有序、能跑的脚本装好环境后第一段脚本不要写复杂就做一件事打开一个网页打印标题关闭浏览器。这段代码跑通了说明环境没有问题后面学元素定位才有意义。from selenium import webdriver options webdriver.ChromeOptions() options.add_argument(--window-size1920,1080) # 固定窗口大小避免页面响应式布局坑 driver webdriver.Chrome(optionsoptions) driver.get(https://example.com) print(页面标题:, driver.title) driver.quit()跑完之后说两点心得。第一driver.quit()和driver.close()不一样close()只关当前标签页quit()会结束整个浏览器进程。日常脚本一律用quit()否则会残留一堆后台进程后面再启动时就可能报端口占用。第二如果你是从旧教程里复制的find_element_by_id(xxx)写法在Selenium 4里会直接报错因为这类分散方法被移除了必须改成find_element(By.ID, xxx)这种统一写法。以后的代码我都会用后一种。3. 元素定位八个方法用哪个、怎么选3.1 八种定位方式与它们的性格页面自动化绕不开“找到元素”这关。Selenium提供了八种定位方式本质是八种“寻人启事”的写法。我按推荐度从高到低排一下定位方式写法示例适用场景IDBy.ID, username元素有唯一id时首选最稳定NAMEBy.NAME, email表单控件常用CLASS_NAMEBy.CLASS_NAME, btn-primary页面样式较规范时可用TAG_NAMEBy.TAG_NAME, input批量获取同类型元素时用LINK_TEXTBy.LINK_TEXT, 登录精确定位带文字链接PARTIAL_LINK_TEXTBy.PARTIAL_LINK_TEXT, 登链接文字很长时用模糊匹配CSS_SELECTORBy.CSS_SELECTOR, #login input.btn写起来优雅支持层级XPATHBy.XPATH, //div[idlogin]//input[1]没有稳定属性时作为兜底方案我的经验是**能用ID就用ID没有ID优先看name、>/html/body/div[2]/form/div[3]/button这种代码就是给自己埋雷。我一般会改成根据属性定位from selenium.webdriver.common.by import By driver.find_element(By.XPATH, //button[contains(class, login-btn)]) driver.find_element(By.XPATH, //input[placeholder请输入用户名])CSS选择器在复杂页面上更推荐因为它表达“元素长什么样”比XPath更直观driver.find_element(By.CSS_SELECTOR, form.login-form input[namepassword]) driver.find_element(By.CSS_SELECTOR, .modal .btn-confirm)Selenium 4还加入了相对定位器比如找“在某个按钮上方的输入框”“在某个标题右侧的链接”这在测复杂布局时很好用但起步阶段不用太在意。**定位的核心原则是优先找开发同学约定加测试专用属性。**我每接手一个项目都会推动前端在关键交互元素上加># 输入文字 username_input driver.find_element(By.ID, username) username_input.clear() username_input.send_keys(tester01) # 点击按钮 login_btn driver.find_element(By.ID, login-btn) login_btn.click() # 判断元素是否可见 print(login_btn.is_displayed())看起来简单但有几个细节值得注意。send_keys之前要不要clear()我的习惯是每次都调因为有些输入框会自带默认值或者上次残留的文字不先清空就会拼接成一段脏数据。另外某些定制组件的输入框send_keys会偶尔丢字符尤其是中文输入法环境下。这时候可以改用先点击输入框再发送内容或者直接用JavaScript赋值并触发事件后者属于高阶技巧遇到再处理。下拉框几乎是后台系统标配。处理select标签用Selenium自带的Select类比硬点option靠谱from selenium.webdriver.support.ui import Select city_select Select(driver.find_element(By.ID, city)) city_select.select_by_visible_text(北京)复选框、单选框这类元素click()既能勾选也能取消注意脚本里要维护“期望状态”和“当前状态”的一致性否则第二次运行可能点成了反方向。4.2 iframe和多窗口切换页面上如果嵌了富文本编辑器、地图组件、第三方支付控件很大概率会遇到iframe。iframe就是页面里嵌套的另一个完整页面Selenium的“眼睛”默认只能看到外层页面看不到嵌套里面的元素。这时候需要先切换再操作driver.switch_to.frame(content_iframe) body driver.find_element(By.TAG_NAME, body) body.send_keys(这是富文本内容) driver.switch_to.default_content() # 切回外层页面多窗口切换也是高频场景。点击一个链接新开了标签页Selenium默认还在旧页面必须手动切换driver.switch_to.window(driver.window_handles[-1]) # 操作完新窗口后再切回去 driver.switch_to.window(driver.window_handles[0])**记住一个口诀切换了就要切回来。**很多脚本跑到一半突然找不到元素就是因为上一个案例切进了iframe忘记切出下一个案例还在“迷路”状态。这就是典型的上下文污染。4.3 上下左右滚动把元素滚到可见再操作如果你的页面有无限滚动、横向表格、或者首屏懒加载滚动操作就是逃不开的环节。最常见的是滚动到页面底部触发加载更多driver.execute_script(window.scrollTo(0, document.body.scrollHeight))更推荐的是直接让某个元素滚动进入视野再对它操作target driver.find_element(By.XPATH, //button[contains(text(), 确认提交)]) driver.execute_script(arguments[0].scrollIntoView({block: center});, target) target.click()这里强调“滚到可见再点击”是因为很多按钮本身没有不可点击只是屏幕外看不见浏览器为了模拟真实用户行为会拒绝点击不可见元素报ElementNotInteractableException。至于热搜词里的“网页左右滑动”我一次性说透。横向滚动的场景多见于数据报表、时间轴、横向排列的卡片列表。整个页面横向滚动用window.scrollBydriver.execute_script(window.scrollBy(500, 0))嵌套容器内的横向滚动比如某个div设置了overflow-x: auto那要滚动的是这个容器driver.execute_script(document.querySelector(.table-wrapper).scrollLeft 500)实操中我会封装一个函数传容器选择器和偏移量进去这样表格组件多点几个页签时左右滑动就复用了def scroll_container(driver, selector, offset): driver.execute_script( fdocument.querySelector({selector}).scrollLeft {offset} )4.4 弹窗和文件上传弹窗分两种。一种是浏览器原生alert直接处理alert driver.switch_to.alert print(弹窗内容:, alert.text) alert.accept() # 点确定 # alert.dismiss() # 点取消另一种是页面内自定义弹窗本质上是DOM节点用普通定位就能找到往往还要处理一下遮罩层。点击被遮罩挡住时会报ElementClickInterceptedException临时解法是先按ESC或点击关闭按钮再从操作逻辑上解决。文件上传反而是最省心的场景之一。只要页面上有input typefile标签直接塞路径进去driver.find_element(By.CSS_SELECTOR, input[typefile]).send_keys(rC:\data\case.xlsx)不要去看什么模拟键盘快捷键那一套很脆弱。send_keys上传文件是Selenium里少数“简单又可靠”的操作。5. 等待机制是脚本稳定性的生命线5.1 三种等待方式到底差在哪脚本不稳定最常见的原因就是页面还没准备好代码就去操作了。Selenium里有三种等待方式我直接对比一下等待方式写法作用范围问题强制等待time.sleep(3)全局固定阻塞太慢、太脆能不用就不用隐式等待driver.implicitly_wait(10)所有元素查找过程只解决“元素出现”不解决“可点击”显式等待WebDriverWait(...).until(...)指定条件推荐使用灵活可控强制等待的问题在于不等就一定失败等少了也失败等多了白白浪费时间。隐式等待的问题是它只会持续轮询“元素存不存在”很多元素出现的是DOM节点但还没绑定事件、没渲染完点击一样会出错。所以我的项目中显式等待是绝对主力。5.2 显式等待的推荐写法显式等待的核心思路是给一个“条件”和“最长等待时间”Selenium会不断轮询页面直到条件成立或者超时。这样页面一毫秒加载完脚本就一毫秒继续执行快和稳都能兼顾。from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC login_btn WebDriverWait(driver, 10).until( EC.element_to_be_clickable((By.ID, login-btn)) ) login_btn.click()expected_conditions里常用的条件我整理一张表条件作用presence_of_element_located元素出现在DOM中但不一定可见visibility_of_element_located元素可见有尺寸、非隐藏element_to_be_clickable元素可见且可点击text_to_be_present_in_element元素包含指定文本element_to_be_selected元素被选中一般用于下拉项alert_is_present弹窗出现invisibility_of_element_located元素消失常用于等待弹层关闭有一点要特别提醒**别把隐式等待和显式等待混用。**两者的轮询机制会叠加导致实际等待时间比预期长得多还会制造一些间歇性失败。我在项目里只设置显式等待设不设全局隐式等待看团队规范但同一套代码里两种并存是大忌。5.3 动态加载页面的等待边界有些页面是点击按钮后发异步请求再渲染数据等待条件不能写死“等待某个文字出现”因为可能接口报错、数据为空文本永远不会出现。这时候写断言和等待应该等待“数据容器出现”而不是等“具体内容出现”WebDriverWait(driver, 10).until( EC.visibility_of_element_located((By.CSS_SELECTOR, .data-list)) )还有一个实用技巧等待表格的行数发生变化。比如页面展示“共50条”你筛选后变成“共5条”直接等待文本变化比较麻烦更好的做法是等“分页里的总条数元素”里不再包含原来的数字。这些都是经验活多做几次项目就有手感了。6. 从脚本走向框架用pytest搭建可维护的自动化用例6.1 别把所有代码堆在一个文件里脚本写到一定数量最大的问题不是“写不出来”而是“改不动”。所有用例堆在一个文件里每个用例都自己启动浏览器、自己定位、自己关闭100个用例就是100坨重复代码。改一个按钮的定位得全局替换几十处。Page Object模式就是来收拾这个局面的。核心思想不复杂**把每个页面抽象成一个类封装该页面上的操作和元素定位测试用例只关心业务步骤不关心具体定位表达式。**比如登录页class LoginPage: def __init__(self, driver): self.driver driver def login(self, username, password): self.driver.find_element(By.ID, username).send_keys(username) self.driver.find_element(By.ID, password).send_keys(password) self.driver.find_element(By.ID, login-btn).click()测试用例里调用起来就非常干净def test_login_success(login_page): login_page.login(tester01, 123456) assert login_page.is_logged_in() is True页面元素定位被收拢到页面类里以后前端改了按钮id只需要改一处。这种模式不是炫技是让自动化测试从“一次性脚本”变成“可持续维护资产”的分水岭。从第一天就按这个模式写比写了几百个脚本后再重构成本低得多。6.2 用pytest管理用例和失败截图pytest是我用来组织用例的骨架。它和Selenium本身关系不大但组合起来之后用例编写、执行、失败处理都变得规范。核心是写一个conftest.py用fixture管好浏览器生命周期import pytest from selenium import webdriver pytest.fixture(scopefunction) def driver(): options webdriver.ChromeOptions() options.add_argument(--window-size1920,1080) driver webdriver.Chrome(optionsoptions) yield driver driver.quit()失败截图是自动化测试最实用的功能之一。没有截图用例失败就只能靠日志猜有截图失败原因一目了然。在conftest.py里加一个hook每次用例失败自动截图pytest.hookimpl(hookwrapperTrue) def pytest_runtest_makereport(item, call): outcome yield report outcome.get_result() if report.when teardown and report.failed: driver item.funcargs.get(driver) if driver: driver.save_screenshot(freports/{item.name}.png)这样跑完一个用例集只需要打开reports目录就能按用例名看到所有失败现场。6.3 测试报告、日志与测试数据导出用例多一点之后裸的pytest控制台输出就不够用了。我一般加两个东西日志和报告。日志用Python标准库的logging即可在关键步骤输出“点击了什么”“输入了什么”“等待了什么”排查问题会舒服很多。报告方面可以用Allure插件也可以自己写一个脚本把执行结果汇总。说到底自动化测试的价值不在于“跑过的用例数量”而在于“失败的用例能不能在五秒内定位到原因”。另外很多业务线需要把结果提交给甲方或者领导看我会用openpyxl把测试结果导出成Excel表格import openpyxl wb openpyxl.Workbook() ws wb.active ws.append([用例名, 结果, 耗时, 失败原因]) for case in results: ws.append([case.name, case.status, case.duration, case.error]) wb.save(reports/auto_test_result.xlsx)6.4 一套框架的实际落地顺序我建议不要一上来就搭全套框架而是先跑通一个最痛的核心流程比如“登录新建单据审核通过”。跑通之后你才清楚这个项目里有哪些定位坑、等待坑、弹窗坑再回头搭框架时就能带着真实约束来设计。反过来先搭框架再写用例很容易做出一堆漂亮却跑不动的抽象层。框架是长出来的不是一开始设计出来的。7. 被反爬机制误识别成机器人从这些特征排查7.1 我们测自己的页面为什么还被“人机验证”拦有些团队做了自动化测试之后发现脚本访问自己的系统时频繁触发滑块验证或者风控拒绝。这个问题在热搜词里被叫“python selenium反爬虫”但它本质上不是爬虫问题而是自动化脚本和真实浏览器存在指纹差异被前端风控SDK识别出来了。最常见的自动化特征是navigator.webdriver属性。正常情况下真实浏览器里这个值是false由WebDriver驱动的浏览器这个值默认是true。这是一条非常明显的“机器人标记”。除此之外自动化脚本的User-Agent往往和真实浏览器不一致启动参数里带着自动化调试痕迹行为路径过于机械比如鼠标动作完全不带轨迹也会被风控系统标记。7.2 让脚本少一点“机器人感”的合规调整这里要先把立场说清楚下面的方法只适用于你自己开发、自己部署、有权限测试的系统。如果你的自动化测试总是被自家的风控系统拦下可以通过配置降低误判率。用于绕过别人网站的验证码或风控是不合规的不在本文讨论范围内。最基本的调整从启动配置开始options webdriver.ChromeOptions() options.add_argument(--disable-blink-featuresAutomationControlled) options.add_experimental_option(excludeSwitches, [enable-automation]) options.add_experimental_option(useAutomationExtension, False)--disable-blink-featuresAutomationControlled可以移除Chrome内核的自动化标记上面的excludeSwitches会去掉Chrome顶部的“Chrome正受到自动测试软件控制”提示。这两个参数属于公开、广泛使用的配置。navigator.webdriver属性还可以通过CDP命令在页面加载前覆盖掉driver.execute_cdp_cmd(Page.addScriptToEvaluateOnNewDocument, { source: Object.defineProperty(navigator, webdriver, { get: () undefined }); })这段代码的原理是在每次导航到新页面、页面JavaScript执行之前先注入一段脚本把navigator.webdriver的getter改掉。这样页面加载时读到的就已经是“看似正常”的值。这个方法在自动化测试圈子里很常见你把它理解为“给测试浏览器化个妆”让它更容易通过自己系统的风控校验。7.3 从根源上减少风控误伤绕来绕去都是治标真正治本的办法是让测试环境别和线上风控较劲。我的建议分三层第一测试环境关闭滑块等强验证能力或者配置白名单让测试账号直接绕过第二给自动化脚本准备专用的测试账号走独立的权限和限流策略第三推动前端在测试环境增加自动化识别支持比如识别到WebDriver时自动切换到低压模式。与其不断调整浏览器指纹不如从系统设计上让自动化测试这件事合法合规地被支持。另外要提醒一点如果你连自己公司的系统都过不了风控优先排查是不是登录态、IP、设备指纹的问题这类问题靠改浏览器参数解决不了得从测试账号和测试环境配置入手。自动化测试的边界永远是自己能控制的系统。8. 常见异常与排查技巧一份速查表8.1 高频报错和它的真实原因异常类型你看到的样子常见原因处理方式NoSuchElementException找不到元素定位表达式错、页面没加载完、在iframe里先等待再检查iframe最后验证表达式TimeoutException等待超时元素真的没出现或者条件写错打开失败页面截图看是元素没渲染还是条件问题ElementClickInterceptedException点击被拦截弹窗、遮罩层挡住了元素先关闭弹窗或用JS点击临时绕过ElementNotInteractableException元素不可交互元素隐藏、无尺寸、被禁用检查CSS属性确认是否真的可见StaleElementReferenceException元素引用失效页面刷新后旧元素引用还在用重新查找元素不要复用旧对象SessionNotCreatedException会话创建失败驱动版本和浏览器版本不匹配去下载匹配的驱动InvalidSelectorException选择器无效XPath或CSS语法写错在开发者工具里先验证这里特别讲一下StaleElementReferenceException。很多新手遇到这个就懵其实道理很简单你把一个元素对象存在变量里页面发生了刷新或者跳转原来的DOM节点被替换掉了旧引用自然失效。解决方法是重新定位后再操作不要保存“一次性”的旧引用跨页面使用。8.2 脚本“时好时坏”时先查哪几处如果脚本不是每次都失败而是偶发失败优先检查三件事第一是不是存在没清理干净的弹窗比如上一个用例留下的toast提示挡到了元素第二是不是等待时间临界建议把关键步骤的显式等待判断条件改宽松一点第三是不是测试数据串了前面的用例改了共享数据后面的用例就受影响。自动化测试里偶发失败经常不是脚本问题而是测试环境状态问题。在每个用例的setUp里重置数据、清理浏览器上下文会省掉大量排查痛苦。8.3 无头模式与下载文件的细节CI服务器上跑自动化一般会用无头模式也就是不弹出浏览器窗口options.add_argument(--headlessnew)无头模式跑得快但也有区别。最典型的是页面渲染尺寸、字体加载、PDF下载、文件下载行为都不同。文件下载时无头模式必须显式配置下载目录options.add_experimental_option(prefs, { download.default_directory: rD:/reports/downloads, download.prompt_for_download: False })如果不配置无头模式的下载不知道存到哪去测试“导出Excel”这类用例就永远失败。另外无头模式跑一个“点击地图”功能时我们遇到过窗口太小导致元素渲染不全的问题后来固定window-size1920,1080才稳定。能用无头就用无头但遇到诡异失败时先切回有头模式跑一遍看现场。8.4 最后分享一点跨过多次坑之后的体会做自动化测试这些年我最深的感受是**它不是一次性交付的“神器”而是一套需要持续维护的工程体系。**环境依赖、页面变化、测试数据、网络波动任何一环出问题都可能让脚本变成摆设。我更推荐从最痛的小用例开始先解决自己重复劳动最多的一小块业务跑稳定了再逐步扩大范围。维护脚本遇到问题时别急着从网上复制定位表达式先回来看等待是否合理、iframe是否遗漏、驱动和浏览器版本是否匹配——这三个地方解决了大部分问题都能落地。另外自动化测试的最终价值是让团队有信心做频繁发布。脚本能稳定地在每次发版前跑完核心回归把失败用例清晰地递到你面前这件事比“写得很炫酷”重要得多。保持脚本简洁、保持等待机制合理、保持定位符可读这套体系能陪你走很久。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进