ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Python使用Selenium与pytest进行高效测试的示例详解

Python使用Selenium与pytest进行高效测试的示例详解

前言


如果说「用 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 层是金字塔顶端,贵而脆,只留那些「非走真浏览器不可」的检查;能下沉的统统下沉。做到这一点,你的测试套件会从「不敢跑」变成「随时敢跑」,这才是「高效」的真正含义。




RELATED READING

延伸阅读

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