我们有个 SaaS 后台的订单列表页,UI 用例简单到不像会出事:进去,选「已支付」筛选条件,点查询,断言表格是 12 行【推断】。本地连跑 100 次全绿【推断】,挂到 CI 每晚跑一轮,稳定红 3—5 次【推断】,白天手动重跑又全过。
这种「重跑就过」的红灯最难处理:它不指向任何具体的产品 bug,也不指向任何具体的选择器。翻 CI 录像看,断言那一刻骨架屏还在闪——表格里已经有 12 个 【推断】,每格金额却还是「--」。行数对,数据没回来,用例等于对一个半成品页面做了判决。
后来我们把这类问题归到一个名字底下:就绪判定(readiness)。它既不是选择器脆弱,也不是视觉对不齐,而是另一件被长期低估的事——你到底在页面的哪个时刻开始断言。auto-waiting 太好用,好用到让人以为框架已经替你等好了。
一、auto-waiting 的能力边界:它保证可交互,不保证数据是最终的
Playwright 官方口径是:执行点击、填写前,框架会对目标元素跑一遍 actionability 检查——已附着在 DOM 上、可见、稳定(不在动画位移中)、能接收事件、未被 disabled,全部通过动作才发出。
它回答的是「我能不能对这个元素动手」,不回答「页面上的数据是不是最终值」。异步渲染的前端里这两件事是分离的:框架先渲染骨架屏或空表格,接口回来后再回填单元格。骨架屏里那个 只要可见、稳定,对 auto-waiting 就是「可交互」的,它没义务知道那个「--」是占位符还是真实空值。
所以你断言行数时,等到的是「表格结构就位」,不是「订单数据就位」。中间那段异步窗口通常只有几百毫秒【推断】,但 CI 负载高的夜里会拉长到刚好卡住断言——这就是每晚红 3—5 次的原因【推断】。
二、networkidle 的失效场景:在现代后台页面上它基本不触发
很多人的第一反应是 page.wait_for_load_state("networkidle")。官方语义是:一段时间内没有网络连接活动(默认约 500ms 内并发请求数为零)才算空闲。
问题是现代后台页面几乎不存在这个时刻。订单列表页上通常同时挂着推送通知的 WebSocket、每 30 秒【推断】轮询任务状态的定时器、埋点心跳、前端资源预取。只要任意一个在持续发包,网络就永远不会安静。
于是你得到最坏的一种失败:networkidle 不报错,只是一直等,等到超时耗尽抛一个 TimeoutError。你拿到的是「超时」,不是「订单接口没回来」,CI 里只多出一批毫无规律的超时记录。
所以在长连接和轮询页面上,正确做法不是等整个网络安静,而是等你关心的那一个接口:把目标从「页面」收窄到「请求」,可诊断性立刻回来。
三、sleep 的真实代价:把不确定性平摊成耗时
page.wait_for_timeout(3000) 看着像解药,其实是把问题换了个形态:你不知道最慢那次要多久,就赌一个足够大的值,让每条用例、每天、每个分支都替它付钱。
代价有三层。一是时间:套件里 200 条用例【推断】,每条摊 2 秒【推断】,每天就多烧六七分钟【推断】机器时间,纯等待,什么也没验证。二是它没换来确定性:3 秒够不够取决于当天 CI 负载、数据库冷不冷、服务有没有在扩缩容,本地够用、CI 上不够用正是这次的现象。三是最容易被忽略的——sleep 失败时不产生任何诊断信息,你只知道断言挂了,不知道页面当时停在哪一步。
它只能当兜底:探针全过之后再加几十毫秒【推断】静默期,吸收最后一帧动画。拿它当主力等待,等于用耗时买安心,而这份安心在 CI 上并不成立。
四、四类就绪探针:把「什么时候可以断言」写成可读条件
就绪探针的本质,是把模糊的时间问题翻译成一组可判定的业务条件。四类各管一段,缺一段就留一个漏检口子。
DOM 状态探针管「加载态结束了」:等骨架屏、spinner、遮罩从 DOM 里消失。最便宜,前端通常已给了 data-testid。
数据到位探针管「数据回填了」:断言关键字段渲染出真实内容,而非仅元素存在——金额那格不能是「--」、空串或「加载中」。四类里语义最强,也是这次真正救我们的一类。
请求完成探针管「后端给了」:等指定接口返回并校验状态码。要在触发操作前先装监听、操作后查记录,且取最后一次匹配,否则会把首次进页面的响应当成新数据。
渲染稳定探针管「前端画完了」:隔一小段时间取两帧快照,文本与包围盒一致才放行。对虚拟滚动、渐次渲染很有用;页面有常驻动画时会一直不通过,取样要收窄到表格容器。
四类的判定对象与漏洞,重点看下表最后一列:
| 探针类型 | 判定的是什么 | 最适用的场景 | 单独使用的漏洞 |
|---|---|---|---|
| DOM 状态探针 | 骨架屏/加载态节点已消失 | 有明确 loading 组件与 testid | 加载态撤了但数据未渲染,放行一张空表格 |
| 数据到位探针 | 关键字段是真实值而非占位符 | 金额、状态、单号等强语义字段 | 只看首节点,可能局部回填而整页未齐 |
| 请求完成探针 | 指定接口已返回且状态码正确 | 明确知道数据来自哪个接口 | 接口 200 但前端渲染失败,仍会误判 |
| 渲染稳定探针 | 两帧之间文本与包围盒不变 | 动画、虚拟滚动、渐次渲染 | 有常驻动画时永不通过,取样须收窄 |
四类互为补位:骨架屏消失是加载态结束,接口 200 是后端给了,字段回填是前端画上了,两帧一致是它不再抖。四个条件同时成立,你才有资格开始断言业务。
五、可运行代码:readiness 模块、fixture、页面对象
下面三段可以直接放进仓库:readiness.py 是帮助模块,conftest.py 是 fixture,最后一段是页面对象加一条用例。跑法:pip install pytest playwright pytest-playwright && playwright install chromium,再 pytest
tests/test_order_filter.py -q。
# readiness.py —— UI 自动化「就绪判定」帮助模块
from __future__ import annotationsimport osimport timefrom dataclasses import dataclass, fieldfrom typing import Callable, List, Optional, Tuplefrom playwright.sync_api import Page, Response@dataclassclass ProbeResult: ok: bool name: str reason: str detail: dict = field(default_factory=dict)Probe = Callable[[Page], ProbeResult]class ReadinessTimeout(AssertionError):"""就绪超时:带上探针名、最后一次失败原因与现场证据"""def __init__(self, scene, last, attempts, elapsed_ms, evidence): self.scene = scene self.last = last self.attempts = attempts self.elapsed_ms = elapsed_ms self.evidence = evidence super().__init__(f"[readiness] 场景「{scene}」在 {elapsed_ms}ms 内未就绪:"f"{last.name} -> {last.reason} | 轮询 {attempts} 次 | 现场 {evidence}" )def dom_gone(selector: str, name: str) -> Probe:"""DOM 状态探针:骨架屏 / 加载态节点消失"""def _probe(page: Page) -> ProbeResult: count = page.locator(selector).count()if count == 0:return ProbeResult(True, name, "节点已消失")return ProbeResult(False, name, f"页面上仍有 {count} 个节点", {"selector": selector})return _probeDEFAULT_PLACEHOLDERS = ("", "-", "--", "—", "…", "加载中", "Loading", "N/A")def data_filled(selector: str, name: str,
placeholders: Tuple[str, ...] = DEFAULT_PLACEHOLDERS) -> Probe:"""数据到位探针:关键业务字段渲染出真实内容,而不是占位符"""def _probe(page: Page) -> ProbeResult: loc = page.locator(selector).firstif loc.count() == 0:return ProbeResult(False, name, "目标节点不存在", {"selector": selector}) text = (loc.inner_text() or"").strip()if text in placeholders:return ProbeResult(False, name, f"仍是占位符「{text}」", {"selector": selector})return ProbeResult(True, name, "已渲染真实内容", {"text": text[:24]})return _probeclass ResponseRecorder:"""响应记录器:必须在触发请求的动作之前装配,否则会漏记"""def __init__(self, page: Page): self.page = page self.seen: List[Response] = [] page.on("response", self._on_response)def _on_response(self, response: Response) -> None: self.seen.append(response)def find(self, url_part: str) -> Optional[Response]:# 倒序取最后一次匹配,避免把首次进页面的旧响应当成本次筛选结果for response in reversed(self.seen):if url_part in response.url:return responsereturnNonedef recent_urls(self, limit: int = 10) -> List[str]:return [r.url for r in self.seen[-limit:]]def request_done(recorder: ResponseRecorder, url_part: str, name: str,
expect_status: int = 200) -> Probe:"""请求完成探针:等待特定接口返回并校验状态码"""def _probe(page: Page) -> ProbeResult: response = recorder.find(url_part)if response isNone:return ProbeResult(False, name, "接口还没有响应", {"expect_url_contains": url_part,"recent": recorder.recent_urls(5)})if response.status != expect_status:return ProbeResult(False, name, f"状态码是 {response.status}", {"url": response.url})return ProbeResult(True, name, f"已返回 {response.status}", {"url": response.url})return _probedef render_stable(selector: str, name: str, interval_ms: int = 200) -> Probe:"""动画/渲染稳定探针:间隔两帧,节点数、文本与包围盒都不再变化"""def _snapshot(page: Page): loc = page.locator(selector) count = loc.count() text = (loc.first.inner_text() or"").strip() if count else"" box = loc.first.bounding_box() if count elseNonereturn count, text, boxdef _probe(page: Page) -> ProbeResult: first = _snapshot(page) page.wait_for_timeout(interval_ms) second = _snapshot(page)if first[0] > 0and first == second:return ProbeResult(True, name, f"两帧一致,共 {first[0]} 个节点")return ProbeResult(False, name, "两帧之间仍在变化或节点为空", {"frame_1": str(first)[:80],"frame_2": str(second)[:80]})return _probedef all_of(*probes: Probe) -> Probe:"""组合探针:全部通过才算就绪,失败时返回第一个未通过的探针"""def _probe(page: Page) -> ProbeResult:for probe in probes: result = probe(page)ifnot result.ok:return resultreturn ProbeResult(True, "all_of", "全部探针通过")return _probedef _dump_evidence(page: Page, last: ProbeResult, artifact_dir: str) -> dict: os.makedirs(artifact_dir, exist_ok=True) stem = f"{int(time.time())}_{last.name}" shot = os.path.join(artifact_dir, f"{stem}.png") dom = os.path.join(artifact_dir, f"{stem}.html")try: page.screenshot(path=shot)with open(dom, "w", encoding="utf-8") as fh: fh.write(page.content())return {"url": page.url, "title": page.title(),"screenshot": shot, "dom": dom, "detail": last.detail}except Exception as exc: # 取证失败不能盖住原始超时原因return {"url": page.url, "evidence_error": str(exc),"detail": last.detail}def wait_until_ready(page: Page, probe: Probe, *, scene: str = "未命名场景",
timeout_ms: int = 10_000, poll_ms: int = 150,
artifact_dir: str = "artifacts") -> None:"""轮询探针直到就绪;超时抛 ReadinessTimeout,并落一份失败现场""" start = time.monotonic() attempts = 0 last = ProbeResult(False, "init", "尚未开始轮询")whileTrue: attempts += 1 last = probe(page)if last.ok:return elapsed_ms = int((time.monotonic() - start) * 1000)if elapsed_ms >= timeout_ms: evidence = _dump_evidence(page, last, artifact_dir)raise ReadinessTimeout(scene, last, attempts, elapsed_ms, evidence) page.wait_for_timeout(poll_ms)
# conftest.py —— pytest-playwright 的就绪 fixture
import pytestfrom playwright.sync_api import Pagefrom readiness import (ResponseRecorder, all_of, data_filled, dom_gone, render_stable, request_done, wait_until_ready)@pytest.fixturedef recorder(page: Page) -> ResponseRecorder:"""监听必须在任何触发请求的动作之前装好,所以单独做成 fixture"""return ResponseRecorder(page)@pytest.fixturedef ready(page: Page, recorder: ResponseRecorder):"""把 wait_until_ready 收敛成用例里一行可调用的 ready(...)"""def _ready(probe, *, scene: str, timeout_ms: int = 10_000) -> Page: wait_until_ready(page, probe, scene=scene, timeout_ms=timeout_ms)return page _ready.dom_gone = dom_gone _ready.data_filled = data_filled _ready.render_stable = render_stable _ready.all_of = all_of _ready.request_done = lambda url_part, name, expect_status=200: request_done( recorder, url_part, name, expect_status)return _ready
# pages/order_list.py + tests/test_order_filter.py
import pytestfrom playwright.sync_api import Pagefrom readiness import (ResponseRecorder, all_of, data_filled, dom_gone, render_stable, request_done, wait_until_ready)class OrderListPage:"""订单列表页对象:就绪探针挂在页面对象上,用例只管断言业务""" PATH = "/admin/orders" API = "/api/v1/orders" SKELETON = "[data-testid='table-skeleton']" SPINNER = "[data-testid='order-table'] .ant-spin-spinning" ROW = "[data-testid='order-table'] tbody tr" FIRST_AMOUNT = "[data-testid='order-table'] tbody tr:first-child td[data-col='amount']" TOTAL = "[data-testid='order-total']"def __init__(self, page: Page, recorder: ResponseRecorder): self.page = page self.recorder = recorderdef filter_by_status(self, status: str) -> "OrderListPage": self.page.goto(self.PATH, wait_until="domcontentloaded") self.page.select_option("[data-testid='status-filter']", status) self.page.click("button:has-text('查询')") self.wait_ready(scene="订单列表按状态筛选")return selfdef wait_ready(self, scene: str = "订单列表", timeout_ms: int = 10_000) -> None: probe = all_of( dom_gone(self.SKELETON, "骨架屏"), dom_gone(self.SPINNER, "表格加载态"), request_done(self.recorder, self.API, "订单列表接口"), data_filled(self.FIRST_AMOUNT, "首行订单金额"), data_filled(self.TOTAL, "合计金额"), render_stable(self.ROW, "表格行数"), ) wait_until_ready(self.page, probe, scene=scene, timeout_ms=timeout_ms)def row_count(self) -> int:return self.page.locator(self.ROW).count()@pytest.fixturedef order_list(page: Page, recorder: ResponseRecorder) -> OrderListPage:return OrderListPage(page, recorder)def test_paid_orders_rendered_before_assert(order_list: OrderListPage): order_list.filter_by_status("paid")# 走到这一行,数据已回填完成,断言才有意义assert order_list.row_count() == 12# 本文示例设定值【推断】 amount = order_list.page.locator(OrderListPage.FIRST_AMOUNT).inner_text()assert amount.strip() notin {"", "-", "--"}
真正决定成败的是两个细节:recorder 必须在触发筛选前装配,page.on("response") 事后补不回来;find 倒序取最后一次匹配,否则首次进页面那次的响应会被当成本次筛选结果,探针提前放行,你又回到老问题。把探针挂在页面对象上,是为了让「这个页面什么时候算就绪」只有一处定义:接口路径或字段变了,只改 wait_ready。
六、四种等待策略的正面对照
同样是「等一下」,四种策略在五个维度上差别很大。下表是改造前后的直接对照,耗时列都是本文场景下的示例设定值【推断】,量级供参考,不是普适基准。
| 等待策略 | 稳定性 | 单条耗时 | 漏检风险 | 失败可诊断性 | 维护成本 |
|---|---|---|---|---|---|
| 固定 sleep(3s) | 差,赌一个最慢值,负载一高就穿底 | 恒定增加 3000ms【推断】,纯等待 | 高,慢于阈值必然漏检 | 无,只报断言失败,不知页面停在哪 | 看似低,实则随用例数线性扩散 |
| waitForSelector(行) | 中,元素存在不等于数据回填 | 约 120—400ms【推断】 | 中高,空行与占位符同样算「存在」 | 中,能知道缺哪个选择器 | 中,选择器一改就要跟 |
| waitForLoadState('networkidle') | 差,长连接与轮询页面上不触发 | 不可控,常见结局是直接超时 | 高,一路静默等到超时 | 低,只报 TimeoutError,不说谁在发包 | 低,但换不来任何保障 |
| 语义就绪探针(本文方案) | 好,判定条件就是业务条件 | 约 80—260ms【推断】,就绪即放行 | 低,数据未到位就不放行 | 好,探针名+原因+截图+已收响应清单 | 中,接口路径或字段变更需同步探针 |
看第三、第四列就够了:sleep 与 networkidle 既不稳定,失败时又什么都不告诉你。语义探针耗时不比 waitForSelector 高多少,因为它是「就绪即放行」而非「等满固定值」;多出来的维护成本,换来漏检风险和排障时间同时下降。
七、失败可诊断:让红灯自己说清楚它卡在哪一步
就绪判定的价值一半在稳定,一半在失败时的信息量。以前红灯只有一句 assert 12 == 0【推断】,你得翻录像、翻 trace 逐帧找骨架屏;现在 ReadinessTimeout 直接给出场景名、卡住的探针、失败原因、轮询次数与耗时,外加截图和 DOM 快照路径。
「首行金额仍是占位符」和「订单接口还没有响应」是两种故障:前者多半是前端渲染或字段名变了,后者多半是后端慢或挂了。同一次红灯,一个该找前端,一个该看服务端监控——这种区分能力,sleep 和 networkidle 永远给不了。
再补两个坑:超时值不要一刀切,列表页 10 秒【推断】,导出、批量审批这类重接口给到 30 秒【推断】,超时做成调用处参数而非全局常量;取证要防二次失败,页面崩溃时截图本身会抛异常,_dump_evidence 必须 try 住,别让取证错误盖掉原始的就绪原因。
改造之后这条用例连跑两周没再出现夜间红灯【推断】,单次等待比原来那版 sleep(3) 还短一截【推断】。我们没有改一行断言,只是把「什么时候可以开始断言」从猜变成了写清楚的条件。
自动化的确定性不来自等得更久,而来自把「就绪」定义成可判定的业务条件。
关于我们
本文部分内容参考了霍格沃兹测试开发学社整理的相关技术资料,主要涉及软件测试、自动化测试、测试开发及 AI 测试等内容,侧重测试实践、工具应用与工程经验整理。