☰
Python实现pytest测试结果飞书消息卡片推送实战
2026/10/2 20:25:47 网站建设 项目流程

做自动化测试这件事,代码写起来往往不是最难的,最难的是让测试结果真正被看到。我早期跑完一套pytest用例,报告生成之后就丢在服务器某个路径下,除了记录在案,几乎没人点开看。后来换了思路:任务跑完直接把关键结果推送到飞书群里,谁负责谁跟进,一眼就能看到。这篇文章就是基于这个思路提炼出来的一整套实战方案,从飞书群机器人配置、消息卡片结构设计,到Python签名算法、报告数据解析,以及我在实际部署中踩过的坑,全部摊开来讲,保证你照着做就能用。

这个项目适合谁?如果你正在做Python自动化测试,又被“报告没人看、问题没人跟”这件事困扰过,或者你刚接触飞书开放能力,想把测试结果、构建通知、定时任务状态这类信息弄到群里,那这一篇就是为你准备的。整个过程不依赖重型框架,一个Python脚本加上一个Webhook地址就能跑起来,接入成本很低,但收益非常直接。

1. 先搞清楚:消息卡片到底能解决什么问题

飞书群机器人的消息推送,最常见的做法是发纯文本消息。文本消息胜在简单,几行代码就能发出去,但它的表达力确实有限——一堆文字堆在一起,通过率、失败数、运行时长全靠肉眼去分辨。而消息卡片不一样,它把信息拆成了结构化的模块:标题、指标区块、结果明细、操作按钮,收消息的人可以一眼抓住重点。

我在实际项目中把两套方案都试过,最后完全切到消息卡片。原因很简单:人在群里收到一条密密麻麻的文本,第一反应是划过去,但收到一张排版清晰的卡片,至少会停下来看一眼关键指标。自动化测试报告这件事,本质是“让正确的人在正确的时间看到正确的信息”,消息卡片在这一点上明显更胜任。

还有一点容易被忽略——消息卡片支持交互按钮。比如点一下“查看完整报告”跳转到内网测试报告页面,或者点一下“重跑失败用例”触发流水线。这在纯文本消息里是做不到的。虽然这篇文章的标题重点在“消息卡片实战”,但底层涉及的签名计算、HTTP调用、JSON解析这些Python能力,在其它平台的机器人开发里同样通用。

1.1 前置准备:创建自定义机器人并拿到Webhook地址

开始写代码之前,先得在飞书群里建一个机器人。操作路径是这样的:进入目标群聊 -> 打开群设置 -> 找到“群机器人” -> 添加自定义机器人。添加成功后会得到一个Webhook地址,形如:

https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

这个地址就是后续所有消息推送的入口。拿到Webhook之后,建议先在飞书群里试发一条文本消息,确认地址有效。具体测试可以直接用命令行工具curl快速验证:

curl -X POST -H "Content-Type: application/json" \ -d '{"msg_type":"text","content":{"text":"test from terminal"}}' \ https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxx

如果群里出现了“test from terminal”,说明Webhook前半段没问题。这一步看起来基础,但它能帮你排除掉了后面所有排查时“到底是不是机器人生效了”的疑虑。我见过不少同事一上来就写Python代码,调了半天发现Webhook本身是失效的,浪费时间。

1.2 签名校验:为什么需要它,怎么理解它

飞书自定义机器人在创建时,可以开启“签名校验”功能。开启之后,请求体里必须携带两个额外参数:timestamp(时间戳)和sign(签名值)。签名算法本身不复杂:把发请求时的Unix时间戳和密钥拼成特定格式的字符串,再用这个字符串作为密钥,对请求体做HMAC-SHA256计算,最后做Base64编码。

用一个生活化的类比:时间戳是“暗号的有效期”,签名是“进门用的暗号”。服务器收到请求后,先看你的暗号对不对,再看暗号有没有过期,两个条件都满足才放行。这样做的好处很实际——就算Webhook地址不小心泄露了,没有密钥的人也没法伪造请求,而且还能防止恶意重放。

从安全角度说,我建议所有正式环境都开启签名校验。尤其是测试报告这种会包含业务信息的消息,一旦Webhook被外部恶意调用,刷屏骚扰还是小事,更严重的是暴露了自动化测试的工作节奏和内部信息结构。后续代码部分我会给出完整的签名计算实现。

2. 消息卡片的接口模型与数据结构

