做了几年Web测试,有一段时间我几乎把全部精力都花在手工点页面上。每天重复登录、填表单、验证数据的流程,明明逻辑没变,却要一遍遍在浏览器里点来点去。直到我认真把Python和Selenium这套组合用起来,才体会到什么叫“写一次脚本,让浏览器自己去跑”。这篇文章就是基于我自己从零搭建Web自动化测试的真实经验,从方案选型、环境搭建、脚本编写到问题排查,梳理出一套可以直接参考的完整路径。无论你是刚接触自动化测试的脚本新手,还是在手工测试里挣扎想转型的从业者,这套思路和代码都能帮你少走不少弯路。
1. 项目整体思路与方案选型
1.1 我们到底要解决什么问题
先聊清楚一个事:自动化Web测试不是把手工点的每一步机械地录下来,而是通过代码直接驱动浏览器执行一系列操作,再断言页面结果是否符合预期。它的核心价值在于“可重复”和“回归”。
我自己经历过的场景很典型:一个电商系统每两周发一个版本,每次发版前都要把登录、商品搜索、加购物车、下单支付这个主链路完整走一遍。手工测一次半小时,全量回归可能要一整天。后来我把主链路写成了自动化脚本,每次发版前跑一遍只需要几分钟,冒烟测试从半天压缩到半小时以内。这才是自动化测试存在的意义——让机器去替人做那些重复、确定、枯燥的验证动作,人把精力留给探索性测试和复杂业务逻辑。
如果你处在这种状态:项目频繁迭代、核心流程稳定、页面元素大多有稳定标识,那用Selenium做Web自动化测试就是很合理的选择。它不是万能药,但覆盖大部分前端功能回归绰绰有余。
1.2 为什么偏偏选Selenium而不是其他方案
选工具这件事,我踩过不少坑。最早试用过商业化工具,录制回放确实炫,但脚本一复杂就失控,维护成本高得离谱。后来转向开源方案,在Selenium、Playwright、Puppeteer之间犹豫了很久。最终定下来Selenium,核心原因有三点:
第一,语言生态。Selenium支持Python、Java、C#、Ruby等主流语言,而Python在测试领域的简洁性毋庸置疑。我可以用最少的代码写最清晰的用例,团队其他成员上手也快。
第二,浏览器兼容性。Selenium WebDriver是W3C标准,Chrome、Firefox、Edge、Safari都原生支持。很多公司的用户群就是用不同浏览器访问系统的,只有Selenium能一套用例多浏览器跑,这种覆盖面是其他工具很难比的。
第三,社区积累。Selenium火了十多年,遇到任何奇怪问题几乎都能在社区里找到答案。我后来遇到的一个动态元素定位问题,就是在老帖子里找到的思路。选测试工具不是选最潮的,是选最不容易让你卡死的。
1.3 整套方案的架构长什么样
我习惯把自动化测试项目分成三层,这个分层思路也是业界常见的做法:
底层是驱动层,包含浏览器驱动程序,负责把脚本指令转换成浏览器能识别的原生操作;中间是测试逻辑层,用Page Object模式把页面元素和操作封装成独立模块,测试用例不直接接触繁杂的选择器;上层是用例层,只描述业务动作和预期结果。
这样分层的好处非常直接:页面改了,只需要改对应Page Object里的定位逻辑,用例代码基本不用动。后来产品经理要求把登录按钮的CSS类名换掉,我只改了一个文件里的一行定位代码,五十多条用例全部照常跑过。这个体验让我彻底相信了分层设计的价值。
2. 环境搭建与Selenium核心机制
2.1 从安装依赖到浏览器驱动配置
写Selenium脚本前,先把基础环境配好。我用的是Python 3.9+,配合Selenium 4.x版本,整体步骤很简单:
pip install selenium光装这个包还不够。Selenium本身不操作浏览器,它要借助WebDriver来指挥浏览器。以Chrome为例,需要下载和浏览器版本匹配的chromedriver。以前还要手动下载驱动再配置Path,Selenium 4.13之后有了Selenium Manager,可以自动管理驱动版本,省了好多事。但我还是建议你至少知道手动配驱动的路径,因为公司内网环境经常没有外网访问权限。
我当时在公司内网就踩过这个坑。首台测试服务器没法访问外网,pip的镜像源配了内网地址才装上Selenium,但chromedriver下载这一步卡了很久。最后是在有外网的机器上下载驱动压缩包,拷到内网后解压,再把驱动路径通过webdriver.Chrome(executable_path='/path/to/chromedriver')指过去才解决。
注意:你用的是Chrome还是Edge,驱动必须严格对应浏览器。版本号差太远会直接抛session not created异常,这个排查起来很浪费时间。
2.2 浏览器实例启动与参数配置
启动一个浏览器实例,最基础的写法是:
from selenium import webdriver driver = webdriver.Chrome() driver.get("https://example.com")但实际工作中,我几乎不会用这么裸的配置。跑自动化测试时通常不希望弹真实窗口,尤其是一跑几十个用例时,弹窗满屏飘,机器会卡到怀疑人生。所以无头模式是必开的:
from selenium import webdriver from selenium.webdriver.chrome.options import Options options = Options() options.add_argument("--headless=new") options.add_argument("--window-size=1920,1080") options.add_argument("--disable-gpu") options.add_argument("--no-sandbox") # Linux服务器上常需要这个 options.add_argument("--disable-dev-shm-usage") driver = webdriver.Chrome(options=options)这里有个细节值得展开:--headless=new是新版无头模式,对WebGL、CSS渲染的支持比旧版好。如果你在无头模式下截图,发现某些元素渲染异常,可以试试加--window-size=1920,1080,很多布局问题其实就是默认窗口太小导致的。
我之前在一个后台管理系统的测试中就遇到过表格列重叠的问题,当时百思不得其解,代码在本地有头模式跑得好好的。后来排查到是服务器的虚拟显示分辨率只有800x600,页面按响应式布局收缩后,表格全挤成一团。加了窗口尺寸参数后问题直接消失。
2.3 元素定位,几大策略对比与我的取舍
驱动浏览器打开页面后,一切操作的本质都是先找到元素,再对元素做操作。Selenium提供了多种定位策略,我按实际使用频率给你排个序:
- ID:最高优先,HTML里ID通常唯一,定位速度快,但页面里不是所有元素都有ID
- CSS Selector:灵活度极高,支持层级、属性、伪类,我日常工作里用这个最多
- XPath:能处理CSS不好表达的结构,比如根据文本内容定位、找父级兄弟节点,但性能略差,写长了也难维护
- 链接文本:只适合精确链接定位,局限性强
- Name:表单元素常见,但页面里重复Name不少,要注意
我踩过最大的坑是过度依赖XPath。早期写脚本时,不管什么元素都抄浏览器开发者工具拷一个XPath出来,后来页面结构一改,脚本就大面积报错。现在我给自己定的规矩是:能用CSS Selector就用CSS Selector,XPath只在确实需要处理复杂DOM关系时才用,而且尽量用相对路径而不是从html根节点一路写到底。
比如定位一个按钮,我推荐写成:
driver.find_element(By.CSS_SELECTOR, "button.submit-btn")而不是一大串/html/body/div[2]/div[3]/form/button。前者对DOM结构变化的容忍度高得多。
2.4 显式等待与隐式等待,防元素未加载的关键
新手最容易犯的错是:脚本跑得飞快,页面没跟上,元素还没渲染出来就直接去操作,结果抛出NoSuchElementException。这本质上是同步问题。
Selenium给了两种等待方案:隐式等待是设置一个全局超时时间,每个查找元素操作都会轮询等待;显式等待则是针对特定条件,等待某个元素出现、可点击、可见以后再继续执行。
我个人的经验是:不要用隐式等待做主要防御手段。隐式等待一旦设置太长,会让定位失败的元素白白等很久,拖慢整个测试套件。显式等待更精准,代码可读性也更好:
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) submit_btn = wait.until( EC.element_to_be_clickable((By.CSS_SELECTOR, "button.submit-btn")) ) submit_btn.click()用expected_conditions判断元素可点击,比time.sleep(3)这种固定等待靠谱太多。固定等待最大的问题在于不确定性:网络稍慢就报错,网络快又白白多等。后来我把脚本里的sleep全部改成显式等待,稳定性直接上了个台阶。
3. 实操过程:从登录到订单全链路脚本
3.1 一个刚需场景:登录用例怎么写
理论讲再多,不如来一段实际代码。我拿一个模拟项目X来举例——一个典型的电商系统,我需要测试用户从登录到提交订单的完整链路。
先写登录用例的骨架:
import time from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC def test_login(): driver = webdriver.Chrome() wait = WebDriverWait(driver, 10) try: driver.get("https://mall.example.com/login") # 填写用户名和密码 username = wait.until(EC.presence_of_element_located((By.ID, "username"))) username.send_keys("qa_user") password = driver.find_element(By.CSS_SELECTOR, "input[name='password']") password.send_keys("Passw0rd!2025") # 点击登录按钮 login_btn = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, "button.login-btn"))) login_btn.click() # 断言跳转后的页面包含用户名 welcome = WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.CSS_SELECTOR, ".user-info")) ) assert "qa_user" in welcome.text print("登录成功,断言通过") finally: driver.quit()这里有个细节,登录按钮点击后页面可能重新加载或者走Ajax跳转,断言前最好用WebDriverWait等待目标元素出现。我遇到过不少脚本跑到这里就断言失败,原因就是刚点完登录按钮立刻去查元素,页面还停留在登录态。
3.2 复杂点的场景:商品搜索到加购再到提交订单
单测登录只能算入门,真实业务链路长得多。我把搜索商品、选规格、加入购物车、提交订单整合成一个用例片段,你可以直接参考:
from selenium.webdriver.common.keys import Keys # 搜索商品 search_input = wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, "input.search-box"))) search_input.send_keys("无线机械键盘") search_input.send_keys(Keys.ENTER) # 等待搜索结果中出现商品卡片,并点击第一个商品 first_item = wait.until( EC.element_to_be_clickable((By.CSS_SELECTOR, ".goods-item .goods-title")) ) first_item.click() # 切换窗口,因为商品详情通常在新标签页打开 handles = driver.window_handles driver.switch_to.window(handles[-1]) # 选择规格并加购 spec_btn = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, ".sku-item.active"))) spec_btn.click() add_cart_btn = driver.find_element(By.CSS_SELECTOR, "button.add-cart-btn") add_cart_btn.click() # 去购物车结算 cart_btn = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, ".cart-icon"))) cart_btn.click() checkout_btn = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, "button.checkout-btn"))) checkout_btn.click() # 在订单确认页提交订单 submit_btn = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, "button.submit-order"))) submit_btn.click() # 断言订单编号生成 order_no = wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, ".order-no"))) assert order_no.text.startswith("ORD")这段代码里有几个值得注意的点,都是我实际踩出来的经验。
第一,driver.switch_to.window(handles[-1])这一步很容易漏。点击商品后如果在新标签页打开,不切换窗口的话,脚本会在原页面上找不存在的元素。更稳的做法是先在switch_to.window前判断窗口数量是否增加,避免网络慢导致窗口还没创建就去切换。
第二,spec按钮的定位。很多商品的规格选项是动态渲染的,直接取active类可能还没生成。我做的最简陋的兜底方案是加显式等待,更优雅的方案是遍历所有sku-item,选择第一个可点击的:
from selenium.webdriver.remote.webelement import WebElement def pick_first_clickable_sku(driver, wait): skus = wait.until( EC.presence_of_all_elements_located((By.CSS_SELECTOR, ".sku-item")) ) for sku in skus: if sku.is_enabled() and sku.is_displayed(): sku.click() return3.3 参数化数据驱动:把登录用例拆成多条数据
真实测试不能只测一条正确路径。登录功能至少要考虑:账号错误、密码错误、空表单、手机号格式不对。如果每条用例都重写一遍脚本,维护成本太高。我们可以把数据和逻辑拆开。
我的做法是创建一个简单的数据列表,用循环驱动同一个登录函数:
login_cases = [ {"username": "qa_user", "password": "Passw0rd!2025", "expect": "success"}, {"username": "wrong_user", "password": "Passw0rd!2025", "expect": "fail"}, {"username": "qa_user", "password": "WrongPass", "expect": "fail"}, {"username": "", "password": "", "expect": "fail"}, ] for case in login_cases: result = login_and_check(case["username"], case["password"]) if case["expect"] == "success": assert result is True else: assert result is False这个模式下,新增一条测试数据只需要往列表里加一个字典,完全不用动核心函数。后面如果你想把数据放到Excel、YAML甚至数据库里,也只是换个数据源的事,逻辑层完全不受影响。
3.4 测试报告与日志,给脚本配上一双眼睛
脚本能跑还不够,你得知道它跑得怎么样。最简单的输出方式就是打印日志,但真实项目里我建议直接上日志模块:
import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s", filename="test_run.log" ) logger = logging.getLogger(__name__) logger.info("开始执行登录用例")配合截图更是排查问题的利器。Selenium提供了获取页面截图的API,我通常会在断言失败时截一张图:
import os def take_screenshot(driver, name): ts = time.strftime("%Y%m%d_%H%M%S") path = os.path.join("screenshots", f"{name}_{ts}.png") driver.save_screenshot(path) logger.info(f"截图保存至: {path}")这样做的好处是,脚本半夜定时跑失败了,第二天你打开test_run.log和截图目录,能直接还原现场:是在哪一步、什么页面状态下出的问题。省去了大量“重跑一遍看能不能复现”的时间。
4. 常见问题排查与避坑技巧
4.1 元素定位失败的几种典型情形
NoSuchElementException是Selenium世界里的第一大异常,出现频率极高。我梳理了几种引发这个异常的情形:
页面还没加载完就去找元素。解决办法是加WebDriverWait,优先级最高的是用presence_of_element_located或者visibility_of_element_located判断。
页面在frame或shadow DOM中,默认的查找上下文覆盖不到。我遇到过一个支付页面,支付按钮嵌在iframe里,直接find_element怎么都找不到,切进iframe以后一次就成功了:
driver.switch_to.frame("paymentIframe") pay_btn = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, "button.pay-btn")))元素在页面底部,没有被浏览器视口加载。这时候需要先滚动到元素位置再操作:
driver.execute_script("arguments[0].scrollIntoView(true);", element)4.2 StaleElementReferenceException,动态页面的头号敌人
这个异常名翻译过来叫“元素引用过期”。意思是元素对象还在,但它挂载的DOM节点已经被更新或者重新渲染,再操作旧引用就会报错。
我印象最深的是分页表格的测试。每次点击下一页,整个表格的DOM都会被重建,我之前拿到的元素引用全部失效。解决办法也很直接,每次点击翻页后重新获取元素:
# 错误示范:先找到元素,再点击翻页后直接操作 page_btn = driver.find_element(By.CSS_SELECTOR, ".next-page") page_btn.click() page_btn.click() # 这里大概率报 StaleElementReferenceException # 正确做法:每次点击前重新定位 driver.find_element(By.CSS_SELECTOR, ".next-page").click() wait.until(EC.staleness_of(page_btn)) driver.find_element(By.CSS_SELECTOR, ".next-page").click()用staleness_of等待旧元素消失,可以在页面上任何交互逻辑里当作同步点。
4.3 元素不可交互:被遮挡或者没到可点击状态
有时候元素明明定位到了,但click()就是报错,提示element not interactive。我总结了下,通常有两类原因:
一类是元素被其他元素遮挡。比如弹窗遮罩层没完全消失,按钮被覆盖着。另一类是元素有disabled或readonly属性,此时用element_to_be_clickable等待就会一直卡到超时。
排查这类问题的标准思路是:先看页面实时状态,确认元素是可见可点击的;再看脚本里的等待条件是否匹配。
如果确实被遮挡,优先上显式等待遮罩层消失:
mask = driver.find_element(By.CSS_SELECTOR, ".modal-mask.in") wait.until(EC.invisibility_of_element(mask))4.4 用例间相互影响导致串行问题
写自动化测试很容易踩的一个坑是:用例之间有隐性依赖。比如一个测试用例先执行登录,另一个用例假设登录态已经存在,直接去访问需要鉴权的页面。这样当第一个用例失败时,后面所有用例跟着失败,排查起来也特别痛苦。
我的改进方案是让每个用例都自己负责前置条件。需要登录的用例,开头就完成登录动作;需要特定数据的用例,先通过接口或者页面操作构造数据。这样每个用例可以独立运行,不会因为执行顺序改变而崩掉。
4.5 实践中的浏览器兼容性隐患
测试脚本在你本地Chrome上跑得好好的,不代表它能在其他浏览器里也跑得动。我后来用Selenium Grid把用例同时分发到Chrome和Edge环境执行,就发现过不少兼容性差异:
- 有些元素的CSS类名在Edge里有大小写不同
- 有些动画在Firefox里执行更慢,显式等待超时时间要延长
- 旧版IE模式下,部分CSS选择器不生效
如果你团队的用户确实跨浏览器,我建议尽早把多浏览器执行纳入测试范围,不要等上线前才临时查。
5. 进阶实践:Page Object模式与CI集成
5.1 Page Object模式,让测试代码真正可维护
脚本越写越多以后,如果你还到处写着driver.find_element(By.ID, "xxx"),那改版时你会非常痛苦。Page Object模式的核心思想,是把页面抽象成一个类,把页面里的元素定位和操作方法都封装在类里,测试用例只调用类的方法。
我以登录页面为例:
class LoginPage: def __init__(self, driver, wait): self.driver = driver self.wait = wait def load(self): self.driver.get("https://mall.example.com/login") return self def enter_username(self, username): el = self.wait.until(EC.presence_of_element_located((By.ID, "username"))) el.clear() el.send_keys(username) return self def enter_password(self, password): el = self.driver.find_element(By.CSS_SELECTOR, "input[name='password']") el.clear() el.send_keys(password) return self def click_login(self): btn = self.wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, "button.login-btn"))) btn.click() return self测试用例就变得非常精简:
def test_login_success(): driver = webdriver.Chrome() wait = WebDriverWait(driver, 10) try: login_page = LoginPage(driver, wait).load() login_page.enter_username("qa_user").enter_password("Passw0rd!2025").click_login() welcome = wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, ".user-info"))) assert "qa_user" in welcome.text finally: driver.quit()这样说吧,Page Object模式最大的价值不是减少代码量,而是让“页面变化影响测试脚本”这件事被限制在一个小范围内。登录页结构变了,我只需要改LoginPage,几十个依赖登录态的用例不会跟着改。
5.2 无头模式与定时执行,让回归测试自动跑起来
把自动化测试跑起来只是第一步,让它自动跑才是解放生产力的关键。我一般用两种方式实现定时执行:
第一种是用操作系统自带的任务计划程序,比如Windows计划任务或者Linux的cron。写一个简单的shell脚本,执行python test_login.py这类命令,每天凌晨固定跑,再把日志输出到文件里。这个方案零成本,适合小团队。
第二种是基于CI流水线。代码仓库里配置一个测试任务,每次提交代码时触发测试,测试失败就阻断合并。这个思路能尽早发现问题,但前期需要投入搭建流水线的时间。
我在模拟项目X里用的是第一种,每天凌晨5点全量回归,早上到办公室先看日志。如果一片绿,基本可以安心做手工探索性测试;如果红了,打开截图和日志直接定位问题,比手工回归高效太多。
5.3 我踩过的一个无头模式特殊坑
远程服务器上跑无头模式,有个常见异常WebDriverException: unknown error: DevToolsActivePort,刚开始会以为驱动没装好,其实多数原因是Chrome在Linux环境缺少必要依赖包或者沙箱权限受限。
解决方法是先确认系统里有没有Chrome本体,Selenium只装了驱动是不够的。然后看驱动日志,通常会提示缺哪些库,libnss3、libatk这些基础依赖需要补齐。生产服务器的最小化安装经常不带这些。
另外,如果并发跑多个浏览器实例,建议每个实例用不同的用户数据目录,否则会资源冲突:
options.add_argument("--user-data-dir=/tmp/chrome_test_profile_1")5.4 项目规模变大后,pytest是你的好伙伴
脚本一多,靠if __name__ == "__main__"逐个运行已经不够用了。我后期把项目切到了pytest框架,配合fixture管理浏览器实例,用例编写和结果收集都规整了不少。
这里给一个小小的fixture示例:
import pytest from selenium import webdriver from selenium.webdriver.chrome.options import Options @pytest.fixture def driver(): options = Options() options.add_argument("--headless=new") driver = webdriver.Chrome(options=options) yield driver driver.quit() def test_login_with_fixture(driver): driver.get("https://mall.example.com/login") assert "登录" in driver.titlepytest的好处还在于天然支持参数化、用例分组、插件扩展,以及生成JUnit XML格式测试报告。后面如果要对接CI系统,这个格式是标准的,很多平台上都能直接展示测试结果。可以说,从零散脚本过渡到pytest,是自动化测试项目从“能跑”走向“能用”的关键一步。
6. 写在最后的几点体会
从最早靠time.sleep硬等页面加载的新手,到后来用Page Object、显式等待、无头模式、pytest编排整套回归体系,我最大的感触是:自动化测试的核心不是炫技,而是稳定地省时间。保持脚本稳定的几个习惯,值得你从第一天就养成:选择稳定的定位策略,能靠ID和CSS就不靠复杂的XPath;等待条件要精准,明确等什么元素出现,而不是盲目睡几秒;用例之间互相独立,谁都不依赖谁的执行顺序;失败时要有足够的现场证据,日志、截图、控制台输出缺一不可。
还有一个我后来才悟到的点:自动化测试并不是完全替代手工测试。复杂页面的视觉效果、交互手感、边界逻辑,这些机器很难验证全面。它更适合做回归保障,把确定性的检查交给脚本,把不确定性留给人的判断。你可以先把最核心的日常流程自动化,比如登录、搜索、下单、支付这类的关键链路,再从报障和需求变动最频繁的业务模块入手。用最小成本覆盖最大风险,逐步再把它扩展到整个全流程。这套方法我在模拟项目X里落地之后,团队的发版信心强了不少,大家终于敢在改完代码之后快速验证老功能了。希望这篇文章的思路和代码,也能让你少踩几个坑,早一点把Web自动化测试跑起来。