云计算百科
云计算领域专业知识百科平台

Playwright 自动等待 + AI 失败诊断

/* 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"

区别有两个:

  • 前者会等,后者取一次就判。 页面慢 600 毫秒时,后者失败,前者通过。
  • 失败信息不同。 expect 会带上实际值和期望值:
  • 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。两套模型差别很大,放一起看更清楚。

    Selenium(Day35)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 截图比对》,讲怎么用截图判断「长得对不对」。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Playwright 自动等待 + AI 失败诊断
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!