飞书消息卡片在API层面的本质其实就是一种JSON结构。你向Webhook发送一个符合规范的JSON,飞书把它渲染成可视化卡片。掌握这个数据模型,比背任何SDK都重要,因为SDK只是JSON的封装而已,直接操作JSON才是最能看清全貌的方式。

2.1 interactive类型卡片的三段式结构

先看一个最基础的卡片请求体长什么样:

{ "msg_type": "interactive", "card": { "config": { "wide_screen_mode": true }, "header": { "template": "blue", "title": { "tag": "plain_text", "content": "接口自动化测试报告" } }, "elements": [ { "tag": "div", "text": { "tag": "lark_md", "content": "**通过率:** 92.3%" } } ] } }

可以看到,整个卡片分为三个核心组成部分:

  • config:卡片配置,常用的是wide_screen_mode(宽屏模式)和enable_forward(允许转发)。宽屏模式建议打开,不然手机上显示很窄。
  • header:卡片头部,template控制颜色,title是标题。模板色支持blue、green、red、orange、purple等,我习惯用green表示全部通过、red表示有失败、orange表示有跳过或警告。
  • elements:卡片主体,是一个数组。数组里可以放div文本模块、hr分割线、note备注、action按钮模块等,并且顺序完全由你决定。

header的template颜色其实可以做成动态的,在Python里根据测试结果来决定用哪个颜色。这个细节在视觉上非常有效,群成员扫一眼颜色就知道这次跑得怎么样,根本不需要读文字。

2.2 div、note、action常用模块逐一说明

elements数组里最常用的三个模块是div、note和action。

div模块是主力,它支持lark_md格式的文本,也就是说可以解析加粗、链接、引用这些Markdown语法。测试报告里的关键指标,比如用例总数、通过数、失败数、执行耗时,都可以用“字段名:值”的形式写在同一个div里。lark_md对普通加粗、链接支持得挺好,但表格、行内代码这类复杂语法不建议依赖它,实测渲染效果不稳定。

note模块是灰色小字,适合放备注信息,比如“由pytest定时任务触发”“报告生成时间:2025-01-XX 14:32”。它不会抢占视觉焦点,但能给消息补充上下文。

action模块是按钮模块,里面可以放多个button。每个button有text和action,action的类型可以是打开URL(open_url),也可以发送自定义回调。在自动化测试场景中,最实用的就是放一个“查看完整测试报告”按钮,点击跳到报告地址。如果配合Jenkins或GitLab CI,还可以放一个“重跑失败用例”的按钮,点击后触发对应的流水线。

还有一个简单的办法可以获取报告的可访问地址:如果你只是本地跑脚本,可以用Python起一个临时的HTTP服务,把pytest-html生成的HTML报告目录暴露出去。但这种方式注意安全性,内网临时用可以,别暴露到公网。

3. Python端完整实现:从签名到推送一条龙

这个环节是整个实战的核心,我直接给出可落地的代码,每一段都会解释它解决什么问题、为什么这么写。我会从签名函数开始,一步步构建到最终的发送函数。

3.1 签名生成与请求发送

签名算法看似简单,但直接从官网上抄下来改写成Python时容易踩坑。最稳妥的写法是下面这样:

import base64 import hashlib import hmac import time import requests def gen_sign(timestamp, secret): """ 根据飞书签名规则生成sign值。 规则说明:将 timestamp + "\n" + secret 作为key, 使用HmacSHA256算法对请求体做签名,然后Base64编码。 这里保留完整流程,方便根据实际版本调整。 """ string_to_sign = "{}\n{}".format(timestamp, secret) hmac_code = hmac.new( string_to_sign.encode("utf-8"), digestmod=hashlib.sha256 ).digest() sign = base64.b64encode(hmac_code).decode("utf-8") return sign def send_feishu_card(webhook, secret, card_payload): """ 发送消息卡片。 card_payload 是interactive类型卡片的完整JSON结构(dict)。 """ timestamp = str(round(time.time())) sign = gen_sign(timestamp, secret) body = { "timestamp": timestamp, "sign": sign, "msg_type": "interactive", "card": card_payload } resp = requests.post( webhook, json=body, timeout=10 ) result = resp.json() if result.get("code") != 0: raise RuntimeError( "飞书消息发送失败: code={}, msg={}".format( result.get("code"), result.get("msg") ) ) return result

有几个细节要展开说。

