hzn头像

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

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

两个容易忽略的细节:

  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 节选)

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()

关键结论

  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),但它让父进程(IDE)能实时看到提权后独立控制台里的进度。

下一篇

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

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2303_77184285/article/details/169949723

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--