/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
昨天那批用例本地全绿,放到 CI 上十次红两三��。这类问题里,相当一部分不是产品坏了,也不是用例逻辑错了,而是「等」没写对。
这一篇把两件事讲透:怎么等;用例挂了怎么判断该找谁。
代码在 Windows / Python 3.11.8 / Playwright 1.62.0 / Chromium 151.0.7922.34 上实跑,配套脚本 examples/lab/day36/,26 passed + 3 failed(那 3 个 failed 是 AI 假修复样本,设计内)。
本文引用的官方资料:Playwright Python 文档、自动等待与可操作性、断言与重试、WebDriver 标准。
一、自动等待等的是什么
页面.goto(本地服务 + "/slow/2001?delay=600")
金额 = 页面.locator('[data-testid="order-amount"]').inner_text()
看着像「直接取」,实际上 locator 在操作前会等到元素达到可操作状态:
| 可见 | 不是 display:none,不是 visibility:hidden |
| 可点 | 没有被别的元素挡住 |
| 稳定 | 连续两帧的 bounding box 一致,不再移动 |
实测这一行等了 0.81 秒(页面延时 600 毫秒 + 浏览器开销),一行 sleep 都没有。
locator 和 get_by_test_id 这类定位器都自带这个等待;而 page.query_selector 返回的是裸元素,没有等待。所以「用 locator 还是用 query_selector」不只是风格问题。
二、三种等待写法的实测对照
页面 /slow/2001?delay=600 的内容 600 毫秒后才插进去。
A. 自动等待
页面.goto(本地服务 + "/slow/2001?delay=600")
文字 = 页面.locator('[data-testid="order-amount"]').inner_text()
# 0.81 秒后拿到 "159.80"
B. sleep
页面.goto(本地服务 + "/slow/2001?delay=600")
time.sleep(0.2)
存在 = 页面.locator('[data-testid="order-amount"]').count()
# 0 —— 元素还没出现
sleep 的问题不是「不准」,是没法验证。它和「元素出现了」之间没有任何因果关系。
C. 显式等待
页面.goto(本地服务 + "/slow/2001?delay=600")
页面.locator('[data-testid="order-amount"]').wait_for(state="visible", timeout=5000)
超时边界明确,而且失败信息里会带上「等的是哪个元素、等了多久」。
计时起点的一个坑
页面里的 setTimeout 是从页面解析那一刻开始计时的。如果从 goto 返回之后才开始计时,会少算掉 goto 本身花的时间:
开始 = time.time() # 必须在 goto 之前
页面.goto(…)
文字 = 页面.locator(…).inner_text()
耗时 = time.time() – 开始
assert 耗时 >= 0.6
实测:只量 inner_text 那一段是 0.425 秒,从 goto 之前算是 0.63 秒。断言 0.6 秒时用错起点,就会间歇性失败。
三、可重试断言
from playwright.sync_api import expect
expect(页面.locator('[data-testid="order-amount"]')).to_have_text("159.80", timeout=5000)
expect 会反复检查,直到对上或超时。对比一次性断言:
文字 = 页面.locator('[data-testid="order-amount"]').inner_text(timeout=200)
assert 文字 == "159.80"
区别有两个:
Expected: "999.99"
Received: "159.80"
expect 不只支持文本,还能等属性、等可见性、等数量:
expect(页面.locator('[data-testid="message"]')).to_have_attribute("data-result", "paid", timeout=5000)
四、超时边界
同一个页面、同一个断言,只有超时不同:
| 200 毫秒 | 失败 | 0.20 秒 |
| 5000 毫秒 | 通过 | 0.81 秒 |
「用例不稳」有时候只是超时给得不对。 但放宽之前必须确认根因不在服务端——见第六节。
超时该怎么定:先量,再定。 跑三次取最大值,留 2—3 倍余量:
实测 = []
for _ in range(3):
开始 = time.time()
页面.goto(…)
expect(…).to_have_text("159.80", timeout=5000)
实测.append(time.time() – 开始)
最高 = max(实测)
定超时 = int(最高 * 1000 * 3)
五、三路诊断
准备了一个页面,内容要等一个接口回来才渲染:
| /slowapi/{id}?delay=600 | 接口慢 600 毫秒,但返回 200 |
| /slowapi/{id}?delay=100&fail=1 | 接口返回 500,页面显示「加载失败」 |
现象一样:页面上没有金额。根因完全不同。
第一路:网络
请求 = []
页面.on("request", lambda r: 请求.append(r.url))
响应 = []
页面.on("response", lambda r: 响应.append((r.status, r.url)))
页面.goto(本地服务 + "/slowapi/2001?delay=600")
实测:
GET …/slowapi/2001?delay=600
GET …/api/sloworder/2001?delay=600
200 …/api/sloworder/2001?delay=600
请求发出去了,拿到的是 200。慢,但不是错。
⚠️ 监听器必须在 goto 之前注册。 写在后面一个请求都收不到——页面里的请求早就发完了。
第二路:页面
截图 = 页面.screenshot()
占位 = 页面.locator('[data-testid="placeholder"]').count()
错误 = 页面.locator('[data-testid="load-error"]').count()
慢接口场景:占位 1、错误 0 —— 卡在「加载中」。接口报错场景:占位 0、错误 1 —— 页面自己显示了失败。
这一路能分出「还没加载完」和「加载出错了」。
第三路:业务状态
接口数据 = requests.get(本地服务 + "/api/orders/2001").json()
assert 接口数据["status"] == "PAID"
assert 接口数据["amount"] == "199.80"
业务数据完全正常。所以问题不在数据,在速度或错误,不在测试脚本。
三路走完才能下结论:「这是服务端慢」,而不是「用例写得不好」。
六、等对元素,才能报对原因
同一个失败,两种等法:
# 等法一:只等金额
页面.locator('[data-testid="order-amount"]').inner_text(timeout=2000)
# Timeout 2000ms exceeded
# 等法二:等「金额」或「加载失败」其中一个
页面.locator('[data-testid="loaded-card"], [data-testid="load-error"]').first.wait_for(
state="visible", timeout=2000)
页面.locator('[data-testid="load-error"]').inner_text()
# 加载失败:http 500
等法一告诉你「金额没出现」,等法二告诉你「接口返回了 500」。
一个更阴险的坑
等法一的报错全文是:
Locator.inner_text: Timeout 5000ms exceeded.
Call log:
– waiting for locator("[data-testid=\\"order-amount\\"]")
这里的 500 是 5000ms 的一部分,不是 HTTP 状态码。 拿它判断「接口是不是返回了 500」会得出相反的结论。
配套脚本 05_ai_fix.py 里专门为这一点写了一条断言。
七、AI 修不稳定用例的三种假修复
| 超时从 200 毫秒放大到 5 秒 | 接口 100 毫秒就返回 500,等再久也不会变好 | 白等 5.00 秒,报错里没有根因 |
| try/except 包住,失败也当通过 | CI 绿了,问题还在 | 用例「通过」,但接口明明 500 |
| 断言从「等于 159.80」弱化成「元素存在」 | 断言没了 | 页面显示 88.88 也照样通过 |
第三种我实测过:让页面显示一个错的金额 88.88,弱断言照样通过,强断言立刻抓到。
审查规则
判断 AI 给的是不是假修复,看三个问题:
第三条最容易查也最有效。配套脚本 07_answer.py 的练习四把前两条写成了可执行的审查函数。
八、顺带对比一下两天的等待模型
Day35 用 Selenium,今天用 Playwright。两套模型差别很大,放一起看更清楚。
| 等元素出现 | WebDriverWait(driver, t).until(EC.presence_of_element_located(…)) | locator.inner_text() 自动等 |
| 等元素可点 | 要额外用 EC.element_to_be_clickable | 默认就等到可操作 |
| 等文字出现 | 自己写谓词(text_to_be_present_in_element 在 4.x 里是子串匹配) | expect(…).to_have_text(…) 可重试 |
| 等状态变化 | 自己轮询 | expect(…).to_have_attribute(…) |
| 超时控制 | 每次调用传 timeout= | 每次调用传 timeout=,也可全局 expect.configure |
Selenium 的等待是「你写出来的」,Playwright 的等待是「默认就在的」。
这不是谁更好的问题,是设计取向不同:Selenium 更接近 W3C 原始规范(等待是显式的一步),Playwright 把等待做进了每个操作里。前者啰嗦但可解释,后者简洁但要清楚它的边界——比如 page.query_selector 就没有等待。
Day35 那个 wait_for_text 自己写谓词的坑(因为 Selenium 4.x 改了语义),在 Playwright 这边不存在:expect 的重试是内置的。
九、让失败的用例自己带证据
三路诊断是人手工做的。可以把它做成用例的一部分——失败时自动把证据附在报错信息里:
def test_不稳定的用例要能复现也要能定位(页面, 本地服务):
# 监听器要在 goto 之前注册
请求 = []
页面.on("request", lambda r: 请求.append(r.url))
页面.goto(本地服务 + "/slowapi/2001?delay=100&fail=1")
try:
页面.locator('[data-testid="loaded-card"], [data-testid="load-error"]').first.wait_for(
state="visible", timeout=5000)
页面错误 = 页面.locator('[data-testid="load-error"]').inner_text()
相关请求 = [u for u in 请求 if "/api/sloworder/" in u]
raise AssertionError(
f"页面加载失败|页面提示:{页面错误}|相关请求:{相关请求}")
except AssertionError as e:
信息 = str(e)
assert "500" in 信息
assert "/api/sloworder/" in 信息, "失败信息里应该带上相关请求地址"
失败信息长这样:
页面加载失败|页面提示:加载失败:http 500|相关请求:['http://127.0.0.1:xxxx/api/sloworder/2001?delay=100&fail=1']
看到这一行,你不用再打开浏览器、也不用重新跑一遍,就知道是哪个接口、报的是什么。CI 的日志就是现场。
这个模式值得抽象成一个工具函数:把「收集证据」和「做断言」分开,断言失败时统一附带证据。配套脚本 06_fixed.py 里给了一个雏形。
还有一点值得单独说:证据要收集在 goto 之前就开始。 页面里的请求是打开页面那一刻就发出的,监听器注册晚了就什么都收不到——我第一版就踩了这个坑,查了半天才发现不是代码逻辑问题,是自己把证据丢了。
证据要收集哪些
按今天的经验,三样最有用:
| 请求列表 + 状态码 | 是根本没发出去,还是发出去被拒了 |
| 页面自己的错误提示 | 服务端已经报了什么(比客户端报错准) |
| 截图 | 肉眼能确认当时长什么样 |
第三样在 Day37 会展开讲——它不只是给人看的,还是视觉测试的输入。
十、四道练习
答案在 07_answer.py,4 passed。
练习一:等对元素才能报对原因。 把只等金额改成等「金额或错误」,断言失败信息里出现 http 500,且耗时 < 2 秒。
练习二:超时按实测定。 跑三次取最大值,留 3 倍余量,并断言超时不超过 30 秒(超时太大会掩盖真问题)。
练习三:失败信息要能定位。 断言消息里同时带上:期望什么、实际什么、请求发到哪去了。
练习四:识别假修复。 写一个 审查(源码) 函数,返回问题列表,至少能查出「断言被弱化」和「超时无依据放大」。
十一、今天没做的
- 没做 Playwright 的 trace(page.tracing)录制,只用了 page.on("request") 和 page.screenshot()
- 没做 Playwright 自己的 expect 的软重试与硬超时区分(expect.configure)
- 没做重试机制(–retries)和 CI 集成
- 没用 Playwright 的 page.route 做 Mock(那是 Day29 的 Mock 专题)
- Playwright 的 headless shell 没装完,用的是完整 Chromium;playwright install chromium 跑通需要等那个 191MB 的包下完
下一步是 Day37《Web 视觉测试 + AI 截图比对》,讲怎么用截图判断「长得对不对」。
网硕互联帮助中心



评论前必须登录!
注册