第一,timestamp必须用秒级Unix时间戳,并且要用字符串形式拼进签名串。有的资料里写的是毫秒级,拼出来签名永远对不上,这是最容易踩的第一坑。第二,hmac.new的第二个参数是消息内容,我们这里用的是空内容,因为官方推荐做法是直接对待发送的body做签名,但对大多数场景来说,只需要保证“签名串+时间戳能对上”即可。如果你对接的飞书版本有差异,以官方文档签名规则为准做调整。第三,requests必须加timeout,否则网络异常时线程会一直挂住,在定时任务里尤其危险,会导致任务堆积。

3.2 构造测试报告卡片的工厂函数

发送函数准备好了,下一步就是构造卡片内容。我通常写一个专门用来生成测试报告卡片的函数,输入是测试结果统计信息,输出是一个符合飞书规范的字典型结构。这样业务数据和展示层就分开了,后续想换消息模板或者加字段都很方便。

def build_report_card(stats, report_url=None): """ stats结构示例: { "total": 26, "passed": 24, "failed": 1, "errors": 0, "skipped": 1, "duration": 123.5, "start_time": "2025-01-15 10:22:00" } """ total = stats["total"] passed = stats["passed"] failed = stats["failed"] errors = stats["errors"] skipped = stats["skipped"] rate = round(passed / total * 100, 2) if total else 0 # 根据结果动态决定卡片的主题色 if failed + errors > 0: template = "red" elif skipped > 0: template = "orange" else: template = "green" elements = [ { "tag": "div", "text": { "tag": "lark_md", "content": "**用例总数:** {}\n**通过:** {} | **失败:** {} | **错误:** {} | **跳过:** {}".format( total, passed, failed, errors, skipped ) } }, { "tag": "div", "text": { "tag": "lark_md", "content": "**通过率:** {}%\n**执行耗时:** {} 秒".format(rate, stats["duration"]) } } ] if report_url: elements.append({ "tag": "action", "actions": [ { "tag": "button", "text": { "tag": "plain_text", "content": "查看完整报告" }, "type": "primary", "url": report_url } ] }) elements.append({ "tag": "note", "elements": [ { "tag": "plain_text", "content": "触发时间:{}".format(stats["start_time"]) } ] }) return { "config": { "wide_screen_mode": True }, "header": { "template": template, "title": { "tag": "plain_text", "content": "接口自动化测试报告" } }, "elements": elements }

这段代码的妙处在于:颜色、按钮、备注完全是数据驱动的。测试全绿的时候群里看到的是绿色卡片,有人提交了烂代码导致挂了,卡片立刻变红,且附带一个可以点进去的完整报告入口。

另外注意,按钮的url字段和action的type搭配。在飞书卡片里,如果要支持点击跳转,button的type可以是"primary"或"default",同时提供url字段。如果要触发自定义回调,则需要配置Webhook和应用能力,复杂度高一些,这里不展开。

3.3 从pytest拿到统计数据

卡片数据从哪来?最直接的方式是解析pytest的测试输出。如果你的项目已经接了pytest,那么可以先用下面的方式收集结果:使用pytest的钩子脚本或者在命令行里加上JUnit XML输出,然后解析XML文件。以一个简单的JUnit XML解析为例:

import xml.etree.ElementTree as ET import time def parse_junit_xml(xml_path): tree = ET.parse(xml_path) root = tree.getroot() testsuite = root.find("testsuite") if testsuite is None: testsuite = root # 兼容某些版本的输出结构 total = int(testsuite.attrib.get("tests", 0)) failures = int(testsuite.attrib.get("failures", 0)) errors = int(testsuite.attrib.get("errors", 0)) skipped = int(testsuite.attrib.get("skipped", 0)) passed = total - failures - errors - skipped # JUnit XML里没有直接给总耗时,可以自己计时 duration = float(testsuite.attrib.get("time", 0)) return { "total": total, "passed": passed, "failed": failures, "errors": errors, "skipped": skipped, "duration": round(duration, 2), "start_time": time.strftime("%Y-%m-%d %H:%M:%S") }

在pytest命令行中生成JUnit XML很简单:

pytest tests/ -x --junitxml=reports/junit.xml

随后在Python里调用parse_junit_xml拿到stats,再传给build_report_card,最后send_feishu_card推送。这一套流程下来,从测试执行到报告通知就完全自动化了。

如果你用的是unittest,也可以自己在tearDown里统计结果,或者用TextTestRunner的result对象。核心思路是一样的:先把测试数据归拢成一个dict,再把它喂给卡片生成函数。这也体现了分层的好处——不管你用pytest还是unittest还是自定义脚本,最后落到飞书卡片的数据结构都一样。

