前言
如果说「用 Selenium 加 pytest 搭一个测试框架」是把骨架立起来,那么本文聚焦的是单条用例到底该怎么写。同样一个「登录」场景,有人写成三十行到处硬等待的脚本,有人写成五行的清晰断言——差别不在工具,而在对用例写作规则的理解。
先纠正一个普遍误解:很多人以为 UI 测试写得越多、覆盖越广就越好。恰恰相反,UI 测试是整个测试金字塔里最慢、最贵、最脆的一层。它每次都要真开浏览器、真加载页面,一条用例几秒到几十秒;它对页面结构、网络时序格外敏感,稍有风吹草动就红。所以正确的策略是:UI 层只放真正需要「端到端走一遍」的关键路径,能下沉到单元测试或接口测试去验证的逻辑,就不要放到 UI 层。这条判断贯穿全文。
本文聚焦的是每一条用例内部的写法——断言怎么写、conftest.py放什么、跳过与预期失败怎么标、数据怎么驱动;至于测试目录怎么组织、浏览器生命周期怎么统一管理,那是另一层话题,本文不展开。
一、断言:用最直白的写法表达期望
pytest 的断言之所以好用,在于它直接用 Python 的assert,失败时还会把两边的值打印出来,不需要你调用专门的断言函数。
# 适用于 Python 3.8+
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_search_shows_results(driver):
driver.get("http://127.0.0.1:8000/search")
driver.find_element(By.NAME, "q").send_keys("python")
driver.find_element(By.ID, "submit").click()
# 先等结果出现,再做断言,避免时序抖动
WebDriverWait(driver, timeout=10).until(
EC.presence_of_element_located((By.CSS_SELECTOR, "#results .item"))
)
items = driver.find_elements(By.CSS_SELECTOR, "#results .item")
assert items, "搜索结果不应为空"
assert driver.title.startswith("搜索结果")两个要点。第一,断言之前先等条件就绪——绝大多数「偶发失败」都是「断言跑在元素出现之前」。第二,给断言写一句人能读懂的消息(assert items, "搜索结果不应为空"),失败时 pytest 会把它一并输出,看报告的人不必去翻代码。
对于「期望抛异常」的场景,用pytest.raises捕获,而不是try/except自己判断:
# 适用于 Python 3.8+
import pytest
from selenium.common.exceptions import NoSuchElementException
def test_missing_element_raises(driver):
driver.get("http://127.0.0.1:8000/empty")
with pytest.raises(NoSuchElementException):
driver.find_element(By.ID, "definitely-not-there")pytest.raises会断言「这段代码确实抛了指定类型的异常」,没抛反而算失败。这比手写try/except更清晰,也不容易漏掉「异常没抛」这种情况。
二、conftest.py:把公共设施放在这里
conftest.py是 pytest 的「约定文件」,放在其中的 fixture 会被同目录及子目录的测试自动发现,不需要 import。Selenium 项目里最典型的就是一个负责开关浏览器的driverfixture。
# 适用于 Python 3.8+(conftest.py)
import pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
@pytest.fixture
def driver():
opts = Options()
opts.add_argument("--headless=new") # 无头模式,CI 上无需显示器
d = webdriver.Chrome(options=opts)
d.implicitly_wait(0) # 明确不使用隐式等待
try:
yield d # 交给测试用例使用
finally:
d.quit() # 用例结束必然回收这里用yield的 fixture 写法:yield之前是前置准备,之后(finally里)是清理。把浏览器的创建与销毁集中在 fixture 里,用例本身就不必操心收尾,也不会因为某条用例忘了quit()而残留进程。conftest.py里还可以放公共的登录辅助函数、测试数据的路径常量等——凡是「多个用例都要用」的东西都归这里。
三、跳过与预期失败:给不稳定的用例一个正式归宿
UI 测试会遇到两种特殊情况:某些用例在当前环境下暂时不该跑(比如只在特定浏览器上才有意义),以及某些用例已知会失败(比如对应的缺陷还没修)。pytest 为这两种情况都准备了正式标记,不要用「注释掉」或「加个if跳过」来对付。
# 适用于 Python 3.8+
import sys
import pytest
@pytest.mark.skip(reason="该功能本期未上线,用例暂时停用")
def test_new_feature(driver):
...
@pytest.mark.skipif(sys.platform == "darwin",
reason="仅在非 macOS 环境验证")
def test_platform_specific(driver):
...
@pytest.mark.xfail(reason="已知缺陷 #123,修复前预期失败", strict=True)
def test_known_bug(driver):
...区别要讲清:skip是「不跑」,结果是跳过;skipif是「满足条件才跳过」,条件在收集阶段求值;xfail是「跑,但预期它失败」——如果它真的失败,算预期内(xfail);如果它竟然通过了,则标记为 xpass。strict=True会让 xpass 变成失败,这样当缺陷被修复、用例开始通过时,你会被提醒去摘掉这个标记,而不是让一个过期的 xfail 永远挂着。
这三者最重要的价值是让「状态」显式化:一条被 skip 的用例,在报告里会明确显示为跳过,而不是「不存在」。这对测试可信度很关键——你随时能看出哪些覆盖是缺的。
四、数据驱动:一份逻辑跑多组边界
参数化是减少重复的利器,但这里要强调「UI 层参数化要克制」。因为每组参数都会真的跑一遍浏览器,参数一多,反馈时间就失控。
# 适用于 Python 3.8+
import pytest
from selenium.webdriver.common.by import By
@pytest.mark.parametrize(
"keyword, expect_empty",
[
("python", False),
("", True),
],
)
def test_search_variants(driver, keyword, expect_empty):
driver.get("http://127.0.0.1:8000/search")
driver.find_element(By.NAME, "q").send_keys(keyword)
driver.find_element(By.ID, "submit").click()
items = driver.find_elements(By.CSS_SELECTOR, "#results .item")
assert bool(items) is (not expect_empty)原则是:用参数化覆盖少量高价值边界(空输入、超长输入、特殊字符),而不是穷举所有组合。如果你想验证的是「关键词解析逻辑」本身,那它就该是一个单元测试,几毫秒跑几十组;放到 UI 层来跑同样的组合,是把单元测试的活儿塞进了最慢的一层。
五、为什么 UI 测试要少而稳
把上面的讨论收束成一条判据:一条检查,如果不用真开浏览器也能验证,就不要放进 UI 测试。
| 检查内容 | 应放层次 | 原因 |
|---|
| 字符串拼接、参数校验 | 单元测试 | 毫秒级,不依赖浏览器 |
| 接口字段、返回码 | 接口测试 | 稳定、快、可大量覆盖 |
| 关键路径能否走通 | UI 测试 | 只有端到端才能验证 |
| 页面间跳转是否正确 | UI 测试 | 依赖真实渲染与导航 |
| 按钮颜色、样式 | 视觉/快照测试 | 不该用行为断言去测 |
这么分层的直接好处是反馈快、维护省。UI 层只保留「非它不可」的那几条关键路径,用例总数控制在能让人一眼看完报告的规模;其余全部下沉。同时,UI 用例数量少了,每条用例更要追求稳定:用显式等待代替硬睡、用稳定的选择器、断言前先等就绪。少而稳,胜过又多又飘。
常见坑点
- ❌ 断言直接跟在操作后面,元素还没出现就判失败,用例时红时绿。
✅ 先显式等待目标元素就绪(WebDriverWait),再做断言。
- ❌ 用
try/except Exception: pass把异常吞掉,用例永远「通过」。
✅ 期望异常用pytest.raises(具体异常类型)显式断言。
- ❌ 把浏览器创建、销毁写在每条用例里,一处配置要改几十处。
✅ 收进conftest.py的yieldfixture,统一前置与清理。
- ❌ 暂时不管的用例直接注释掉,报告里看不出覆盖缺口。
✅ 用@pytest.mark.skip/skipif显式标记,状态可见。
- ❌ 已知缺陷用
xfail后就不管了,缺陷修好了标记还挂着。
✅ 用strict=True,让「意外通过」暴露出来,及时摘标记。
- ❌ 把大量业务规则校验也塞进 UI 参数化,一套跑几分钟。
✅ UI 层只覆盖关键边界;能下沉到单元/接口层的就下沉。
- ❌ 选择器依赖易变的样式类或页面层级,页面改版用例成片失败。
✅ 用稳定锚点(专用测试属性、语义角色、稳定的 id)。
- ❌ 同时开隐式等待和显式等待,超时行为互相干扰,难以定位。
✅ 二选一,推荐只用显式等待;如设了隐式等待就统一到 0 或明确注释。
总结
| 主题 | 关键做法 |
|---|
| 断言 | 先等就绪再断言;用pytest.raises表达期望异常 |
| 公共设施 | 浏览器生命周期与公共函数放conftest.py |
| 状态标记 | skip/skipif/xfail(strict=True)让状态显式 |
| 数据驱动 | 参数化只覆盖少量高价值边界 |
| 分层策略 | UI 只留端到端关键路径,其余下沉 |
写好 Selenium + pytest 用例,核心就两句话:一条用例只回答一个问题,把等待和收尾交给 fixture。在这之上,牢记分层原则——UI 层是金字塔顶端,贵而脆,只留那些「非走真浏览器不可」的检查;能下沉的统统下沉。做到这一点,你的测试套件会从「不敢跑」变成「随时敢跑」,这才是「高效」的真正含义。