系列:从 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.py
logger.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。
def capture_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)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
if report.when == "call" and report.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 节选)
def capture_step(self, step: str, prefix: str = "") -> str:
from utils.screenshot import capture_annotated
self.current_step = step
ts = time.strftime("%H%M%S")
fname = f"{prefix}_{step}_{ts}.png" if prefix else f"{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(c for c in step if c not in '\\/:*?"<>|')[:60]
allure.attach(
png_bytes, name=f"截图-{safe}",
extension="png",
attachment_type=allure.attachment_type.PNG,
)
return path
示例 3:失败自动截图钩子(真实 conftest.py 的 pytest_runtest_makereport 节选,两个容易忽略的细节)
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
if report.when == "call" and report.failed:
session = item.funcargs.get("app")
from utils.screenshot import capture_annotated
# 细节 1:必须 isinstance(current_step, str)
# pywinauto 的 WindowSpecification 有 __getattr__,hasattr 永远 True
step = "未知步骤"
shot_window = None
for v in item.funcargs.values():
if hasattr(v, "current_step") \
and isinstance(getattr(v, "current_step", None), str):
step = v.current_step
# 细节 2:优先截 Page Object 的 window,不是 app.top_window()
# 失败时顶层可能是加载框/弹窗,top_window 会截到极小图
try: shot_window = v.window
except Exception: shot_window = None
break
if shot_window is None:
shot_window = session.main_window # fallback
safe_step = "".join(c for c in step if c not in '\\/:*?"<>|')[: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 = fp
def write(self, s):
self.console.write(s); self.console.flush()
self.fp.write(s); self.fp.flush()
def flush(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),但它让父进程(IDE)能实时看到提权后独立控制台里的进度。
下一篇
证据链建立后,开始治理“第一次能过、第二次就挂”的问题:第 5 篇|弹窗、状态与等待。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2303_77184285/article/details/169949723



