
有段时间我总在半夜被叫起来查线上问题打开CI翻到昨晚的自动化测试报告发现红了一片——不是业务真的崩了是报告生成完就躺在那里没人看。从那天起我给自己定了个小目标让测试结论主动找人而不是人去找报告。这篇文章就来说说我用Python把这套自动化测试报告推送链路做完整的实战过程核心是把pytest跑出来的结果转成飞书群机器人能识别的消息卡片直接推到工作群里。适合所有做接口自动化、UI自动化的测试开发也适合想优化团队反馈链路但不想上重型测试平台的人。不需要你有多深的Python基础只要会写pytest用例照着思路就能落地。1. 为什么要把测试报告送到群里自动化测试闭环缺失的最后一段1.1 报告躺在CI上等于没人看很多团队的自动化测试是这么跑的代码提交触发流水线pytest跑完生成一份HTML或者Allure报告然后传到某个只有测试自己知道的地方。表面上闭环了实际上反馈链路断了。开发不会主动点开Jenkins看结果业务更不会连测试自己都经常因为手头事多忘记检查。我见过最典型的一次事故某个核心接口因为一次重构参数名变了自动化测试当天晚上就红了但无人感知直到第二天用户反馈页面报错大家才开始排查最后翻报告才发现问题在十几个小时前就被测试捕捉到了。报告生成不等于报告送达这才是自动化测试最常见的失效方式。所以我在设计反馈链路时强制给自己加了一条规则测试结束后的三分钟内必须让关键信息出现在工作群里。谁都可以不看CI但工作群的消息你不会完全不看。与其依赖人主动去翻报告不如让报告主动走到人面前。1.2 消息卡片为什么比纯文本更适合测试结论有人可能会说用飞书机器人发段纯文本不就行了我最初也是这么干的跑完发一行字“接口自动化测试通过97失败3”。但用了一段时间发现两个问题。一是信息密度太低。纯文本只能列数字失败细节塞进来就是一坨群里刷屏严重看的人还得从上往下一行行扒。二是没有视觉重点。全绿和飘红的区别不够醒目人的潜意识会忽略同质化的消息。消息卡片解决的就是这个。卡片有颜色模板失败红色、全绿绿色、有跳过橙色群里一刷就能看到状态。卡片还有结构化排版用例总数、通过率、耗时、失败列表各占一个区块眼睛扫一遍就拿到了关键信息。最重要的是卡片上能放按钮点击直接跳转完整报告这个交互纯文本做不到。从我踩坑的经验来看消息卡片不是锦上添花而是自动化测试报告推送的正解。它把一个“消息提醒”升级成了“可交互的报告摘要”。2. 飞书群机器人的底层机制Webhook、安全设置与签名2.1 自定义机器人的能力边界飞书群机器人本质上是个Webhook。你在群设置里添加一个自定义机器人飞书会给你一个唯一的URL格式大概是https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx。你往这个URL发一个POST请求机器人就把消息发到群里。这个设计很轻量但也有很多限制。机器人只能发消息不能收消息没有主动对话能力也不能拉人进群。它能发送的消息类型有限最常用的就是text纯文本和interactive消息卡片。还有一个容易忽略的点机器人只能把它被创建的那个群当作目标你拿到Webhook后往群里发就行但不能指定发到别的群。这些边界搞清楚后你就明白了飞书群机器人适合做的是一件事——把某个固定流程产出的结论稳定推送到一个固定群。自动化测试报告推送和这个定位完美契合。2.2 三种安全配置怎么选创建自定义机器人时飞书会要求设置安全配置有三种自定义关键词、加签、IP白名单。很多人直接跳过但我强烈建议至少开一个否则任何拿到Webhook的人都能往你群里灌消息这是个真实存在的风险。安全方式原理优点缺点适用场景自定义关键词消息内容必须包含指定关键词配置简单不影响请求格式内容受限卡片里得硬塞关键词只想挡住误触发的场景加签请求URL带上时间戳和HMAC签名防伪能力强无法简单伪造发送端要额外写签名逻辑推荐通用性最好IP白名单只有指定IP能发送从源头拦截伪造请求CI出口IP可能变动维护麻烦有固定CI服务器的团队我个人建议用加签必要的时候再加IP白名单。加签的核心思路是在Webhook地址后面拼两个参数timestamp和sign服务端会重新计算签名做比对。后面我在推送类里会给出完整实现。2.3 签名算法推导与代码实现加签算法的官方流程不复杂但细节决定成败。完整的签名生成步骤如下拿到当前Unix时间戳单位是秒转成字符串。用timestamp \n secret拼出待签名字符串secret就是你创建机器人时设置的那个密钥。对待签名字符串做HMAC-SHA256运算。对结果做Base64编码。对Base64结果做URL编码防止特殊字符干扰参数传递。对应的Python代码非常短import base64 import hashlib import hmac import time import urllib.parse def gen_sign(secret: str) - tuple[str, str]: timestamp str(int(time.time())) string_to_sign f{timestamp}\n{secret} hmac_code hmac.new( string_to_sign.encode(utf-8), digestmodhashlib.sha256, ).digest() sign urllib.parse.quote_plus(base64.b64encode(hmac_code)) return timestamp, sign这里我踩过一个很隐蔽的坑Python的hmac.new()第一个参数是密钥但飞书官方示例里直接传了string_to_sign也就是说它把“待签名字符串”当成了密钥对空消息做签名。这个写法看着不符合常理但飞书服务端确实是按这个规则来校验的。所以你在网上搜到的各种版本有的能通有的不能通原因就在这。建议以飞书开放平台最新官方文档里的示例为准遇到19021签名错误时写个小脚本把timestamp、secret、签名结果逐段打印和官方Demo比对这是最快的排障路径。还有一个细节timestamp必须用当前时间飞书服务端只允许一定时间范围内的偏移。有些初学的人把timestamp写死或者复用了上一次的值就会一直签名失败。3. 从pytest手里“掏”出测试结果两种主流方案3.1 方案一pytest_terminal_summary钩子实时提取要在测试结束后拿到汇总数据最直接的做法是写一个conftest.py在pytest_terminal_summary钩子里提取结果。这个钩子会在pytest收集完所有测试结果、准备打印终端总结时被调用相当于给了你一个“临门一脚”的机会。先看完整代码# conftest.py import time def pytest_terminal_summary(terminalreporter, exitstatus, config): stats terminalreporter.stats passed len(stats.get(passed, [])) failed len(stats.get(failed, [])) error len(stats.get(error, [])) skipped len(stats.get(skipped, [])) xfailed len(stats.get(xfailed, [])) xpassed len(stats.get(xpassed, [])) total passed failed error skipped xfailed xpassed duration round(time.time() - terminalreporter._sessionstarttime, 2) failed_cases [] for report in stats.get(failed, []) stats.get(error, []): error_line str(report.longrepr).splitlines()[-1] if report.longrepr else unknown failed_cases.append({ nodeid: report.nodeid, error: error_line, }) print(\n[FeishuReporter] collected results) print(ftotal{total}, passed{passed}, failed{failed}, ferror{error}, skipped{skipped}, duration{duration})这个方案的好处是不依赖任何第三方插件只要pytest能跑完数据就能拿到。terminalreporter.stats是一个字典key是结果类型value是TestReport对象列表。report.nodeid能定位到具体用例report.longrepr包含失败时的堆栈和断言信息。需要注意report.longrepr在不同pytest版本里可能是字符串也可能是不可变异常信息对象直接转成str比较稳妥。我只取了最后一行当摘要如果你想拿到完整堆栈存整个字符串也行但卡片里展示的时候就要做截断处理。3.2 方案二解析Allure结果目录有些团队已经接入了Allure测试跑完会生成allure-results目录里面每个用例对应一个*-result.json包含完整的执行状态、失败原因、步骤信息。这种情况下可以写一个独立脚本测试结束后解析这些JSON文件再推送飞书。import glob import json def parse_allure_results(directory: str allure-results) - dict: passed, failed, broken, skipped 0, 0, 0, 0 failed_cases [] for file_path in glob.glob(f{directory}/*-result.json): with open(file_path, encodingutf-8) as fp: data json.load(fp) status data.get(status) if status passed: passed 1 elif status failed: failed 1 failed_cases.append({ nodeid: data.get(fullName, data.get(name, unknown)), error: data.get(statusDetails, {}).get(message, no message), }) elif status broken: broken 1 failed_cases.append({ nodeid: data.get(fullName, data.get(name, unknown)), error: data.get(statusDetails, {}).get(trace, no trace), }) elif status skipped: skipped 1 total passed failed broken skipped return { total: total, passed: passed, failed: failed, broken: broken, skipped: skipped, failed_cases: failed_cases, }用这个方案有个前提pytest运行时要生成Allure原始数据即在命令行加上--alluredirallure-results。好在它不依赖pytest在同一个进程里执行你可以在CI的after_script阶段调用独立推送脚本也可以自己在本地跑完测试后手动执行一次灵活度更高。3.3 统一结构把两种方案合成一个数据模型两种方案各有优势我更建议的做法是定义一个统一的数据结构推送相关的代码只认这个结构不管数据来自pytest还是Allure。这样以后换测试框架或者改用其他数据源推送逻辑完全不用动。from dataclasses import dataclass, field dataclass class ReportData: title: str total: int passed: int failed: int error: int skipped: int duration: float failed_cases: list field(default_factorylist) report_url: str property def pass_rate(self) - float: if self.total 0: return 0.0 return round((self.passed self.skipped) / self.total * 100, 1)建议至少存这些字段总用例数、通过数、失败数、错误数、跳过数、耗时、失败用例列表、报告链接。前几项用于卡片上的汇总信息失败用例列表用于明细展示报告链接用于跳转完整结果。通过率我习惯把skipped算进去因为跳过不等于失败但也说明有用例没跑如果全绿但是一半都跳过了这种情况值得注意。4. 消息卡片组装如何让一份报告在群里“一眼看懂”4.1 卡片2.0的结构拆解飞书消息卡片目前推荐用2.0版本它和1.0最大的区别是2.0的卡片JSON需要放在请求体里的card字段下而1.0是直接把卡片结构放在请求体根上。这个差异我印象太深了后面踩坑详谈。一个标准的卡片2.0请求体长这样{ msg_type: interactive, card: { schema: 2.0, config: { wide_screen_mode: true }, header: { title: { tag: plain_text, content: 接口自动化测试报告 03-27 10:30 }, template: red }, elements: [] } }header就是卡片顶部那块带背景色的区域template控制颜色title是标题文字。elements是卡片的正文部分按从上到下的顺序渲染常用的组件有div普通区块可以放文本或多个字段。hr分隔线。action按钮交互区。note灰色小字备注适合放时间戳等辅助信息。卡片的排版核心就是div和fields的组合。fields是自适应的双列布局可以放两两一组的指标。我用它放用例总数、通过率、耗时、执行时间四个指标两行排开视觉上非常清爽。4.2 状态颜色与失败用例展示策略卡片模板颜色不要一成不变一定要跟着测试结果动态切换这是消息卡片的灵魂。我的策略是测试结果模板颜色触发条件全绿通过green失败和错误均为0且跳过数量很少有跳过/预期失败orange成功为主但有较多跳过或xfailed有失败或错误red任意失败或错误存在需要注意error和failed在pytest里是两种状态error通常指用例执行过程中出现意外异常比如fixture抛错了failed指断言失败。这两者都意味着测试没有通过展示时应该都算作失败。失败用例展示还有个细节如果失败数量很多比如一百个用例红了三十个把三十个全部塞进卡片群里刷出来就是一座山。我的经验是卡片上最多展示前5条每条包含用例名称和一行错误摘要后面加一句“还有其他N个失败用例请查看完整报告”把完整信息留给报告链接。这样既给了即时信息又避免了卡片泛滥。4.3 从测试数据到卡片的组装函数有了数据结构组装卡片就是纯拼接逻辑。下面这个函数接收ReportData返回一个可以直接塞进请求体的卡片字典。def escape_markdown(text: str) - str: return text.replace(\\, \\\\).replace(|, \\|).replace(\n, ) def build_report_card(report: ReportData) - dict: if report.failed report.error 0: template red elif report.skipped 0 or report.passed 0: template orange else: template green elements [] summary { tag: div, text: { tag: lark_md, content: ( f**运行结果**通过 {report.passed} / f失败 {report.failed} / f错误 {report.error} / f跳过 {report.skipped} ), }, } elements.append(summary) elements.append({tag: hr}) fields [ {is_short: True, text: {tag: lark_md, content: f**用例总数**\n{report.total}}}, {is_short: True, text: {tag: lark_md, content: f**通过率**\n{report.pass_rate}%}}, {is_short: True, text: {tag: lark_md, content: f**总耗时**\n{report.duration}s}}, {is_short: True, text: {tag: lark_md, content: f**执行时间**\n{report.title.split( )[-1]}}}, ] elements.append({tag: div, fields: fields}) if report.failed_cases: elements.append({tag: hr}) lines [] for case in report.failed_cases[:5]: name escape_markdown(case[nodeid]) err escape_markdown(case[error]) lines.append(f1. {name}\n {err}) content \n.join(lines) if len(report.failed_cases) 5: content f\n\n还有其他 {len(report.failed_cases) - 5} 个失败用例请查看完整报告。 elements.append({ tag: div, text: {tag: lark_md, content: f**失败用例**\n{content}}, }) if report.report_url: elements.append({tag: hr}) elements.append({ tag: action, actions: [{ tag: button, text: {tag: plain_text, content: 查看完整报告}, url: report.report_url, type: primary, }], }) return { schema: 2.0, config: {wide_screen_mode: True}, header: { title: {tag: plain_text, content: report.title}, template: template, }, elements: elements, }escape_markdown这个函数一定要加。我曾经遇到过失败信息里带|管道符在lark_md语法里被当成了表格分隔符整个卡片的渲染就错乱了。还有信息里带[和]的也可能被理解为链接语法。用户输出永远是不可信的进卡片前统一转义一次省了很多麻烦。5. 推送服务的完整封装签名、重试、异步与日志5.1 核心发送类实现组装好卡片后接下来就是发送。看起来就是一个requests.post但我在实际项目中反复打磨后发现发送这个动作本身值得封装成一个独立类至少在超时、重试、错误处理三个维度做好保障。import time import hmac import base64 import hashlib import urllib.parse import requests class FeishuReporter: def __init__(self, webhook_url: str, secret: str ): self.webhook_url webhook_url self.secret secret def _gen_sign(self) - tuple[str, str]: timestamp str(int(time.time())) string_to_sign f{timestamp}\n{self.secret} hmac_code hmac.new( string_to_sign.encode(utf-8), digestmodhashlib.sha256, ).digest() sign urllib.parse.quote_plus(base64.b64encode(hmac_code)) return timestamp, sign def _build_url(self) - str: if not self.secret: return self.webhook_url timestamp, sign self._gen_sign() separator if ? in self.webhook_url else ? return f{self.webhook_url}{separator}timestamp{timestamp}sign{sign} def send(self, card: dict, timeout: int 10) - dict: url self._build_url() payload {msg_type: interactive, card: card} resp requests.post( url, jsonpayload, headers{Content-Type: application/json; charsetutf-8}, timeouttimeout, ) resp.raise_for_status() result resp.json() if result.get(code) ! 0: raise RuntimeError( fFeishu API error, code{result.get(code)}, msg{result.get(msg)} ) return result def send_with_retry(self, card: dict, retries: int 3) - dict: last_error None for attempt in range(1, retries 1): try: return self.send(card) except Exception as e: last_error e print(f[FeishuReporter] attempt {attempt}/{retries} failed: {e}) if attempt retries: time.sleep(2 ** attempt) raise RuntimeError(fFeishuReporter send failed after {retries} retries: {last_error})_gen_sign里的签名逻辑和第2章是一致的。send_with_retry用了指数退避第一次失败等2秒第二次等4秒第三次放弃。我在实际使用中一般设置3次重试就够了因为飞书接口抖动通常几秒内就能恢复。这里有个细节构建URL时我判断了webhook_url里是否已经有?参数虽然飞书默认的hook地址没有query但保不齐有人把地址包装了一层代理加这个判断更稳妥。5.2 接入pytest钩子与CI流水线有了FeishuReporter和build_report_card接入pytest的最后一步就是在conftest.py里串联起来。我在第3章只打印了汇总数据现在把完整的推送逻辑加上。# conftest.py import os import time import traceback def pytest_terminal_summary(terminalreporter, exitstatus, config): try: report collect_report(terminalreporter, exitstatus) webhook os.environ.get(FEISHU_WEBHOOK) secret os.environ.get(FEISHU_SECRET, ) if not webhook: print([FeishuReporter] FEISHU_WEBHOOK not set, skip push) return reporter FeishuReporter(webhook_urlwebhook, secretsecret) card build_report_card(report) reporter.send_with_retry(card, retries3) print([FeishuReporter] report pushed to Feishu successfully) except Exception: traceback.print_exc()Webhook和secret从环境变量读取不要硬编码到代码里。如果是Jenkins在构建任务的凭据里配置如果是GitLab CI在Settings里的CI/CD Variables里配置本地调试就用.env文件或者直接在shell里export。把凭据和代码分离这是基本的工程素养。GitLab CI的配置也很简单关键是用after_script处理推送这样即使测试用例本身有失败也不会影响推送执行test: stage: test script: - pytest --alluredirallure-results after_script: - python scripts/push_feishu_report.py artifacts: when: always paths: - allure-results如果你的推送逻辑放在conftest.py的hook里那script阶段跑pytest时就已经推送了after_script里可以不放。但如果希望推送动作独立、可复用、或者不想让测试进程被网络请求拖慢就建议做成独立脚本在after_script里执行。5.3 稳定性与安全性的隐性要求推送这个动作看似简单但它跑在测试链路的末端如果它不稳定反而会成为新的噪音。我总结了几个隐性要求。第一推送失败绝对不能影响测试退出码。这就是为什么我在conftest.py里用try/except包住整个推送逻辑。你想想这个场景测试全绿但推送的时候网络抖了一下结果CI任务变红了这是多大的乌龙。第二请求超时必须设置。requests默认不设超时一个挂起的请求可能等上几十秒甚至更久。pytest进程如果卡在推送这一步整条流水线的时间就被拖长了。设个timeout10失败就重试重试不行就放弃推送的重要性永远低于测试本身。第三重试要有节奏不能无限重试。我这里用指数退避最多3次。有的团队喜欢用无限的while True重试我个人不推荐飞书接口如果持续异常说明是它们那边的问题你重试一百次也没用不如先把异常记录下来让值班的人去处理。第四从安全角度看加签后的secret一旦泄露等于攻击者有了往群里发消息的能力。所以我每次看到有人在代码仓库里提交带secret的Webhook地址都会提醒他们立刻到飞书后台重置密钥。此外飞书对每个自定义机器人有发送频率限制一分钟大概是100条如果你在重试循环里不加控制极端情况可能触发频控返回9499错误这时候无论怎么重试都没用只能等冷却时间过去。6. 我实际踩过的坑从排查到解决的完整链路6.1 坑一19021签名校验失败第一次接加签的时候我按网上一个教程写完代码信心满满地跑结果飞书返回{code: 19021, msg: sign match fail}整个人是懵的。我的排查链路是这样的先把签名和发送写成独立脚本加上日志打印出timestamp、secret、string_to_sign、sign盯着看没发现异常然后把同样的参数手动拼到URL里用Postman发了一遍还是19021接着怀疑secret有问题复制的时候可能多带了空格或者换行又去飞书后台重新拷贝了一次依然报错最后我下狠心把拼接好的timestamp、secret、sign逐项输入到飞书官方的签名校验页面终于定位到问题——我写的签名公式和官方示例不是同一个规则。网上有些文章把签名流程写成了“对secret做HMAC加密”还把timestamp忽略掉了实际上飞书要求的是对timestamp \n secret这个整体做签名一个字符都不能差。排查这个问题的过程让我养成了一个习惯凡是第三方接口的签名、加密逻辑一律以官方文档的示例代码为最终基准不轻信任何二手转载。6.2 坑二卡片2.0的结构多包了一层项目初期我用的还是飞书消息卡片1.0的写法后来想给卡片增加更多区块就去飞书开放平台的卡片搭建工具里拖拽配置复制出来的JSON直接放进请求体结果返回19024参数错误。反复看发现卡片搭建工具默认生成的是卡片2.0结构而2.0要求在请求体的card字段下多包一层。1.0时期是这样的{ msg_type: interactive, header: {...}, elements: [...] }2.0时期必须是这样的{ msg_type: interactive, card: { schema: 2.0, config: {...}, header: {...}, elements: [...] } }如果直接把2.0的卡片结构平铺在请求体根上飞书就会报参数错误。这个坑特别容易踩因为卡片搭建工具复制的JSON已经带了schema: 2.0但发送时外包一层card这个动作工具并不会提醒你。我现在的做法是不在工具里复制最终JSON而是把工具当作视觉参考最终请求体代码自己维护。这样结构始终在自己的掌握中不会被工具的版本机制坑到。6.3 坑三请求超时拖慢pytest退出这个问题最隐蔽。一开始推送逻辑放在pytest_terminal_summary里用默认的requests.post没有设置timeout。某天CI上一个小时的测试跑完了却在推送这一步卡了四十多秒整条流水线时间被拉长了很多。排查链路也简单看CI日志发现pytest已经打印完所有结果进程却没有立刻退出再一查是requests.post挂在等待响应飞书接口偶发慢响应默认不设超时的requests会一直等。修复方案就是我在第5章里强调的——所有网络请求都要设超时。加了timeout10之后哪怕飞书真的抽风最多等10秒就放弃了不会无限拖下去。另外一个相关经验是如果推送逻辑放在pytest进程里最好用同步发送而不是开新线程因为pytest退出时会直接杀线程异步任务的日志可能还没打印出来就没了。6.4 坑四失败信息里的竖线把卡片渲染搞乱了有一次测试失败的断言信息里带了JSON片段里面有个|字符卡片发出去后那个区块的文字错乱多出了表格线整张卡片丑得没法看。原因是卡片2.0的lark_md文本模块会把|识别成表格分隔符。解决方式我已经在前面写好了对用户可控的文本做转义|替换成\|换行替换成空格。这个坑提醒我任何从测试代码、接口响应、第三方日志里拿到的东西进卡片之前都要当成不可信数据对待。6.5 思考把推送能力沉淀成团队工具这套链路跑通之后我又花了一些时间把推送脚本从测试项目里拆成了一个独立的Python包参数化webhook、密钥、卡片模板让不同项目组直接引用。这样做的理由很简单自动化测试的反馈链路不是某一个人的需求只要一个团队在跑自动化就值得拥有统一的报告推送能力。我见过很多团队测试报告推送都是“每个项目各写一份”最终就是功能重复、维护困难、有人写3个重试有人写0个重试。不如抽一次公共能力把签名、发送、重试、错误码处理这些逻辑收敛在一个地方各项目只需要提供自己的数据。最后再分享一个小技巧如果你的团队对失败特别敏感可以在卡片上的“查看完整报告”按钮下面加一行note用灰色小字标注“若连续失败请关注XX服务状态页”。我试过之后群里测试的人明显少了因为大家自己就能根据提示去判断是测试环境问题还是代码问题这大概就是自动化测试反馈链路该有的样子。