4. 实战场景:我把这套方案接到定时任务里

代码写完了,我来说一个完整的落地案例。我这边有一套跑在某台Linux服务器上的接口回归测试,每天凌晨2点触发,跑完后自动推送结果到测试组的飞书群里。整个实现分三步。

4.1 拉取最新代码并执行测试

定时任务用crontab实现。实际运行时先拉代码再执行测试,这样确保每天测的是最新代码。脚本入口写成shell脚本更便于维护:

#!/bin/bash cd /opt/projects/api-test git pull origin main source venv/bin/activate pytest tests/ -q --junitxml=reports/junit.xml python notify_feishu.py

notify_feishu.py里面做的事情就是把前面那几个函数串起来:解析XML、构造卡片、发飞书。注意shell脚本里我刻意没有加set -e,因为我的预期是:即使测试有失败,pytest仍然会返回非0退出码,但后续的notify_feishu.py还是要继续跑,否则飞书收不到任何通知。这是很多人会忽视的细节。

4.2 失败时@负责人

光发一张卡片还不够,测试挂了之后最好能@到具体的人。飞书消息卡片支持在文本内容里通过at标签@指定用户,但前提是你得知道用户的open_id或user_id。最简单的方案是先用飞书API根据手机号或邮箱查用户ID,然后在卡片文本里拼接at标签。

这里给出一个简化的实现思路:

def get_user_id_by_mobile(mobile, tenant_access_token): url = "https://open.feishu.cn/open-apis/contact/v3/users/batch_get_id" headers = { "Authorization": "Bearer {}".format(tenant_access_token), "Content-Type": "application/json" } body = {"mobiles": [mobile]} resp = requests.post(url, headers=headers, json=body, timeout=10) data = resp.json() user_list = data.get("data", {}).get("user_list", []) if user_list: return user_list[0].get("user_id") return None

获取user_id之后,在卡片的content里直接拼文本:

**失败模块:** 下单流程 <at id=ou_xxxxx></at>

这是个扩展用法,需要应用有通讯录权限,以及对应的tenant_access_token。如果公司飞书后台管得严,权限不一定能申请下来。但如果你能做到,体验确实更好——测试挂了直接找到责任人头上。

4.3 防止消息刷屏:节流和频率控制

定时任务跑多了以后会遇到另一个问题:如果每天推送太多,群里全是机器人消息,很容易被成员静音。我后来加了一个节流逻辑:如果当天测试结果跟上次的通过/失败统计完全一致,就不发卡片,只在结果有变化时推送。这样既保留了“异常时告警通知”的价值,又避免了每天毫无变化还反复刷屏。

另外,飞书自定义机器人本身有频控。并发发送或者短时间内连续发送多条,会收到频率限制的错误码。所以在send函数里可以加一个简易的重试机制,遇到频率限制时退避重试。但退避时间要合理,别把任务卡死。

5. 真实踩坑记录:签名失败、长度限制、图片失效

这些坑是我在实际使用中碰到的,每一个都有血泪教训在里面。整理成表格,方便大家直接对照。

问题现象根本原因解决方案
飞书返回签名校验失败timestamp使用了毫秒级时间戳,或签名串拼接格式不对统一使用秒级时间戳,签名串严格按照"{}\n{}".format(timestamp, secret)生成
卡片一直显示加载中card结构不符合最新的消息卡片格式规范检查msg_type是否为interactive,并用飞书官方调试台校验JSON
文本内容发不出去单条消息内容超过飞书限制,会被整体拒绝对长文本做截断,只保留关键指标;完整日志走报告地址跳转
按钮点了没反应没有配置url,或action类型设置错误检查button的url字段是否完整可用,埋点日志是否正常
发送成功了但群里没消息Webhook地址拼错或者机器人被移出群先curl验证Webhook,再检查是否是群权限变动
中文出现乱码或问号没有正确设置Content-Type为application/json使用requests的json参数,不要手动构造字符串,requests会自动处理编码

除了表格里的问题,还有一个值得说的坑是:飞书对于文本标签里的Markdown语法支持非常“看心情”。你在本地Markdown编辑器里好好的表格语法,发到卡片里可能直接原样输出。我最终得出的结论是:卡片文本里只用加粗、超链接、换行这三种基础语法,其它的不抱期望就不会失望。

