Device UI 测试性能优化记录 — 7.3x 加速

982 字
5 分钟
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 往返!
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_count
row_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() > 0

wait_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_counthas_no_data_messagewait_for_table_data
tests/chem_45/ui/test_device.py移除验证阶段冗余调用

速度提升#

配置优化前优化后加速比
顺序执行140s14–19s~7.3x
xdist 2 workers80s11s~7.3x

正确性#

  • 优化前后所有通过 case(7/10)仍通过
  • 优化前后所有失败 case(3/10 login 空字段校验)仍失败,为预先存在的服务端行为差异
  • 竞态条件通过 wait_for_function 统一轮询解决

关键教训#

  1. Playwright CDP 往返很昂贵 — 避免在 Python 循环中逐单元格调用 inner_text();使用 page.evaluate() 在浏览器内批量处理
  2. body.inner_text() 在复杂页面上极慢 — 优先使用文本定位器 text=...locator.count() 做存在性检查
  3. 慢速代码可能掩盖竞态条件 — 优化后要重新审视异步操作的时序依赖
  4. page.evaluate("() => ...") 返回函数对象而非执行结果 — 使用纯表达式而非箭头函数

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
Device UI 测试性能优化记录 — 7.3x 加速
https://wwkcim.xyz/posts/device-ui-performance-optimization/
作者
wwk
发布于
2026-07-24
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
wwk
Hello, I'm wwk.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:000:00
暂无歌词
分类
标签
站点统计
文章
16
分类
5
标签
34
总字数
17,180
运行时长
0
最后活动
0 天前
站点信息
构建平台
Local
博客版本
Firefly v6.13.5
文章许可
CC BY-NC-SA 4.0

文章目录