前言如果说「用 Selenium 加 pytest 搭一个测试框架」是把骨架立起来那么本文聚焦的是单条用例到底该怎么写。同样一个「登录」场景有人写成三十行到处硬等待的脚本有人写成五行的清晰断言——差别不在工具而在对用例写作规则的理解。先纠正一个普遍误解很多人以为 UI 测试写得越多、覆盖越广就越好。恰恰相反UI 测试是整个测试金字塔里最慢、最贵、最脆的一层。它每次都要真开浏览器、真加载页面一条用例几秒到几十秒它对页面结构、网络时序格外敏感稍有风吹草动就红。所以正确的策略是UI 层只放真正需要「端到端走一遍」的关键路径能下沉到单元测试或接口测试去验证的逻辑就不要放到 UI 层。这条判断贯穿全文。本文聚焦的是每一条用例内部的写法——断言怎么写、conftest.py放什么、跳过与预期失败怎么标、数据怎么驱动至于测试目录怎么组织、浏览器生命周期怎么统一管理那是另一层话题本文不展开。一、断言用最直白的写法表达期望pytest 的断言之所以好用在于它直接用 Python 的assert失败时还会把两边的值打印出来不需要你调用专门的断言函数。# 适用于 Python 3.8from selenium.webdriver.common.by import Byfrom selenium.webdriver.support.ui import WebDriverWaitfrom selenium.webdriver.support import expected_conditions as ECdef 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, timeout10).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.8import pytestfrom selenium.common.exceptions import NoSuchElementExceptiondef 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.8conftest.pyimport pytestfrom selenium import webdriverfrom selenium.webdriver.chrome.options import Optionspytest.fixturedef driver():opts Options()opts.add_argument(--headlessnew) # 无头模式CI 上无需显示器d webdriver.Chrome(optionsopts)d.implicitly_wait(0) # 明确不使用隐式等待try:yield d # 交给测试用例使用finally:d.quit() # 用例结束必然回收这里用yield的 fixture 写法yield之前是前置准备之后finally里是清理。把浏览器的创建与销毁集中在 fixture 里用例本身就不必操心收尾也不会因为某条用例忘了quit()而残留进程。conftest.py里还可以放公共的登录辅助函数、测试数据的路径常量等——凡是「多个用例都要用」的东西都归这里。三、跳过与预期失败给不稳定的用例一个正式归宿UI 测试会遇到两种特殊情况某些用例在当前环境下暂时不该跑比如只在特定浏览器上才有意义以及某些用例已知会失败比如对应的缺陷还没修。pytest 为这两种情况都准备了正式标记不要用「注释掉」或「加个if跳过」来对付。# 适用于 Python 3.8import sysimport pytestpytest.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修复前预期失败, strictTrue)def test_known_bug(driver):...区别要讲清skip是「不跑」结果是跳过skipif是「满足条件才跳过」条件在收集阶段求值xfail是「跑但预期它失败」——如果它真的失败算预期内xfail如果它竟然通过了则标记为 xpass。strictTrue会让 xpass 变成失败这样当缺陷被修复、用例开始通过时你会被提醒去摘掉这个标记而不是让一个过期的 xfail 永远挂着。这三者最重要的价值是让「状态」显式化一条被 skip 的用例在报告里会明确显示为跳过而不是「不存在」。这对测试可信度很关键——你随时能看出哪些覆盖是缺的。四、数据驱动一份逻辑跑多组边界参数化是减少重复的利器但这里要强调「UI 层参数化要克制」。因为每组参数都会真的跑一遍浏览器参数一多反馈时间就失控。# 适用于 Python 3.8import pytestfrom selenium.webdriver.common.by import Bypytest.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后就不管了缺陷修好了标记还挂着。✅ 用strictTrue让「意外通过」暴露出来及时摘标记。❌ 把大量业务规则校验也塞进 UI 参数化一套跑几分钟。✅ UI 层只覆盖关键边界能下沉到单元/接口层的就下沉。❌ 选择器依赖易变的样式类或页面层级页面改版用例成片失败。✅ 用稳定锚点专用测试属性、语义角色、稳定的 id。❌ 同时开隐式等待和显式等待超时行为互相干扰难以定位。✅ 二选一推荐只用显式等待如设了隐式等待就统一到 0 或明确注释。总结主题关键做法断言先等就绪再断言用pytest.raises表达期望异常公共设施浏览器生命周期与公共函数放conftest.py状态标记skip/skipif/xfail(strictTrue)让状态显式数据驱动参数化只覆盖少量高价值边界分层策略UI 只留端到端关键路径其余下沉写好 Selenium pytest 用例核心就两句话一条用例只回答一个问题把等待和收尾交给 fixture。在这之上牢记分层原则——UI 层是金字塔顶端贵而脆只留那些「非走真浏览器不可」的检查能下沉的统统下沉。做到这一点你的测试套件会从「不敢跑」变成「随时敢跑」这才是「高效」的真正含义。