做RPA开发的人迟早会遇到一个尴尬时刻:自动化流程跑得挺欢,到最后一步要调AWS SES发结果邮件,测试时就傻眼了——总不能真的给用户邮箱发垃圾邮件吧?更别提邮件发出去了之后,你根本没法验证内容是否正确、收件人有没有搞错。我处理这类问题的方式,是把RPA-Python里的邮件发送逻辑和pytest-aws-ses这个pytest插件整合在一起,在测试环境里拦截所有SES发送请求,再把捕获到的邮件当成断言对象,整套AWS SES测试自动化就闭环了。
这套方案解决的核心问题,是让RPA中的SES邮件发送链路可验证、可回归、可自动化。它适合正在用Python写RPA任务、经常要发通知邮件的人,也适合给邮件发送场景做持续集成的测试开发。整套方案不需要搭本地全套AWS模拟环境,pytest和boto3就够了,几分钟就能把拦截跑起来。下面我会从设计思路、环境搭建、核心机制到完整实操和排障,把整个落地过程掰开揉碎讲清楚。
1. 项目背景与整体设计:RPA邮件链路的测试痛点
1.1 定时报表与异常告警:RPA里最典型的SES场景
我维护过一个模拟项目X,核心逻辑是每天定时跑批:凌晨从业务系统拉取前一天的数据,做清洗和汇总,最后用HTML模板把统计结果拼成邮件正文,通过SES发出去。除了这种定时报表,还有一条异常告警链路——当跑批任务重试多次仍然失败时,要把错误堆栈和影响范围发给值班人员。
这套链路看起来简单,但实际维护起来问题非常多。模板调整是最容易出事的点,某个字段拼错、收件人名单配错、小语种字符乱码,这类bug几乎不会在预发环境暴露,往往要等到真实用户收到邮件、截图反馈之后,研发才知道出了问题。更麻烦的是,SES发出的邮件没有回滚机制,一旦发出就是泼出去的水,你只能发更正邮件补救。
所以真正的问题不是“能不能发”,而是“发了之后怎么验证”。手动测试的做法,是准备一个测试邮箱,跑一次任务、收一次邮件、肉眼看一遍格式。这种做法既慢又不可靠,换一次模板就要重复一轮,而且没法自动化,更没法防止回归。
1.2 选pytest-aws-ses而不是全套AWS本地模拟
解决这个问题,常见的路子有三条。第一条是搭一个完整的本地AWS模拟服务,把SES、S3、SQS全部模拟起来,通过改endpoint_url接入测试环境。第二条是用一个通用的AWS mock库,在测试代码里mock掉boto3对SES的调用。第三条就是我最后选用的方案:直接用pytest-aws-ses这个插件,它不需要起服务端进程,也不需要自己手动编写mock逻辑。
pytest-aws-ses的做法很讨巧:它接管boto3创建SES客户端的逻辑,让所有send_email调用不再走真实网络请求,而是在内存里维护一份已发送邮件的消息列表。测试用例声明一个叫ses_notification的fixture参数,就能拿到这份列表,然后对报文内容做断言。
我放弃第一条路的原因是重量级。RPA的机器上往往同时跑着一堆Python脚本,为了测一个邮件发送功能去维护一套完整的AWS模拟服务,环境变量、endpoint配置、容器编排都要跟着改,得不偿失。第二条路虽然灵活,但要自己写大量mock样板代码,在几十个测试用例里重复维护mock对象,成本也不低。pytest-aws-ses把SES这个场景直接封好了,代码量最小,也最符合“只测试自己关心的那一环”的原则。
1.3 测试自动化的核心思路:拦截代替发送
这套方案底层逻辑其实就四个字:拦截代替。生产环境里,业务代码通过boto3.client("ses")创建真实客户端,调用send_email时请求会走AWS网络链路。pytest-aws-ses则在pytest会话启动时把这个客户端替换成替身,send_email调用不再发往AWS,而是把完整请求体追加进一个列表。
测试代码关心的是“我的RPA逻辑有没有构造出正确的邮件请求”,而不是“AWS有没有真的把信送达”。后者涉及DNS、SPF、DKIM、送达率,这些不该由单元测试负责。pytest-aws-ses把关注点精确落在“邮件内容对不对、收件人齐不齐、发送顺序稳不稳”上,同时保证测试无网络IO、毫秒级返回、绝对安全,不会把测试邮件发到真实用户那边。
我一开始不太确定它是不是真的把网络调用拦截了,专门写了个用例抓包看了一眼,确认全程没有外网请求才放心。后面给团队推广这套方案时,大家担心的也主要是这一层,拿实际测试结果说话比讲原理有效得多。
2. 环境准备与RPA发信模块初始化
2.1 pytest-aws-ses环境搭建:两个安装命令搞定
建议所有RPA相关依赖都放进虚拟环境,不要直接装系统Python。RPA的机器上经常同时装着各种运维脚本、调度框架的依赖,直接pip install到全局,很容易出现版本互踩,今天修好一个包明天又坏了另一个。创建虚拟环境的方式:
python -m venv .venv
source .venv/bin/activate
pip install pytest pytest-aws-ses boto3
这里有三样东西缺一不可:pytest负责测试发现和执行,pytest-aws-ses负责拦截SES调用,boto3是业务代码创建SES客户端的SDK。插件本身依赖pytest和boto3,装完可以用两个命令确认版本:
pytest --version
pip show pytest-aws-ses
装好之后,可以先跑一个空的测试文件确认pytest能正常发现用例,比如创建一个test_smoke.py,里面放一个什么都不做的用例。这一步虽然简单,但能尽早排除依赖安装不完整、pytest配置混乱等环境问题,避免后面写了大堆测试用例却发现跑都跑不起来。
2.2 MailSender解耦写法:给client留一个注入入口
环境就绪后,第一步不是写测试,而是审视业务代码结构。RPA的邮件发送逻辑我建议封装成一个独立的发信模块,核心点在client对象的构造方式。下面是一个常见的封装写法:
# mail_sender.py
import boto3
class MailSender:
def __init__(self, client=None):
self.client = client if client is not None else boto3.client("ses", region_name="us-east-1")
def send_result_email(self, to_addresses, subject, html_body):
return self.client.send_email(
Source="no-reply@example.com",
Destination={"ToAddresses": to_addresses},
Message={
"Subject": {"Data": subject, "Charset": "UTF-8"},
"Body": {"Html": {"Data": html_body, "Charset": "UTF-8"}},
},
)
关键在__init__函数:允许外部传入client,不传时再用boto3创建真实客户端。生产环境调用MailSender()时不需要传参,默认拿到的就是真实boto3 client;测试环境里pytest-aws-ses会自动拦截boto3.client调用的返回对象,所以仍然可以按默认方式构造。这个设计叫做依赖注入,它最大的价值是让业务代码不感知测试环境的存在。
很多RPA项目会把boto3.client("ses")直接写在任务脚本的main函数里,后面想测试只能干瞪眼。我的经验是,凡是和外部系统打交道的对象,创建入口都要设计成可以替换的形式。不需要为了测试写复杂的抽象层,一个默认参数就够了。等出问题想重构的时候就会发现,这个小小的入口能省掉无数改代码的时间。
3. 核心机制拆解:ses_notification fixture如何捕获邮件
3.1 从一次send_email请求能拿到什么
pytest-aws-ses把拦截到的消息保存成与boto3请求参数一致的结构,也就是说,你传给send_email的Source、Destination、Message这些字段,拿到时就是什么样。写第一个测试用例时,最直接的办法是打印出来看:
def test_peek_captured_message(ses_notification):
sender = MailSender()
sender.send_result_email(
["ops@example.com"],
"每日报表运行结果",
"<html><body><h1>完成</h1><p>本次处理1200条记录</p></body></html>",
)
print(ses_notification)
运行后如果用例通过,屏幕上会打印出类似下面的结构:
[{
'Source': 'no-reply@example.com',
'Destination': {'ToAddresses': ['ops@example.com']},
'Message': {
'Subject': {'Data': '每日报表运行结果', 'Charset': 'UTF-8'},
'Body': {'Html': {'Data': '<html>...</html>', 'Charset': 'UTF-8'}}
}
}]
这个结构和send_email的请求参数完全对齐,所以断言非常直观。比如断言收件人,就取msg["Destination"]["ToAddresses"];断言主题,就取msg["Message"]["Subject"]["Data"];断言正文,就取msg["Message"]["Body"]["Html"]["Data"]。
有一点需要注意,不同版本的pytest-aws-sesfixture名字可能略有差异,一般来说是ses_notification,但如果你装的版本里不是这个名字,或者想确认还有没有其他可用fixture,可以直接在项目根目录跑pytest --fixtures查看插件注册了哪些fixture。我见过有同事装完插件不看文档,照着老版本博客写脚本,结果fixture名字对不上,折腾了半个下午。
3.2 function作用域与测试数据隔离
pytest-aws-ses的拦截列表默认是function级别的fixture,意味着每个测试函数开始前会拿到一个干净的列表,测试结束后自动清理。这个设计在RPA场景里非常重要,因为邮件是一批一批发的,如果上一条用例的发送记录还留在这个列表里,后面用例对数量的断言就会立刻出错。
我自己验证过,默认情况下两个相邻测试用例之间的邮件列表是完全隔离的。第一个用例发了3封,第二个用例跑完后看到的仍然只是自己发的那几封。这个特性让测试case之间的依赖降到了最低,你可以放心地在一个测试文件里写十几个用例,完全不用手动清理。
但要注意一点:不要在conftest.py里重新定义同名fixture并改成session级别。一旦改成session级别,整个测试会话共用一个列表,所有用例的邮件都会堆积在里面,前面用例断言时改动了列表内容,后面用例再拿到的就是被污染过的数据。如果你确实想跨用例共享某些数据,也建议用独立的辅助fixture管理,不要动拦截列表本身的作用域。
3.3 拦截范围边界:哪些调用会被捕获
这个插件主要拦截的是boto3 SES客户端的send_email调用。如果你的RPA代码走的是send_raw_email,就是另一种请求格式了,payload结构完全不同,需要确认当前插件版本是否覆盖,如果覆盖不了,建议把业务代码统一收敛到send_email。从我的实践经验看,大部分RPA通知场景用send_email加HTML模板就够了,send_raw_email主要是在需要构造完整MIME消息、携带附件时才会用。
另一个边界是endpoint_url。如果某个环境里为了对接本地模拟服务,给boto3.client传了endpoint_url参数指向自建地址,pytest-aws-ses的拦截逻辑是否还能生效需要实测验证。稳妥的做法是,RPA业务代码不要自己配置endpoint_url,让环境差异统一交给测试框架处理。生产环境就默认走真实AWS,测试环境就依赖插件拦截,不要在业务层混入环境相关的判断逻辑。
4. 实操:从RPA业务代码到SES测试自动化落地
4.1 第一步:把RPA主流程里的邮件调用抽成独立函数
很多时候RPA主脚本是线性写下来的:拉数据、洗数据、渲染模板、发邮件。发邮件可能只是文件末尾的几行代码,这几行代码测试起来却最麻烦。我的建议是,先把这几行抽成一个独立的业务函数,比如:
def send_finish_notice(recipients, stats):
html = (
"<h2>跑批完成</h2>"
f"<p>任务ID: {stats['task_id']}</p>"
f"<p>成功条数: {stats['success_count']}</p>"
f"<p>失败条数: {stats['failed_count']}</p>"
)
return MailSender().send_result_email(recipients, "跑批完成通知", html)
抽出来之后,测试对象就从“整个跑批任务”变成了“一个发信动作”。通不通过测试,取决于这个函数在给定输入下能不能构造出正确邮件。生产环境的RPA主流程直接调用send_finish_notice,测试环境也一样调用它,两边共用同一套代码逻辑。
抽取过程中会暴露一个技术债:如果原来的业务代码里直接import boto3、在函数内部创建client,这个函数在测试环境下就没法被pytest-aws-ses自动拦截,因为插件接管的是boto3客户端构造函数,而不是从外部替换业务模块里的client变量。这也是为什么我一直强调,发邮件时一定用MailSender这个封装类,而不是散落在各处的原始boto3调用。
4.2 第二步:写第一条能捕获邮件的测试用例
核心步骤是声明ses_notification参数,然后在用例体内调用被测函数,最后对列表内容做断言。一个完整可运行的测试文件长这样:
import pytest
from mail_sender import MailSender
def test_report_email_body_contains_key_stats(ses_notification):
sender = MailSender()
sender.send_result_email(
["data@example.com"],
"数据报表 2025-04-01",
"<p>成功 100,失败 2,耗时 3.5s</p>",
)
assert len(ses_notification) == 1
msg = ses_notification[0]
assert msg["Source"] == "no-reply@example.com"
assert msg["Destination"]["ToAddresses"] == ["data@example.com"]
assert msg["Message"]["Subject"]["Data"] == "数据报表 2025-04-01"
assert "成功 100" in msg["Message"]["Body"]["Html"]["Data"]
注意fixture的写法:ses_notification必须出现在测试函数的参数列表里,pytest才会去实例化它。如果只是import了插件但没把这个fixture写进参数,拦截逻辑不会主动启动,测试跑完看到列表长度是0,就会误以为插件没生效。
我建议第一个用例尽量简单,只验证“捕获列表里有1封邮件”这个基本行为,先跑通再扩展业务断言。第一次跑通时看到列表里真的有一封完整的邮件结构,心里就有底了,后面加断言都只是查字典取字段的事。
4.3 第三步:校验动态内容时用正则而非全等断言
RPA邮件正文里经常包含动态数据,比如任务ID、处理条数、耗时时间。这类字段如果写成绝对相等断言,测试会非常脆弱,数据一变测试就红,甚至会让团队慢慢放弃运行测试。更好的方式是针对动态字段提取模式做断言:
import re
def test_report_email_has_valid_task_id(ses_notification):
sender = MailSender()
task_id = "TASK-20250401"
sender.send_result_email(
["ops@example.com"],
"跑批完成",
f"<p>任务ID: {task_id}</p>",
)
html = ses_notification[0]["Message"]["Body"]["Html"]["Data"]
match = re.search(r"TASK-\d{8}", html)
assert match is not None
assert match.group(0) == task_id
这里断言的思路分两层:先用正则确认任务ID格式符合预期,再和传入值做精确比对。相比直接对整段HTML做全等断言,这种方式更贴近“验证模板正确性”的本意。模板里的静态文案用子串包含断言,动态字段用正则提取后断言,两者结合才是比较合理的验证策略。
动态字段还有一个隐含风险:如果RPA代码在拼接HTML时漏掉了某个占位变量,第一版测试用全等断言可能碰巧通过,但换一天的数据就立刻暴露。正则断言因为不依赖具体数值,反而能稳定地抓住“格式对没对”这个关键问题。
4.4 批量发送场景的断言顺序
RPA场景里经常出现一Job发多封邮件的情况,比如先给管理员发汇总,再给各个业务组发明细。pytest-aws-ses的捕获列表是有序的,严格按发送顺序追加。这个特性可以用来验证发送顺序有没有乱:
def test_multiple_emails_are_sent_in_order(ses_notification):
sender = MailSender()
sender.send_result_email(["admin@example.com"], "管理员汇总", "<p>admin</p>")
sender.send_result_email(["sales@example.com"], "销售组明细", "<p>sales</p>")
sender.send_result_email(["support@example.com"], "客服组明细", "<p>support</p>")
assert len(ses_notification) == 3
recipients = [
msg["Destination"]["ToAddresses"][0]
for msg in ses_notification
]
assert recipients == [
"admin@example.com",
"sales@example.com",
"support@example.com",
]
这段断言看起来简单,但它能挡住一类很典型的回归bug:业务方改动发送顺序后,各团队收到的邮件内容与预期不一致。比如把客服组明细错发给了销售组,这种错误靠肉眼看真实邮件可能一天都发现不了,自动化测试里一条列表对比就能秒级暴露。
顺序断言也不是所有场景都适用。如果RPA代码用了多线程并发发送,几个线程的完成顺序不确定,捕获列表的追加顺序就不稳定,这时候断言“谁先谁后”就没意义了,应该改为断言“每个收件人都收到了对应主题的邮件”,用收件人做维度去分组校验。并发场景的处理在下一章细说。
5. 常见问题与排查技巧实录
5.1 捕获列表为空是怎么回事
捕获列表为空是最常见的初学问题。第一个要查的点,是测试函数参数里有没有写ses_notification。pytest的fixture只有作为参数注入时才会被激活,只import插件但不用fixture,拦截逻辑不会生效。我在第一次用这个插件时就踩过这个坑,一度以为是插件没装好,后来在别人的代码里看到fixture出现在参数列表里,才反应过来。
第二个要查的点是调用方法。确认被测函数用的是send_email而不是send_raw_email,如果走的是send_raw_email,报文结构和拦截范围都可能完全不同。第三个排查技巧是把测试函数临时改成print(ses_notification),然后用pytest -s运行,直接看拦截列表里实际有什么数据,这比反复猜测快得多。
还有一种情况:邮件发送发生在子线程或异步任务里,主线程跑到断言时子线程还没执行到send_email。这时候先确认异步任务真正完成再断言,最简单的办法是改用同步调用跑通测试,之后再做并发优化。
5.2 多线程批量发信时的竞态问题
RPA里有时会用ThreadPoolExecutor批量给几十个收件人发通知。并发场景下,pytest-aws-ses的拦截列表会由多个线程同时追加数据,列表本身append是线程安全的,但主线程的断言和子线程的写入之间存在时序竞态。如果你在提交线程池任务后立刻断言,大概率拿到的列表是空的或者不完整。
稳妥的做法是先等所有future完成再断言:
from concurrent.futures import ThreadPoolExecutor, wait
def test_concurrent_send_wait_all(ses_notification):
sender = MailSender()
recipients = ["a@example.com", "b@example.com", "c@example.com"]
with ThreadPoolExecutor(max_workers=3) as executor:
futures = [
executor.submit(
sender.send_result_email,
[addr],
f"通知 {addr}",
"<p>content</p>",
)
for addr in recipients
]
wait(futures)
assert len(ses_notification) == 3
wait之后,理论上所有send_email都执行完了,但线程调度偶尔仍有延迟。追求稳定性的话,可以做一个带超时的重试轮询,比如每0.1秒检查一次列表长度是否达到预期,超过3秒再报错。这种方式写起来稍显繁琐,但在真实并发场景里能省去不少偶发失败。我的建议是,测试用例优先同步直连,只有当你明确在测并发逻辑本身时,才去处理这类时序问题。
5.3 测试间数据污染与fixture作用域
如果你发现两个用例之间邮件列表相互串数据,最可能的原因就是fixture作用域被改过了。默认function级别是最安全的,每个用例都有独立列表。有人为了让测试少写几行代码,会把fixture改成session级别,导致所有用例共用一个列表,一旦前面用例修改了列表内容,后面的用例拿到的就是脏数据。
排查时在项目根目录跑pytest --fixtures -v,能看到当前插件实际生效的fixture定义。如果发现conftest.py里出现了一个同名fixture覆盖默认实现,直接删掉自定义部分,恢复function作用域。还有一点,如果你自己写的辅助函数内部访问了ses_notification,确保这个辅助函数是在测试用例体内调用的,而不是在模块导入时执行的,否则同样会拿到空列表或过期数据。
5.4 排查技巧与避坑清单
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 捕获列表为空 | 测试函数参数没写ses_notification | 把fixture加到函数参数列表 |
| 捕获列表为空 | 业务代码调用的是send_raw_email | 改走send_email或确认插件是否支持 |
| 捕获列表为空 | 发送发生在子线程,主线程断言过早 | 使用wait等待异步任务结束 |
| 测试间互相污染 | fixture被覆盖成session级别 | 检查conftest.py,恢复function作用域 |
| 断言内容失败 | 动态字段用了全等断言 | 改用子串包含或正则提取断言 |
| 本地通过CI失败 | 依赖版本不一致 | 用requirements.txt锁版本 |
这条清单是我在实际维护过程中一点一点攒出来的。每个坑都对应一次线上回归或一次深夜排查,列在这里不是为了凑篇幅,而是希望后来者能直接绕开这些弯路。工具本身的用法很简单,复杂的是工具和RPA业务代码之间那些细碎的交互边界。
最后说一个我自己的体会。这套集成跑通之后,测试本身的难度其实很小,真正麻烦的是RPA业务代码一开始的设计。我踩过最大的坑,就是在RPA任务里直接new了一个boto3客户端,临时想写测试时才发现到处都找不到可以替换mock的入口。如果你现在刚开始写RPA的邮件通知,我强烈建议第一行代码就把client设计成可注入——哪怕生产环境永远传None,这个入口也一定要留着。测试框架是最后一层保障,代码结构才是自动化能长期跑下去的根基。
本文介绍如何利用pytest-aws-ses插件在RPA场景中实现AWS SES邮件发送的无网络、可断言、可回归的自动化测试。核心方案是拦截boto3.send_email调用,将请求体捕获至内存列表,通过ses_notification fixture进行内容校验,支持动态字段正则断言、批量发送顺序验证及多线程竞态处理。方案无需本地AWS模拟服务,仅依赖pytest与boto3,显著提升RPA邮件链路的测试覆盖率与CI稳定性。


被折叠的 条评论
为什么被折叠?



