Device UI 测试性能优化记录 — 7.3x 加速
问题
tests/chem_45/ui/test_device.py 的两个测试用例(admin + test 两个账号)执行极慢:
| 配置 | 优化前耗时 |
|---|---|
| 顺序执行 | 140s(2min20s) |
| xdist 2 workers(默认) | 80s(1min20s) |
诊断
通过逐步计时分析,定位到以下瓶颈:
get_table_row_count() — 每次调用 ~5.5s
位置:src/speedtest/chem_45/ui/pages/device_page.py:62
使用 Python 侧循环遍历每个表格的每个单元格,对每个 <td> 调用 inner_text()。每次 inner_text() 都是一次 CDP 往返,页面有 18 个表格、82 行,导致每次调用耗时 5+ 秒。
# 优化前:Python 循环 + 逐单元格 CDP 调用def get_table_row_count(self) -> int: tables = self._page.query_selector_all("table") for table in tables: rows = tbody.query_selector_all("tr") for row in rows: cells = row.query_selector_all("td") for cell in cells: cell.inner_text() # 每次调用都是一次 CDP 往返!Playwright 的 inner_text() 每次调用都是一次 Chrome DevTools Protocol 往返。在嵌套循环中逐单元格调用,累积的往返延迟是性能瓶颈的核心。
has_no_data_message() — 每次调用 ~5s
位置:src/speedtest/chem_45/ui/pages/device_page.py:86
使用 page.locator("body").inner_text() 读取整页文本,同样涉及昂贵的 CDP 往返。
wait_for_table_data() 的冗余设计
位置:src/speedtest/chem_45/ui/pages/device_page.py:100
has_data = device_page.wait_for_table_data(timeout=5000) # 内部调用 get_table_row_countrow_count = device_page.get_table_row_count() # 冗余调用has_no_data_msg = device_page.has_no_data_message() # 冗余调用每个按钮触发 3 次慢速 DOM 查询,6 个按钮 × 2 个账号 = 36 次慢速调用。
竞态条件的偶然掩盖
点击按钮后,页面会短暂显示”没有找到匹配的记录”,然后 API 返回数据,表格更新。
优化前的 has_no_data_message() 因为读取 body.inner_text() 耗时 5 秒,在这 5 秒内数据已经加载完毕,因此巧合地返回了正确结果。优化后的快速定位器(毫秒级)反而过早返回 True,导致误判。
慢速实现有时恰好”等待”了足够长的时间,让异步操作完成。优化后反而暴露了真正的时序依赖,需要重新设计等待策略。
优化方案
get_table_row_count() — 浏览器内 JS 计算
将行过滤逻辑移到浏览器内执行,一次 CDP 调用完成:
_DATA_ROW_CHECK = """Array.from(document.querySelectorAll('table tbody tr')).filter(row => { const text = row.textContent || ''; if (/没有.*匹配|没有.*记录|暂无.*数据/.test(text)) return false; return Array.from(row.querySelectorAll('td')).some(cell => cell.textContent.trim());}).length"""
def get_table_row_count(self) -> int: return self._page.evaluate(_DATA_ROW_CHECK) # 一次 CDP 调用has_no_data_message() — 文本定位器
使用 Playwright 内置的文本定位器,避免全页文本读取:
def has_no_data_message(self) -> bool: return self._page.locator("text=没有找到匹配的记录").count() > 0wait_for_table_data() — 统一轮询 + 后验判断
不再前置检查 has_no_data_message(),而是用 wait_for_function 同时等待两个条件(数据到达 或 无数据提示),触发后再判断是哪个条件:
def wait_for_table_data(self, timeout: int = 5000) -> bool: try: self._page.wait_for_function( f"{_DATA_ROW_CHECK} > 0 || " "document.body.innerText.includes('没有找到匹配的记录')", timeout=timeout ) return self._page.evaluate(f"{_DATA_ROW_CHECK} > 0") except Exception: return self._page.evaluate(f"{_DATA_ROW_CHECK} > 0")测试层去冗余
wait_for_table_data 本身已返回是否找到数据,测试不再额外调用 get_table_row_count() 和 has_no_data_message():
with step(f"验证{btn_name}表格数据"): has_data = device_page.wait_for_table_data(timeout=5000) if allow_empty: if not has_data: step(f"{btn_name}表格数据为空(允许)") else: assert has_data, f"{btn_name}表格数据为空"结果
涉及文件
| 文件 | 修改内容 |
|---|---|
src/speedtest/chem_45/ui/pages/device_page.py | 重写 get_table_row_count、has_no_data_message、wait_for_table_data |
tests/chem_45/ui/test_device.py | 移除验证阶段冗余调用 |
速度提升
| 配置 | 优化前 | 优化后 | 加速比 |
|---|---|---|---|
| 顺序执行 | 140s | 14–19s | ~7.3x |
| xdist 2 workers | 80s | 11s | ~7.3x |
正确性
- 优化前后所有通过 case(7/10)仍通过
- 优化前后所有失败 case(3/10 login 空字段校验)仍失败,为预先存在的服务端行为差异
- 竞态条件通过
wait_for_function统一轮询解决
关键教训
- Playwright CDP 往返很昂贵 — 避免在 Python 循环中逐单元格调用
inner_text();使用page.evaluate()在浏览器内批量处理 body.inner_text()在复杂页面上极慢 — 优先使用文本定位器text=...或locator.count()做存在性检查- 慢速代码可能掩盖竞态条件 — 优化后要重新审视异步操作的时序依赖
page.evaluate("() => ...")返回函数对象而非执行结果 — 使用纯表达式而非箭头函数
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!













