做测试这行,“自动化测试框架”这七个字几乎每天都能听到。但你要是真让一个人从零把Python、Selenium、pytest这些东西组装成一套能跑的框架,并且讲清楚每一步为什么这么设计,很多人是脚本能写、设计讲不出来。这篇内容就是想解决这两个问题,直接聊一个能落地的Python + Selenium自动化测试框架,从环境搭建、元素定位、等待机制、页面滚动到pytest工程化、报告输出,一路走到底。适合刚入门的测试新人,也适合写了一阵脚本但越来越觉得维护困难的工程师,看完至少能少走我当年踩过的那些弯路。
1. 先想明白:你搭的到底是“脚本”还是“框架”
1.1 自动化测试的三种常见形态
很多人写自动化写了一年,本质上还是在写脚本,而不是框架。这两者的区别,可以简化成一句话:脚本是为了“这次能跑过”,框架是为了“一直能跑下去”。
我把平时见到最多的三种形态总结了一下:
| 形态 | 特点 | 优点 | 致命问题 |
|---|---|---|---|
| 纯脚本 | 一个.py文件从头跑到尾,写死浏览器、URL、账号密码 | 上手快,几十行就能跑通 | 业务一变就崩,数据一变就崩,别人看不懂 |
| 数据驱动 | 测试数据从Excel、YAML、JSON读取,代码和数据分离 | 数据层和逻辑层解耦,新增数据比改代码快 | 元素定位分散,用例一多依然难维护 |
| PO分层框架 | 页面对象封装 + pytest管理 + 数据驱动 | 定位集中、用例简洁、易维护 | 起步门槛高,需要一点设计意识 |
我最早写自动化就是第一种,一个文件里塞了二十个用例函数。跑的时候倒也顺畅,直到某天登录框的class属性改了个名,我满项目搜字符串,改到半夜才意识到——没有框架的自动化,本质上是在给自己挖坑。
框架的核心意义不是“跑得更快”,而是改得更快、挂得更少、查得更准。这也是我后来坚持把项目拆成pages、testcases、utils这些目录的原因。
1.2 Selenium在框架里到底负责什么
Selenium说白了是一个浏览器自动化驱动库,它帮我们控制Chrome、Edge、Firefox去执行点击、输入、跳转、滚动这些操作。但注意,Selenium本身不管理测试用例,不生成报告,不管测试数据,也不负责失败重跑。它只是框架里最底层的那双手。
打个比方,Selenium像汽车的油门和方向盘,负责“开得动”;而框架是导航、仪表盘、保养手册,负责“知道怎么开、开到哪里、出了问题怎么修”。很多人只装了油门和方向盘就上路了,结果自然是翻车。
另一个常被忽略的点是,Selenium背后是WebDriver协议,这个协议定义了一套浏览器操作的标准接口。理解这件事,你会发现Appium做移动端自动化时,用的也是同一套协议思路,所以会Selenium的人学Appium会很快。这也是为什么面试时聊框架,绕不开WebDriver规范。
2. 环境搭建的坑,我基本替你踩完了
2.1 Python与Selenium安装避坑指南
我建议直接装Python 3.10及以上版本,这类版本对Selenium 4的兼容性最好。装完记得把Python和Scripts目录加到系统PATH里,否则命令行敲python没反应,别问我是怎么知道的。
接着用pip安装核心依赖:
pip install selenium pip install pytest pip install pytest-html这里有一个新手高频翻车点:网上大量老教程用的是Selenium 3的写法,比如find_element_by_id(),但Selenium 4.x里这些方法全部移除了,统一改成find_element(By.ID, "value")。如果你抄了一段老代码报AttributeError,十有八九是版本API变了。
Selenium 4里正确的定位写法是:
from selenium import webdriver from selenium.webdriver.common.by import By driver = webdriver.Chrome() driver.get("https://example.com") element = driver.find_element(By.ID, "username")2.2 chromedriver版本匹配的终极解法
Selenium要控制Chrome,需要浏览器有一个对应的“遥控器”,这就是chromedriver。很多刚入门的人卡在这里:明明代码没问题,chrome一启动就报错,不是SessionNotCreatedException就是cannot find Chrome binary。
先理解匹配规则:chromedriver的大版本号必须和Chrome浏览器的大版本号一致。比如你本机Chrome是126,那chromedriver也应该是126.x,小版本的误差通常问题不大。
查本机Chrome版本的方法:地址栏输入chrome://version,或者点右上角三个点,帮助,关于Google Chrome。
好消息是,Selenium从4.6版本开始内置了Selenium Manager,它能自动检测浏览器版本、下载匹配的chromedriver。所以只要你用的是新版Selenium,多数情况下根本不需要手动下载driver。如果某些内网环境自动下载失败,再去手动找对应版本的chromedriver,放s常用路径或者指定webdriver.Chrome(executable_path=...)。
我现在的习惯是:只要环境允许,就依赖Selenium Manager自动管理driver,不把driver文件写进项目目录。因为driver和项目本身没有逻辑关系,放进项目里反而会随着浏览器升级出现各种版本错乱。
2.3 项目目录与依赖管理
一套合理的项目结构,决定了这个框架半年后你还愿不愿意看。我常用的目录长这样:
auto_test/ ├── pages/ # 页面对象层 ├── testcases/ # 测试用例层 ├── utils/ # 工具函数 ├── reports/ # 测试报告 ├── screenshots/ # 失败截图 ├── logs/ # 日志 ├── conftest.py # pytest全局配置 ├── pytest.ini # pytest入口配置 └── requirements.txt依赖管理用最朴素的venv + requirements.txt就够了。创建虚拟环境:
python -m venv venv然后在虚拟环境里安装依赖并导出:
pip freeze > requirements.txt虚拟环境的价值在于,同一台机器上不同项目依赖的包版本打起来也不会互相干扰。很多自动化项目跑着跑着突然全挂,查到最后就是全局环境的某个依赖被别的项目升级了,这种经历一次就会老实了。
3. 核心API实战:从元素定位到页面滚动
3.1 元素定位:80%的case用这几种就够
Selenium提供了8种定位方式,日常高频使用的其实只有3种:ID、XPATH、CSS_SELECTOR。
ID能用就用,这是最稳定且速度最快的定位方式。但真实业务系统里,很多元素的老是动态的,或者前端压根没给加ID,只能退到XPATH和CSS。
关于XPATH,我强烈建议放弃绝对路径。你从浏览器F12直接右键Copy XPath复制出来的通常是这种:
/html/body/div[3]/div/div[1]/form/div[2]/input这种路径一旦页面结构多套一层div就会挂。正确的做法是写相对定位,靠元素的稳定属性、文本内容或者结构关系来定位:
# 用稳定属性定位 driver.find_element(By.XPATH, "//input[@name='username']") # 用文本定位按钮 driver.find_element(By.XPATH, "//button[contains(text(),'登 录')]") # 用CSS选择器定位更简洁 driver.find_element(By.CSS_SELECTOR, "input[name='username']")我个人的取舍标准是:CSS能写就写CSS,因为可读性好、执行快;XPATH主要用于处理包含文本匹配、按层级找父级这类CSS不方便表达的场景。如果你发现XPATH越写越长,大概率是页面设计有问题,或者你依赖了不该依赖的动态属性,建议回头重新分析元素特征。
3.2 等待机制:脚本跑不稳的头号原因
自动化测试脚本最大的不稳定因素,不是定位写错,而是元素还没加载出来就去操作了。解决这个问题,看不到sleep不是好的,powerful用显式等待。
先区分两个概念:
- 隐性等待
implicitly_wait:全局生效,告诉WebDriver轮询一段时间再抛异常。但它的等待条件很宽泛,只保证元素出现在DOM里,不保证可见、可点击。 - 显式等待
WebDriverWait:针对某个条件等待,比如元素可点击、可见、存在,可以精确控制。
推荐的做法是:全局设一个短的隐性等待兜底(比如3秒),关键操作前用显式等待指定条件。我给一个自己封装的点击方法,框架里几乎所有点击都走这个方法:
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By def click_element(driver, locator, timeout=10): wait = WebDriverWait(driver, timeout) element = wait.until(EC.element_to_be_clickable(locator)) element.click()调用时传一个元组就行:
click_element(driver, (By.ID, "login-button"))这比直接driver.find_element(...).click()稳定得多。注意,显式等待的超时时间不是越长越好。我见过有人全项目设置30秒,一个用例失败要等半分钟才有结果,排查效率极低。普通业务系统3到10秒是合理区间。
3.3 网页左右滑动与滚动加载的正确姿势
页面滚动算是一个被低估的刚需场景,热搜里也经常能看到“selenium 网页左右滑动”。很多人以为滚动只有window.scrollTo一种,其实要看滚动的容器是谁。
滚动的是整个页面视口:
driver.execute_script("window.scrollTo(0, document.body.scrollHeight)")要滚动到某个元素可见,用scrollIntoView更靠谱,它会自动调整滚动位置,支持水平和垂直两个方向:
driver.execute_script("arguments[0].scrollIntoView({block: 'center'})", element)横向滚动是很多人的盲区。电商网站经常有横向切换的轮播图、或者是某个列表容器内部横向滑动,这时候window.scrollTo根本没用,因为可滚动的是内部容器:
driver.execute_script("arguments[0].scrollLeft += 500", container_element)判断可滚动容器的方法很朴素:在DevTools里选中要滚动的区域,看CSS的overflow属性。如果值为auto或scroll,那这个容器就是独立的滚动主体,必须操作它,而不是操作window。
另外强调一下,滚动操作本身是一个隐性依赖布局的行为。能用元素定位解决的问题,尽量不要依赖坐标滚动,因为不同分辨率下坐标完全不一样。滚动只用来“让元素可见”,之后依然要用正常方式去点击。
4. pytest加持:把脚本升级成可维护的框架
4.1 fixture与conftest:公共逻辑不再重复
pytest之所以成为Python自动化测试的事实标准,fixture机制功不可没。它解决的核心问题是前置准备和清理工作到处都是重复代码。
最简单的fixture用法:
import pytest from selenium import webdriver @pytest.fixture(scope="function") def driver(): _driver = webdriver.Chrome() yield _driver _driver.quit()测试用例里直接声明参数driver,pytest会自动完成浏览器启动和关闭:
def test_login(driver): driver.get("https://example.com") driver.find_element(By.ID, "username").send_keys("admin")注意两点。第一,yield前面的代码是前置,yield后面是后置,后置代码即使用例失败也会执行,这是它能保证浏览器不泄漏的关键。第二,scope参数控制fixture的复用范围:function是每个用例单独起浏览器,module是整个模块共用一个,session是整轮测试共用。刚入门别迷信“共用一个浏览器更快”,共用了之后用例之间的状态隔离问题更多,建议先老老实实用function作用域。
conftest.py是pytest的全局配置文件,放在根目录时,所有用例都能自动使用里面的fixture和hook函数。我常用的conftest.py至少包含:driver管理、失败截图钩子、命令行参数读取。
4.2 Page Object模式:让测试代码告别“牵一发动全身”
Page Object模式的核心理念,用一句话概括:页面长什么样,测试代码不用关心;页面上的操作装进page里,TestCase只关心业务流程。
我一般分三层:
BasePage:封装所有页面共用的操作,如点击、输入、滚动、截图、显式等待。LoginPage:继承BasePage,专门描述登录页的元素和操作。TestLogin:只写测试逻辑,不出现任何XPATH。
BasePage简化版:
class BasePage: def __init__(self, driver): self.driver = driver def click(self, locator, timeout=10): WebDriverWait(self.driver, timeout).until( EC.element_to_be_clickable(locator) ).click() def input(self, locator, text, timeout=10): element = WebDriverWait(self.driver, timeout).until( EC.visibility_of_element_located(locator) ) element.clear() element.send_keys(text)LoginPage简化版:
class LoginPage(BasePage): username_input = (By.ID, "username") password_input = (By.ID, "password") login_button = (By.ID, "login-btn") def login(self, username, password): self.input(self.username_input, username) self.input(self.password_input, password) self.click(self.login_button)这样改带来的直接好处是:登录框属性变了,只改LoginPage一个类;跳转到新页面,只新建一个Page类;完全不会出现测试用例里到处是定位表达式的混乱场面。看起来多写了一层,实际上省掉的排查时间,远超过这一层代码成本。
再配合pytest的参数化,测试数据可以从用例里彻底剥出去:
@pytest.mark.parametrize("username,password", [ ("admin", "password123"), ("test", "test123"), ]) def test_multi_login(driver, username, password): LoginPage(driver).login(username, password)4.3 报告与失败截图:出了问题能定位
没有报告和截图的自动化,等于跑了个寂寞。脚本失败时连现场证据都没有,排查成本极高。
我建议先用pytest-html解决“有没有报告”的问题,命令很简单:
pytest -v --html=reports/report.html但更关键的是失败时自动截图。这需要通过pytest的钩子函数实现,放到conftest.py里:
import os import pytest from datetime import datetime @pytest.hookimpl(hookwrapper=True) def pytest_runtest_makereport(item, call): outcome = yield report = outcome.get_result() if report.when == "call" and report.failed: driver = item.funcargs.get("driver") if driver: os.makedirs("screenshots", exist_ok=True) filename = f"screenshots/{item.name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png" driver.save_screenshot(filename) report.screenshots = [(filename, "失败截图")]这段代码干的事情:用例失败时,自动从当前测试函数的fixture里找到driver实例,截图存到screenshots目录,并把图片路径附加到测试报告里。以后任何人跑出失败,直接把截图甩给前端,根本不用吵到底是谁的问题。
5. 常见问题速查与AI自动化测试的新方向
5.1 高频报错与排查对照表
我把这几年被问得最多、以及自己踩过的坑整理成一张速查表,希望你能直接照着排雷:
| 报错信息 | 根因 | 解决思路 |
|---|---|---|
WebDriverException: unknown error, cannot find Chrome binary | Chrome安装路径异常 | 用options.binary_location指定Chrome可执行文件路径 |
SessionNotCreatedException | chromedriver和Chrome版本不匹配 | 更新chromedriver到匹配版本,或升级到Selenium 4.6以上用Manager |
NoSuchElementException | 定位表达式错误或元素在iframe/新窗口 | 检查表达式,先switch_to.frame(...)再定位 |
ElementClickInterceptedException | 元素被遮罩、浮层挡住 | 等遮罩消失,或改用JS点击 |
StaleElementReferenceException | DOM刷新后旧引用失效 | 不要长期持有元素,操作前重新定位 |
TimeoutException | 显式等待超时 | 确认定位条件写对、元素确实在DOM里、网络是否慢 |
AttributeError: 'WebDriver' object has no attribute 'find_element_by_id' | 老API与Selenium 4不兼容 | 改写成find_element(By.ID, ...) |
最后一个值得一提的坑是StaleElementReferenceException。这个错误对于那些“先找到元素,然后在循环或跨页面操作中又拿它来点”的场景特别常见。我的原则很简单:元素操作前现用现找,不要缓存。如果确实需要在多个操作步骤中使用同一个元素,每一步都重新定位一次,牺牲一点性能换稳定性是值得的。
5.2 从Selenium到AI辅助测试:我的几点观察
聊完传统框架,再说说最近测试圈肉眼可见的变化。AI自动化测试是热词里的高频选项,很多人焦虑“AI会不会替代手工测试、替代测试开发”。我的判断是:AI替代不了框架设计,但它确实在改变写自动化测试的方式。
现在业界比较活跃的方向是AI辅助生成测试用例和智能元素定位。过去写一条登录用例,要人工分析页面结构、写XPATH、写断言;现在可以让模型看页面DOM和业务描述,直接帮你生成POM代码骨架,然后测试人员负责审查和补充边界场景。它干的活更像是“加速器的活”,而不是“驾驶员的活”。
另外,有人把“Agent框架”和“Co-Star框架”这类结构化提示词方法论引入测试流程。本质上是把给AI的任务拆成角色、执行步骤、输出格式等维度,让生成的脚本更可控、更稳定。这个方向我觉得相当有潜力,但前提是你自己得懂框架的结构和边界——如果连步骤三层都讲不清楚,AI帮你生成的代码照样是一堆难以维护的碎片。
我的态度一直是:现有框架的底子打扎实,AI才能成为放大器;底子虚悬,AI只会让错误扩散得更快。
从我自己的使用体验来看,这套Python + Selenium + pytest的组合,最适合的落地场景是中小规模Web系统的回归测试和冒烟测试。它不需要重型平台支撑,几个人维护足够,遇到大的系统重构也能及时跟上。如果你刚起步,先别盲目追求架构的花哨,把driver管理、等待机制、PO分层这三件事做扎实,远比多写一百条用例有意义。后续想扩展的话,可以往数据驱动、Jenkins定时执行、Allure报告、接入接口测试这些方向慢慢延伸。每一步顺着现有结构加,都不会太难。