ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

第 4 篇|建立证据链:日志、截图与 Allure

第 4 篇|建立证据链:日志、截图与 Allure

系列:从 0 到 1 搭建桌面应用 UI 自动化测试框架
上一篇:第 3 篇|跑通业务主链路
下一篇:第 5 篇|治理可重复性:弹窗、状态与等待
关键词:证据链、loguru、capture_step、pytest_runtest_makereport、Allure

本篇导读

UI 自动化的调试成本极高。如果失败时还要人工复现,框架就很难规模化。

第四步的目标是:

任何一个失败,都能从报告直接看到:失败在哪个步骤、当时界面长什么样、日志里发生了什么。

证据链由四部分组成:日志、主动截图、失败自动截图、Allure 报告。

一句话:allure-pytest 2.x 必须用 attach(bytes) 而非 attach(file=),复制策略和 UUID 不一致会让附件在报告里消失。

数据流图:一次用例的证据如何汇合

失败时

用例执行

loguru 双 sink

capture_step 主动截图

allure.step 记录步骤

pytest_runtest_makereport

logs/YYYYMMDD/pytest_HHMMSS.log

screenshots/*.png + Allure 附件

allure-results

失败定位

allure generate

reports/YYYYMMDD/allure_HHMMSS

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()做三件事:

  1. 记录当前步骤名到current_step;
  2. 截图并加上底部标注;
  3. 以 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...

两个容易忽略的细节:

  1. 必须检查isinstance(current_step, str):pywinauto 的WindowSpecification有__getattr__,hasattr永远为 True;
  2. 截 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()

关键结论

  1. 证据链和用例同时设计:不要等失败后再补日志。loguru 双 sink + loguru 自己写文件,8 行配置同时解决 pytest 捕获和提权后 I/O 边界两个问题。
  2. 截图要截「操作现场」,不是「最顶层窗口」:失败时顶层可能是加载框/弹窗,会截到极小图;必须截 Page Object 的window(即业务操作正在进行的那个窗口)。
  3. allure.attach 用 bytes,不要用 file 路径:allure-pytest 2.x + allure2 generate 的文件复制策略与 UUID 生成不一致,inline bytes 才能保证附件稳定可追溯。
  4. 提权会改变 I/O 边界:run.py 的_Tee虽然只做了两件事(write 同时发给 console + fp、每次 flush),但它让父进程(Trae / IDE)能实时看到提权后独立控制台里的进度。

下一篇

证据链建立后,开始治理“第一次能过、第二次就挂”的问题:第 5 篇|弹窗、状态与等待。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进