系列:从 0 到 1 搭建桌面应用 UI 自动化测试框架
上一篇:第 3 篇|跑通业务主链路
下一篇:第 5 篇|治理可重复性:弹窗、状态与等待
关键词:证据链、loguru、capture_step、pytest_runtest_makereport、Allure
本篇导读
UI 自动化的调试成本极高。如果失败时还要人工复现,框架就很难规模化。
第四步的目标是:
任何一个失败,都能从报告直接看到:失败在哪个步骤、当时界面长什么样、日志里发生了什么。
证据链由四部分组成:日志、主动截图、失败自动截图、Allure 报告。
一句话:allure-pytest 2.x 必须用 attach(bytes) 而非 attach(file=),复制策略和 UUID 不一致会让附件在报告里消失。
数据流图:一次用例的证据如何汇合
1. 日志双 sink
# conftest.pylogger.remove()logger.add(sys.stdout,level="INFO",format=...)logger.add(config.ROOT/"logs"/日期/f"pytest_{时间}.log",level="DEBUG",encoding="utf-8",)两个 sink 分工:
- stdout sink:pytest 捕获,Allure 报告可见;
- 文件 sink:提权后 stdout 不可靠,文件最稳。
日志按日期分目录:logs/YYYYMMDD/。
2. 主动截图:capture_step
BasePage.capture_step()做三件事:
- 记录当前步骤名到
current_step; - 截图并加上底部标注;
- 以 bytes 形式挂到 Allure。
defcapture_step(self,step:str,prefix:str="")->str:self.current_step=step# 省略:构造截图路径、调用 capture_annotated(self.window, step, path) 带底部步骤标注、读回 bytes...allure.attach(png_bytes,name=f"截图-{step}",extension="png")关键点:用allure.attach(bytes)而不是allure.attach(file=...),避免 allure-pytest 复制文件导致result.json里的 source UUID 与实际附件文件名不一致。
3. 失败自动截图钩子
@pytest.hookimpl(hookwrapper=True)defpytest_runtest_makereport(item,call):outcome=yieldreport=outcome.get_result()ifreport.when=="call"andreport.failed:# 从 Page Object 取 current_step# 优先截“失败时正在操作的窗口”# 省略:遍历 funcargs 找带 current_step 属性且是 str 的对象、调用 capture_annotated 截图、bytes attach...两个容易忽略的细节:
- 必须检查
isinstance(current_step, str):pywinauto 的WindowSpecification有__getattr__,hasattr永远为 True; - 截 Page Object 的窗口,不要截顶层窗口:失败时顶层可能是加载框/弹窗,截出来只有一个小图。
4. Allure 报告生成
run.py的三段式:
pytest --alluredir=./allure-results allure generate ./allure-results -o reports/YYYYMMDD/allure_HHMMSS --clean webbrowser.open(index.html)报告按日期分目录,不覆盖历史。run.py还会替换 favicon、侧栏 logo 和品牌文字为 DESKTOPAPP。
5. 证据链的四个检查点
| 检查点 | 问题 | 合格标准 |
|---|---|---|
| 日志 | 失败时能否看到 DEBUG 细节? | 有独立文件,按日期归档 |
| 步骤 | 能否知道失败在哪个业务动作? | 每步有allure.step |
| 截图 | 能否看到失败瞬间的界面? | 主动截图 + 失败自动截图 |
| 报告 | 能否离线打开、历史可追溯? | allure generate+ 日期目录 |
6. 真实踩坑
| 现象 | 根因 | 修正 |
|---|---|---|
| Allure 报告 500 failed to fetch | 浏览器禁止file://fetch | 用allure generate生成静态目录 |
| 失败截图只有底部标注条 | 截了app.top_window(),当时顶层是加载框 | 截 Page Object 的window |
| 报告里附件找不到 | attach 用绝对路径文件,复制策略不一致 | 改用 bytes attach |
| 提权后看不到输出 | stdout 在独立控制台 | Tee 到日志文件 |
7. 验收清单
- 每个关键步骤都有截图;
- 任意用例失败后,报告里有“步骤名 + 截图 + 日志”;
- 日志按日期归档,不会覆盖;
- 报告能离线打开,不依赖 HTTP 服务;
- 截图能看出失败时正在操作的是哪个窗口。
8. 可复用要点
- 证据链和用例同时设计,不要等失败后再补日志。
- 截图要截“操作现场”,不要截“当前最顶层窗口”。
- 报告产物按日期分目录,历史结果可追溯。
- 附件用 bytes,不用路径,避免复制策略差异。
关键代码示例
示例 1:loguru 双 sink(真实conftest.py节选,8 行配置同时满足 pytest 捕获和文件落盘)
logger.remove()# sink 1:stdout → pytest 捕获 → Allure 报告可见logger.add(sink=sys.stdout,level="INFO",format="<g>{time:HH:mm:ss}</g> <lvl>{level:<7}</lvl> {message}",)# sink 2:按日期分目录的 DEBUG 文件 → 提权后 run.py 的 _Tee 不继承到子进程,# loguru 自己写文件最稳,用于排查 dismiss 弹窗、启动流程等细节异常_log_dir=config.ROOT/"logs"/datetime.now().strftime("%Y%m%d")_log_dir.mkdir(parents=True,exist_ok=True)logger.add(sink=_log_dir/f"pytest_{datetime.now():%H%M%S}.log",level="DEBUG",format="{time:HH:mm:ss} | {level:<7} | {message}",encoding="utf-8",)示例 2:主动截图标注 + bytes attach(真实pages/base_page.py的capture_step节选)
defcapture_step(self,step:str,prefix:str="")->str:fromutils.screenshotimportcapture_annotated self.current_step=step ts=time.strftime("%H%M%S")fname=f"{prefix}_{step}_{ts}.png"ifprefixelsef"{step}_{ts}.png"path=str(config.SCREENSHOTS/fname)# 带底部步骤标注的截图capture_annotated(self.window,step,path)# 关键:allure.attach(bytes) 而不是 attach(file=...)# 原因:allure-pytest 2.x + allure2 generate 下,attach 文件时会把文件复制到# data/attachments/ 并在 result.json 里记录 source UUID;绝对路径 file 的复制策略# 与 UUID 生成不一致,最终报告里附件找不到,必须 inline bytes 才能保证 source 一致png_bytes=Path(path).read_bytes()safe="".join(cforcinstepifcnotin'\\/:*?"<>|')[:60]allure.attach(png_bytes,name=f"截图-{safe}",extension="png",attachment_type=allure.attachment_type.PNG,)returnpath示例 3:失败自动截图钩子(真实conftest.py的pytest_runtest_makereport节选,两个容易忽略的细节)
@pytest.hookimpl(hookwrapper=True)defpytest_runtest_makereport(item,call):outcome=yieldreport=outcome.get_result()ifreport.when=="call"andreport.failed:session=item.funcargs.get("app")fromutils.screenshotimportcapture_annotated# 细节 1:必须 isinstance(current_step, str)# pywinauto 的 WindowSpecification 有 __getattr__,hasattr 永远 Truestep="未知步骤"shot_window=Noneforvinitem.funcargs.values():ifhasattr(v,"current_step")\andisinstance(getattr(v,"current_step",None),str):step=v.current_step# 细节 2:优先截 Page Object 的 window,不是 app.top_window()# 失败时顶层可能是加载框/弹窗,top_window 会截到极小图try:shot_window=v.windowexceptException:shot_window=Nonebreakifshot_windowisNone:shot_window=session.main_window# fallbacksafe_step="".join(cforcinstepifcnotin'\\/:*?"<>|')[:30]shot=config.SCREENSHOTS/f"{item.name}__{safe_step}_FAILED.png"capture_annotated(shot_window,step,str(shot))# 同样用 bytes attach,避免文件复制策略不一致allure.attach(shot.read_bytes(),name=f"失败截图-{safe_step}",extension="png",attachment_type=allure.attachment_type.PNG)示例 4:提权后日志可见(真实run.py的_Tee类,3 行起作用)
class_Tee:"""stdout 替身:同时写控制台和日志文件,每次 write 立即 flush。 提权后 run.py 跑在独立控制台,父进程拿不到 stdout; 外部轮询日志即可实时看进度。"""def__init__(self,console,fp):self.console=console;self.fp=fpdefwrite(self,s):self.console.write(s);self.console.flush()self.fp.write(s);self.fp.flush()defflush(self):self.console.flush();self.fp.flush()关键结论
- 证据链和用例同时设计:不要等失败后再补日志。loguru 双 sink + loguru 自己写文件,8 行配置同时解决 pytest 捕获和提权后 I/O 边界两个问题。
- 截图要截「操作现场」,不是「最顶层窗口」:失败时顶层可能是加载框/弹窗,会截到极小图;必须截 Page Object 的
window(即业务操作正在进行的那个窗口)。 - allure.attach 用 bytes,不要用 file 路径:allure-pytest 2.x + allure2 generate 的文件复制策略与 UUID 生成不一致,inline bytes 才能保证附件稳定可追溯。
- 提权会改变 I/O 边界:run.py 的
_Tee虽然只做了两件事(write 同时发给 console + fp、每次 flush),但它让父进程(Trae / IDE)能实时看到提权后独立控制台里的进度。
下一篇
证据链建立后,开始治理“第一次能过、第二次就挂”的问题:第 5 篇|弹窗、状态与等待。