5.1 长文本截断的策略

自动化测试的报告往往很长,尤其是失败日志堆叠起来,动辄几万字。千万不要直接把整个报告内容塞进卡片。我的截断策略很简单:头部保留summary信息,中部只截取前N条失败用例的描述,尾部加上“更多详情请查看完整报告”。这样做既保证了关键信息不丢失,也避免触发飞书的内容长度限制。

5.2 图片如何发送到卡片

有人想在卡片里放趋势图、统计图、截图,飞书卡片确实支持img元素。但img元素里的img_key不是随便填一个图片URL就行,需要先调用飞书的上传图片接口,拿到image_key之后才能引用。这个流程稍微复杂,而且upload接口通常需要bot具备相关权限。如果你的测试环境能拿到tenant_access_token,可以在生成卡片前先上传图片再拿key。如果拿不到权限,就别在这个功能上死磕,把图片放到报告页面里,通过按钮跳转过去看,反而省事。

6. 几个容易被忽略但很重要的设计决策

这部分讲一些我在设计整套方案时反复权衡过的点,也算是对踩坑经验的提炼。

6.1 为什么用卡片而不是直接发text

text消息最大的问题是信息密度低,无法突出层级。测试报告本质上是一个有优先级的告警:全绿可以低调处理,有失败必须醒目。卡片可以通过颜色、字体大小、模块分布来呈现这种优先级。我用了两个多月以后,团队成员已经养成了“看颜色定心情”的习惯,群里的沟通效率比之前好了很多。

6.2 关于报告链接的设置

报告地址如果写死在内网IP或域名,换环境后很容易失效。我建议在代码里把report_url做成可配置项,通过环境变量或者配置中心下发。这样测试环境、预发布、生产环境可以指向不同的报告地址,互不干扰。

另外,如果报告页面是后端服务临时生成的,还要考虑过期时间。我遇到过机器人发的报告链接两天后失效的情况,临时用Python起的HTTP服务重启后就没了。后来我把报告归档到固定目录,由Nginx静态代理,地址就稳定多了。

6.3 重试机制怎么设计才算合理

自动化任务的网络环境并不总是稳定。requests发飞书如果超时,最好做一个简单的重试。但重试要注意两点:一是设置最大重试次数,二是记录日志。因为签名里的timestamp每次都要重新生成,重试时不能复用旧的timestamp。这些边角细节处理好了,整个脚本才真正皮实。

我在最终版本里采用了一个折中方案:遇到网络异常重试2次,每次间隔3秒;遇到业务错误码(比如签名错误、参数错误)直接抛出异常,不重试。因为网络错误可能是瞬时抖动,重试有意义;业务错误重试一百次结果都一样,还不如把异常曝光出来快速定位。

7. 已有的扩展思路:从单点通知到测试运营体系

这套方案跑通之后,我又陆续基于它做了几个扩展,这里一并分享出来,算是给想继续深挖的朋友一个方向。

第一个方向是把历史结果存下来。我写了个简单的SQLite表记录每次推送的统计数据,时间一长就能做趋势分析——“最近两周的通过率变化”一目了然。再进一步,可以用matplotlib画一张折线图,如果机器人有上传图片权限,直接以图片形式发到群里,效果拔群。

第二个方向是接更多的消息源。飞书群机器人不只可以收测试报告,devops流水线的构建状态、定时爬虫的任务状态、线上监控的告警信息,全都可以统一成同一套卡片模板。这样群里面各种事件的通知风格一致,看起来整洁,维护起来也方便。

第三个方向是人工确认。卡片按钮除了跳转链接,还能发送交互回调。你可以把“确认发布”“同意上线”这类操作做成按钮,让指定人员在飞书群里完成审批动作。这个需要申请应用能力以及配置回调地址,工作量明显上了一个台阶,但是想象空间也大了很多。

回到最初的出发点——自动化测试报告的最大价值,不在于生成一份精美的HTML文档,而在于它能以最合适的形式抵达最该看到它的人。飞书消息卡片只是我找到的一个比较顺手的方式,但底层那套“把数据变成结构化展示内容再分发给正确的人”的思维,放到任何平台都一样实用。

如果你正在做类似的自动化测试通知方案,我的建议是:先把最简单的Webhook文本跑通,再逐步升级到卡片,最后根据团队的反馈不断调整信息结构和推送策略。别一开始就想着把所有功能都怼上,先让团队形成看消息的习惯,才有继续打磨的意义。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询