自动化测试跑完只是第一步,报告能被团队真正看到、被问题负责人及时认领,才算闭环。我最开始做Python自动化测试报告推送的时候,用的还是最原始的方式——跑完脚本,手动打开HTML报告截图,再拖进群里发一张图。用例量少的时候还行,等用例从几十条涨到几百条,这套流程就成了纯体力活:截图截不全、失败用例没人及时跟进、报告链接还要另外贴。后来我改成飞书群机器人消息卡片,用Python把pytest的测试结果直接组装成一张结构化卡片推到群里,整个团队在同一个对话流里就能看到通过率、失败用例、执行时长和报告链接。这篇文章就把这套方案从零到落地讲透,适合正在做接口自动化、UI自动化或任何需要把测试结果同步给团队的测试开发同学,看完可以直接抄作业。
1. 为什么自动化测试报告要发飞书消息卡片
先说结论:不是所有报告都需要推送到聊天工具,但只要有协作属性,消息卡片就是比“扔个链接”和“贴张截图”都靠谱的载体。
1.1 传统测试报告推送的4个痛点
我见过很多测试团队的报告推送方式,归纳下来无非几类,各有各的难受。
第一类是发HTML报告链接。报告内容确实全,但很多人根本不点开。链接在群里待半天,真正打开的可能只有测试自己。尤其是移动端,打开一个PC端渲染的报告页面,体验一言难尽。
第二类是贴纯文本消息。比如“测试通过120条,失败3条”,信息倒是到了,但完全没有结构化。失败的是哪几条?耗时多久?对应什么模块?全都得靠人再问一遍。
第三类是发截图。信息完整,但不支持交互,并且截图时刻的报告状态是静态的,过期了也没有人能察觉。
第四类是写邮件。太正式了,而且邮件的即时性太差。团队真出问题的时候,没人会先看邮件再开始排查。
在实际落地中,最难受的不是“报告跑不出来”,而是“跑出来之后怎么让人愿意看”。消息卡片恰好把“信息密度”和“阅读成本”平衡住了:一眼扫过去,通过率、失败数、责任人、链接都有;想深入看,点一下按钮就能跳转完整报告。
1.2 消息卡片相比纯文本消息强在哪
飞书群机器人原生支持text、post、interactive几种消息类型。text就是纯文本,post可以带一些简单的富文本格式,但真正值得投入的是interactive消息卡片。
卡片最大的优势是结构自由。你可以把报告里的核心指标拆成模块,放在一张卡片里,例如顶部标题写明项目名和报告时间,中间用字段区域铺开通过/失败/跳过数量,底部放一个“查看完整报告”的按钮。阅读的人不用离开飞书对话框,就能完成第一轮信息消费。
第二个优势是视觉反馈。测试红了还是绿了,靠卡片的主题色一眼就能确认。header的template支持blue、green、red、orange等多种颜色,通过率高用绿色,有失败用例用红色,这种颜色信号比文字描述来得快得多。
第三个优势是交互扩展。卡片里可以放按钮、链接、甚至后续可以改成“确认修复”“重新执行”这类带操作的回调按钮。虽然很多团队第一步只做展示,但这个上限意味着消息卡片不是一个临时方案,后续演进空间很大。
结合当前很多大厂自动化测试团队的实际做法,趋势也很明显:报告不只是给自己看的,而是要让研发、产品、项目管理都能在同一个群里对齐风险。消息卡片就是承担这个角色的好容器。
2. 飞书群机器人接入:从创建Webhook到安全配置
要把卡片发到群里,第一步必然是搞到机器人的Webhook地址。这一步没有技术难度,但配置细节容易踩坑,我拆开讲清楚。
2.1 创建自定义机器人并获取Webhook地址
打开飞书群聊,进入群设置,找到群机器人,选择添加机器人,在机器人列表里选“自定义机器人”。起个名字,比如“测试报告机器人”,头像随意。创建成功后,飞书会给你一个Webhook地址,格式类似:
https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx这个地址就是后续Python脚本发送消息的目标URL。复制保存,不要泄露到公开仓库里。
创建完机器人后,在机器人管理页面还能看到“签名校验”“IP白名单”“关键词”三个安全设置选项。建议至少配置一项,否则任何拿到Webhook的人都能往群里发消息,长期来看是有风险的。
2.2 三种安全校验方式怎么选
飞书自定义机器人目前支持三种安全设置,可以多选,也可以组合。
关键词校验是限制消息内容必须包含指定关键词,比如设置关键词为“测试报告”,那么发送的消息文本里必须包含这四个字,否则请求会被拒绝。这个方式最简单,但关键词数量有限制,并且对interactive卡片里的文本位置也有限制,需要关键词出现在content字段里才能通过校验。
签名校验是目前最推荐的方式。开启后飞书会生成一个密钥(secret),发送请求时需要在请求体中附带timestamp和sign两个字段。sign的计算方式是用timestamp加上换行符再加上密钥拼接成字符串,然后通过HMAC-SHA256算法计算摘要,最后做Base64编码。这个方式不限制消息内容,安全性也更好,推荐所有团队直接采用。
IP白名单是限制请求来源IP,适合固定执行机的场景。比如你的自动化测试跑在公司内网固定Jenkins节点上,可以把节点出口IP加进去。但如果执行机IP会变动,用了白名单反而容易误伤。
我的建议是:日常团队内部使用,开启加签模式就够了;如果自动化任务跑在公网执行机上,可以额外加上IP白名单。
2.3 Python环境准备与requests库安装
发送消息卡片本质上就是一次HTTP POST请求,Python环境只需要有requests库。如果当前机器还没装Python,先从官网下载安装包,装的时候记得勾选“Add Python to PATH”,避免后续在命令行里找不到python命令。
安装依赖:
pip install requests如果要跑pytest项目,还需要额外安装pytest:
pip install pytest如果你用的是虚拟环境,记得先激活虚拟环境再安装。我习惯在项目根目录维护一个requirements.txt,把用到的依赖固定版本写进去,方便新同事拉代码后一键安装。
3. 消息卡片JSON结构拆解与Python发送实现
很多第一次接触飞书消息卡片的人,会在构造JSON这一步卡住。其实卡片结构并不复杂,本质就是三层:整体配置、头部、元素区。
3.1 卡片整体结构:header、elements、config
interactive消息的外层结构是:
{ "msg_type": "interactive", "card": { "config": {}, "header": {}, "elements": [] } }msg_type固定为interactive,表明这是一张消息卡片。card对象内部由三部分组成。
config是卡片配置,最常用的是wide_screen_mode,控制卡片是否宽屏展示。在飞书客户端里,宽屏卡片内容显示区域更大,适合展示报告指标;不设置则默认窄屏,看起来更像一条消息。测试报告建议设置为true,信息展示更舒服。
header是卡片顶部区域,主要由title和template构成。title是标题文本,template是颜色模板,支持blue、wathet、turquoise、green、yellow、orange、red、carmine、violet、purple、indigo、grey。测试报告场景下,我一般用green表示全部通过,red表示有失败,orange表示有错误或跳过较多。
elements是一个数组,负责承载卡片主体内容。常用的元素类型有:
- div:文本块,支持lark_md语法或plain_text纯文本
- hr:分割线,用来区隔不同信息块
- note:备注信息,适合放执行时间、执行人等辅助信息
- action:交互区,可以放按钮和链接
- img:图片
这几类元素组合起来,已经能覆盖绝大多数测试报告展示需求。
3.2 测试数据如何组装成卡片内容
测试报告要展示哪些内容,取决于团队关注什么。最基本的四件套是:用例总数、通过数、失败数、跳过数。再进一步可以加上执行开始时间、执行时长、通过率、失败用例列表、报告链接。
以pytest为例,测试结束后的统计结果长这样:
passed=120, failed=3, skipped=5, error=0我需要把这些原始数据映射到卡片的对应区域。我的做法是:把“执行概要”放进div,用lark_md语法加粗展示;把“通过/失败/跳过”三个核心数字放进fields字段,设置is_short为true,让它们横向排布;底部再用一个note元素放执行环境信息,用一个button按钮放报告链接。
组装过程中有两个容易疏忽的点。
一是lark_md语法不完全等于Markdown。加粗用**,换行用\n,字体颜色需要借助标签实现。注意 标签必须配合lark_md的tag使用,纯文本tag里不生效。
二是数据需要做边界处理。当用例总数为0时,通过率要避免除零异常;当失败列表为空时,不要让“失败用例”区域显示空内容。这些细节在真实项目里非常影响观感。
3.3 requests发送卡片的完整代码
定义几个核心函数:签名生成函数、卡片构建函数、发送函数。下面是简化后的示例,可直接复制改参数使用。
import base64 import hashlib import hmac import json import time import requests def generate_sign(timestamp: str, secret: str) -> str: string_to_sign = f"{timestamp}\n{secret}" hmac_code = hmac.new( string_to_sign.encode("utf-8"), digestmod=hashlib.sha256, ).digest() return base64.b64encode(hmac_code).decode("utf-8") def build_report_card(summary: dict, report_url: str = "") -> dict: passed = summary.get("passed", 0) failed = summary.get("failed", 0) skipped = summary.get("skipped", 0) total = passed + failed + skipped pass_rate = round(passed / total * 100, 2) if total > 0 else 0.0 color = "green" if failed > 0: color = "red" elements = [ { "tag": "div", "text": { "tag": "lark_md", "content": f"**测试项目**:用户中心接口自动化\n**执行时间**:{summary.get('start_time', '-')}\n**执行时长**:{summary.get('duration', '-')}", }, }, {"tag": "hr"}, { "tag": "div", "fields": [ { "is_short": True, "text": {"tag": "lark_md", "content": f"**通过**\n<font color='green'>{passed}</font>"}, }, { "is_short": True, "text": {"tag": "lark_md", "content": f"**失败**\n<font color='red'>{failed}</font>"}, }, { "is_short": True, "text": {"tag": "lark_md", "content": f"**跳过**\n<font color='orange'>{skipped}</font>"}, }, { "is_short": True, "text": {"tag": "lark_md", "content": f"**通过率**\n{pass_rate}%"}, }, ], }, ] if report_url: elements.append( { "tag": "action", "actions": [ { "tag": "button", "text": {"tag": "plain_text", "content": "查看完整报告"}, "type": "primary", "url": report_url, } ], } ) card = { "config": {"wide_screen_mode": True}, "header": { "title": {"tag": "plain_text", "content": summary.get("title", "自动化测试报告")}, "template": color, }, "elements": elements, } return card def send_feishu_card(webhook: str, secret: str, card: dict) -> dict: timestamp = str(round(time.time())) sign = generate_sign(timestamp, secret) payload = { "timestamp": timestamp, "sign": sign, "msg_type": "interactive", "card": card, } headers = {"Content-Type": "application/json"} resp = requests.post(webhook, json=payload, headers=headers, timeout=10) return resp.json()这个方法有几个细节需要注意。
requests.post的json参数会自动把字典序列化为JSON,同时设置Content-Type为application/json,所以不需要手动调json.dumps。timeout建议显式设置,默认不设置可能导致请求一直挂起,影响测试任务整体时长。
3.4 加签模式下signature的计算与验证
签名模式的算法逻辑在官方文档里有说明,本质是HMAC-SHA256。标准的步骤是:
- 获取当前时间的秒级时间戳,转为字符串。例如
1680000000 - 将时间戳和密钥用换行符拼接:
1680000000\n你的secret - 以拼接后的字符串为消息,密钥字符串作为密钥,用HMAC-SHA256算法计算摘要
- 将摘要进行Base64编码,得到sign值
- 请求体中同时携带timestamp和sign两个字段
需要特别注意编码问题。Python里hmac.new的第一个参数和第二个参数都必须是bytes类型,所以拼接后的字符串要先encode成UTF-8,否则会报错。另外,密钥不要和Webhook一起明文写在代码里,尤其是代码要提交到Git仓库时,务必用环境变量或配置文件方式注入。
4. pytest自动化测试报告对接飞书卡片的完整实战
前两部分讲的是单次推送的实现,这一部分讲怎么和pytest自动化测试框架集成,做到跑完测试自动推送,全程无需人工参与。
4.1 项目目录与依赖设计
我建议把推送逻辑独立成一个模块,不要和测试用例混在一起,方便后续维护和复用。
auto_test/ ├── conftest.py ├── requirements.txt ├── tests/ │ ├── test_login.py │ └── test_user.py ├── notify/ │ ├── __init__.py │ ├── feishu.py # 飞书消息封装 │ └── report.py # 报告组装逻辑 └── run.py # 入口脚本notify/feishu.py放签名生成和发送函数,notify/report.py放报告卡片的数据组装逻辑。conftest.py放pytest钩子函数,负责在测试结束后触发推送。这样职责清晰:测试用例只负责测试,报告组装和推送都是独立的。
4.2 pytest钩子函数自动采集测试结果
pytest提供了一系列钩子函数,我推荐使用pytest_terminal_summary,它在整个测试会话结束前触发,此时所有测试结果已经统计完成,我们可以拿到terminalreporter对象,从中提取通过、失败、跳过等数据。
在conftest.py中添加如下代码:
import time import pytest from notify.feishu import send_feishu_card from notify.report import build_report_card @pytest.hookimpl(tryfirst=True) def pytest_terminal_summary(terminalreporter, exitstatus, config): stats = terminalreporter.stats passed = len(stats.get("passed", [])) failed = len(stats.get("failed", [])) skipped = len(stats.get("skipped", [])) error = len(stats.get("error", [])) summary = { "title": "接口自动化测试报告", "passed": passed, "failed": failed + error, "skipped": skipped, "start_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), "duration": f"{terminalreporter._session.duration:.2f}s", } # 从环境变量读取Webhook和Secret webhook = config.getini("feishu_webhook") secret = config.getini("feishu_secret") card = build_report_card(summary) send_feishu_card(webhook, secret, card)这里的stats是一个字典,key对应不同的结果状态,value是对应的测试报告对象列表。通过len()就可以统计数量。error单独统计,因为error通常表示用例执行过程中出现异常,和断言失败不完全是一回事,但推送到群里时可以合并成“失败”给团队看。
注意webhook和secret不要硬编码在代码里,建议放到pytest.ini或环境变量。例如在pytest.ini中定义自定义配置项:
[pytest] feishu_webhook = https://open.feishu.cn/open-apis/bot/v2/hook/your_webhook feishu_secret = your_secret然后在conftest.py里通过config.getini()读取,既能满足灵活性,又避免敏感信息进入代码库。
4.3 组装报告卡片并推送到群
跑测试时执行:
pytest tests/ -v执行完成后,pytest会自动触发pytest_terminal_summary钩子,把统计结果传给组装函数,生成卡片JSON,再通过requests发送到飞书群。
如果是Jenkins或GitLab CI执行,只需要在构建脚本里先安装依赖再跑pytest即可:
pip install -r requirements.txt pytest tests/ -v --tb=short任务结束后群里的机器人会自动发出卡片,负责人点开卡片就能看到这次构建的测试情况。
我在实际项目里还加了失败用例的详细信息展示。做法是遍历stats["failed"],把每个失败用例的nodeid取出来,截取最后一段作为用例名,再拼接成多行文本放到卡片底部的note元素里。这样团队成员不用打开报告就能判断失败原因大概在哪个模块。
failed_cases = [item.nodeid for item in stats.get("failed", [])] if failed_cases: case_text = "\n".join(failed_cases[:10]) elements.append({ "tag": "note", "elements": [{"tag": "plain_text", "content": f"失败用例:\n{case_text}"}], })4.4 扩展:失败重跑、定时触发与报告链接
有了一定基础后,可以继续扩展三个高频需求。
第一是失败用例自动重跑。pytest-rerunfailures插件可以给失败用例设置重跑次数,减少网络抖动或环境偶发问题带来的误报。如果重跑后仍然失败,再推送报警卡片,这样卡片消息的通知价值更高,不会变成“狼来了”。
第二是定时触发。自动化测试通常不会手动执行,可以挂在Jenkins上做定时任务,也可以直接写一个简单的调度脚本,每天固定时间调用pytest命令。实测下来,定时任务配上卡片推送,团队早上来第一件事就是看群里有没有红色卡片,问题响应速度明显提升。
第三是报告链接。pytest本身不自带HTML报告,但可以安装pytest-html插件,执行时追加--html=report.html参数生成HTML报告。把报告文件放到Web服务器或对象存储上,把访问URL传给build_report_card函数,卡片底部右侧就会出现“查看完整报告”按钮。点一下直接跳转,体验完整。
5. 飞书机器人推卡片常见问题与排查速查表
任何方案落地过程中都会踩坑,这部分我把实际开发中遇到的高频问题整理成一个速查表,碰到问题可以直接对照排查。
5.1 常见异常与错误码对照
飞书机器人接口的响应体是严格JSON结构,判断是否成功只需要看code字段是否为0。如果非0,msg中会给出原因。以下是几个高频错误码及处理建议:
| 错误码 | 错误含义 | 解决思路 |
|---|---|---|
| 0 | 成功 | 无需处理 |
| 9499 | 请求频率超限 | 降低发送频率,或对发送请求做重试退避 |
| 19001 | 参数不合法 | 检查JSON字段名和类型,重点看msg_type、card结构 |
| 19021 | 关键词校验未通过 | 消息内容中必须包含已设置的关键词 |
| 19024 | 签名校验失败 | 检查secret是否正确、timestamp是否为秒级、签名算法是否一致 |
| 19025 | IP白名单校验失败 | 检查执行机出口IP是否在白名单内 |
遇到19024时,优先排查签名计算流程。最常见的原因是timestamp和sign不匹配,比如用了毫秒时间戳而飞书要求秒级,或者拼接字符串时漏了换行符。
5.2 卡片渲染异常的原因与规避
卡片请求发送成功,code返回0,但在飞书群里显示不出来,或者显示成原始JSON,这通常是消息类型或卡片格式有问题。常见原因有两种。
一种是把card写在了错误位置。interactive消息要求卡片内容放在card字段下,有人会误放在content字段下导致无法渲染。另一种是elements里使用了不支持的组件组合,比如在note元素里嵌套了img,或者div里的text同时设置plain_text和lark_md。组件必须严格按照飞书开放平台文档定义的格式来写。
排查思路很简单:先用飞书官方的“卡片搭建工具”把JSON粘贴进去预览,能正常预览再去代码里发请求。这样能快速定位是数据结构问题还是接口调用问题。
5.3 频率限制与超时处理
飞书自定义机器人对单个机器人有频率限制,限制大约是每秒5次。如果测试任务里有多条消息需要连续发送,比如每条用例都推一条消息,就很容易触发9499限流。
我在代码里加了一个简单的重试机制,对返回码9499的请求进行三次重试,间隔分别取1秒、2秒、4秒,实测下来能解决大部分偶发限流问题。
另外,requests请求超时时间不要设置太长,推荐5到10秒。飞书接口正常情况下响应都在1秒以内,如果超过10秒,大概率是网络问题或Webhook地址异常,没必要一直等。
5.4 运维阶段的三个小技巧
最后分享三个我在运维过程中觉得非常好用的小技巧。
第一,推送前先打印完整请求体,方便本地调试。把payload输出到日志里,对照官方文档逐字段排查,能节省大量时间。确认无误后可以加一个环境变量控制是否打印,避免生产环境日志被打爆。
第二,卡片模板版本化。把card的JSON结构单独存成模板文件,用jinja2或字符串格式化动态填充数据。这样后续改模板样式,不需要动Python代码,测试和运维成本都更低。
第三,给机器人加一个“健康检查”命令。比如往群里发一条“/ping”,机器人返回一条简单的卡片消息,用来确认Webhook和密钥是否依然有效。团队做大之后,这条小功能能省不少排查沟通的时间。
就我个人实际体验来说,飞书消息卡片推送测试报告这件事,真正改变的不是“报告怎么发出去”,而是“团队怎么看待自动化测试”。以前测试报告是个孤岛,跑完只有自己知道;现在每次跑完,通过率、失败用例、风险信息都在群里公开可见,问题暴露得快,反馈链条也短。关键是整个方案的技术门槛并不高,核心就是一次HTTP请求加一套JSON结构,落地成本远比收益小。如果你的自动化测试还在用截图或者扔链接的方式同步结果,建议直接照这篇文章的方案试一次,跑通之后你大概率不会再想换回去了。