恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
基于pytest与aiohttp的异步接口自动化测试框架设计与实践
首页
资讯中心
/
基于pytest与aiohttp的异步接口自动化测试框架设计与实践
基于pytest与aiohttp的异步接口自动化测试框架设计与实践
发布时间:2026/10/10 8:35:28
1. 为什么最终自己搭了这个框架现成工具的账算明白了再动手先说结论市面上能直接拿来跑的接口自动化方案不少但真正推到“全量回归每周跑三遍、接口从两百涨到两千、报告要让产品和领导都愿意看”这个阶段的时候大部分方案都会透出捉襟见肘的疲态。最典型的是Postman。Postman做手工调试和冒烟测试非常好用Collection Runner可以跑集合Newman也能集成到CI里但问题出在用例的组织和维护上。一旦接口数量上来脚本的断言逻辑会散落在各个Request里环境变量和全局变量的作用域是全局打通的团队多人协作时非常容易互相覆盖。更麻烦的是Postman的断言语法虽然简单但写多了以后代码复用基本靠复制粘贴随之而来的是测试数据和测试逻辑的耦合回归批次里经常出现改A接口导致B接口断言失效的情况。JMeter则是另一个极端。它的强项是性能压测但拿来做功能性的接口自动化是别扭的JSONPath断言写起来不够直观前置后置处理器逻辑复杂聚合报告和HTML报告的可读性对业务人员极不友好而且脚本文件是jmx的XML格式Git diff基本没法看。团队里如果一直用JMeter维护接口回归集到最后就是没人愿意碰那些几千行的jmx文件。Java方向的方案我身边也有人用RestAssured加TestNG加ReportNG这套组合本身没有问题但对于一个纯后端接口数量在几百个的团队来说维护成本有点高动不动就要写POJO改一次接口定义就要重新编译迭代节奏会被拖慢。至于为什么不直接用requests写个无框架的脚本库——那只能叫脚本集不叫框架因为用例组织、环境切换、断言演进、报告沉淀这些事最终还是要回到一套可约束的规则上来。所以我当时定下的核心需求是这么几条用例必须数据驱动尽量少写代码运行要有并发能力不然几百个接口串行跑太浪费时间报告必须结构化不能让领导看到一堆红绿点就完事用例的编写和维护成本要足够低最好接口定义更新时能自动生成一部分用例。筛选下来pytest加allure负责组织和报告aiohttp负责HTTP请求层用例自动生成器负责从接口定义文件里渲染出可直接运行的pytest用例代码——四条链路刚好扣上。2. pytest作为执行引擎的调度与数据驱动设计2.1 conftest里做了哪些全局初始化pytest的conftest.py是整个框架的骨架。我在设计的时候没有把session级别的fixture放在测试用例文件里而是统一收敛到顶层conftest中通过作用域来控制生命周期。# conftest.py import asyncio import aiohttp import pytest pytest.fixture(scopesession) def event_loop(): loop asyncio.new_event_loop() yield loop loop.close() pytest.fixture(scopesession) async def client(env, event_loop): conn aiohttp.TCPConnector(limit50, ttl_dns_cache300) session aiohttp.ClientSession( connectorconn, timeoutaiohttp.ClientTimeout(total15), ) yield session await session.close() pytest.fixture(scopesession) def env(): # 通过命令行参数指定测试环境 return load_env_config()这里有一个值得注意的设计要点ClientSession不能给每个用例单独创建。原因其实就是连接池的复用问题——aiohttp的session内部维护了连接池如果每个用例都新建session再关闭TCP连接就会频繁建立和销毁在高并发场景下不仅慢还会把文件描述符打满。所以我把session设置成session级别的fixture全量用例共享一个连接池。event_loop这门课程也是必须的。pytest-asyncio在较早版本中要求提供事件循环fixture即使你用的是pytest-asyncio的新版本显式声明一个session级的event_loop仍然是最稳妥的它能避免在分布式执行或者和xdist配合时事件循环冲突的问题。这个坑我后面专门讲。2.2 数据驱动的三种落地方式接口自动化的用例和普通单元测试最大的区别在于大多数用例只是输入、输出和断言的组合没必要每个用例都手写一个函数体。我在框架里同时支持了三种数据驱动方式按场景取舍。第一种是纯参数化方式适合接口少、参数组合也不复杂的场景。用pytest的parametrize装饰器直接罗列输入和期望输出pytest.mark.asyncio pytest.mark.parametrize(mobile,expected_code,expected_msg, [ (13800000000, 0, success), (123456, 10001, invalid mobile), ]) async def test_register_by_param(client, mobile, expected_code, expected_msg): resp await client.post(/api/v1/register, json{mobile: mobile}) body resp.json() assert body[code] expected_code assert body[msg] expected_msg这种方式的好处是直观适合新来的同学快速理解。缺点是一旦参数组合到了几十组测试用例文件会变得非常臃肿维护成本直线上升。第二种是外部数据文件方式把用例数据放在JSON或者YAML文件里用fixture读取并展开。这种方式适合接口参数比较多、用例之间按场景分组的场景。# test_cases/register_case.json [ { name: 正常注册, payload: {mobile: 13800000000, code: 1234}, expected: {code: 0, msg: success} }, { name: 验证码错误, payload: {mobile: 13800000000, code: 9999}, expected: {code: 10002, msg: verify code error} } ]第三种方式是配合用例自动生成器来走后面第三章专门说。它的优势在于不需要手工维护测试数据文件接口定义一变用例就跟着更新适合接口数量多、迭代快的项目团队。2.3 并发执行与标签过滤接口回归测试最容易碰到的问题就是用例多了以后越跑越慢。我见过一个团队用requests框架跑600个接口串行要40分钟每次发版前跑一次全量都在等结果。用aiohttp异步改造后同一个环境里并发控制在50以内全量回归能压进3到5分钟。但并发执行在pytest里要区分两种模式。第一种是协程内的并发。也就是fixture返回的session是同一个用例本身是异步函数在同一个事件循环里并发调度。这种模式下你不需要额外装插件只要pytest-asyncio在正常处理异步用例就行。它会为每个异步测试函数创建task事件循环负责调度。第二种是进程级的并发。用pytest-xdist通过-n auto把用例分发到多核进程跑。接口自动化和UI自动化不太一样它没有浏览器这种重资源所以进程级并发带来的收益相对小而且会带来session级fixture在每个worker里重复初始化的副作用。我实测下来的结论是如果压测目标只是让回归时间可控优先做协程内并发只有当接口测试还要承担部分性能探测职责时才考虑结合xdist做进程级分发。标签过滤这块我强烈建议在框架里预设好用例分级。用pytest的mark机制给用例打上smoke、core、full、bugfix标签日常调试只跑-m smoke提交前跑-m core夜间定时任务才跑全量。这样既保证了反馈速度又不会因为耗时太长导致大家懒得跑回归。3. 用例自动生成从接口定义到可直接运行的pytest用例3.1 生成器的输入源设计这可能是整个框架里最有工作量、也最容易被做烂的模块。所谓用例自动生成最大的难点不是“能生成”,而是“生成出来的东西能持续维护不会变成一堆一次性垃圾代码”。我做这个模块时的输入源选的是接口定义文件。团队维护的接口文档一般有两种形态一种是OpenAPI/Swagger导出的json或yaml另一种是自己定义的接口描述文件。如果你的项目有现成的Swagger优先解析Swagger字段信息、参数类型、必填与否都是现成的。如果还在手工维护接口文档那就需要定一个自己团队的接口描述规范用YAML来描述每个接口的请求、断言和取值逻辑# apis/user_api.yaml name: 获取用户信息 path: /api/v1/users/{userId} method: GET params: userId: source: from_previous_step value: parse_token_user_id headers: Authorization: Bearer ${token} extract: - name: user_name selector: $.data.name asserts: - type: eq selector: $.code expected: 0 tags: [core, smoke]你可能会问为什么要定义输入文件而不是直接从代码里读取原因很简单——负责写接口定义的通常是后端开发测试框架的用例生成器需要的是“后端修改接口定义后测试同步更新”这条链路能跑通。“接口定义文件”是前后端合作的契约围绕契约来做用例生成职责边界最清晰。3.2 模板渲染的要点用例生成器本质上是个代码生成器。我最开始想直接用字符串拼接来生成用例代码写了几天就放弃了——只要接口参数一复杂字符串拼接里的引号、缩进、特殊字符全在挑战耐心。后来换成了Jinja2模板用模板来渲染用例代码稳定性和可读性立刻上来了。from jinja2 import Environment, FileSystemLoader env Environment( loaderFileSystemLoader(generator/templates), keep_trailing_newlineTrue, autoescapeFalse, ) def render_test_case(api: dict) - str: tpl env.get_template(test_api_case.py.j2) return tpl.render(apiapi)模板里面有几个关键变量测试函数名、请求的目标URL、请求方法、headers拼接、断言代码、allure特性标签。函数名要保证唯一我采用的规则是test_{模块}_{接口名}接口名用下划线代替驼峰避免生成函数命名冲突。模板里的内容要兼顾两点生成出来的代码必须符合pytest的收集规则至少函数名要以test_开头代码还应该有足够的可读性因为团队成员肯定会打开生成的用例文件去看请求参数和断言逻辑不能生成一堆看都看不懂的天书。3.3 生成流程中不可省的三个环节接口定义文件会是长期演进的状态生成器如果是“每次跑测试前重新生成全部用例”一定会引发三个问题一是变更检测缺失导致每次改接口定义后都要全量覆盖代码提交历史乱成一锅粥二是无用用例堆积老接口删除了但生成用例还在三是有手工调整过的定制用例被覆盖。我的处理方式是引入一个“生成-对比-覆盖”的流程。接口定义文件里面加一个version字段生成器会维护一个manifest文件记录每个接口定义对应的生成用例的哈希值。每次运行前先计算当前接口定义文件的哈希和manifest中记录的哈希做比对只有发生变化的部分才重新生成。这样git diff里看到的改动就只是真实受影响的用例而不是全量文件变动。第二个环节是幂等性检查。同一份接口定义文件重复执行生成器生成的用例文件必须一致。这里需要特别注意字典的遍历顺序——Python 3.7之后dict才保持插入顺序但如果你的接口定义YAML经过多次编辑字段顺序可能变化生成出来的用例顺序就会变。解决办法是在写模板前对api字典做sort处理用sorted重新排列键值。第三个环节是产物校验。生成代码不能直接落盘要先编译检查。我用ast.parse对生成结果做语法树解析语法有错误就直接抛异常绝不产出坏文件。生成的用例还要经过一遍简单的静态检查比如断言模板替换后是否存在空的expected值避免出现assert_body[code] 这种半截代码。3.4 生成器与pytest收集器的配合这一节是整个自动生成模块的落地关键。pytest在收集用例的时候是按目录扫描文件、按函数名匹配规则收集的。如果你的生成器是在测试运行之前执行的那没有问题直接先跑生成器再跑pytest。但如果希望通过pytest命令一键完成生成器必须被挂到pytest的钩子流程里。我在conftest.py里用了pytest_configure钩子来触发生成# conftest.py def pytest_configure(config): if config.getoption(--disable-case-gen, defaultFalse): return run_case_generator()pytest_configure在pytest初始化配置阶段就会调用此时还没开始收集用例文件所以生成的用例文件能确保被后续的收集器扫描到。这个顺序问题我在前面踩过一次坑详见第六章。还需要注意一个细节生成器的运行日志要能被pytest捕获并显示在所有测试输出之前方便定位问题。我用了logging.getLogger(case_gen)配合pytest --log-cli-levelINFO查看生成情况平时默认只显示warning避免生产激动人心的源码生成过程淹没测试结果。4. aiohttp请求层封装异步并发与依赖参数提取4.1 为什么非要异步在决定用aiohttp之前我也曾经犹豫过要不要继续用requests。requests库毫无疑问是Python HTTP请求的事实标准接口测试里90%的代码用requests写都没问题。真正逼我换掉requests的不是功能缺失而是运行模型的差异。requests的请求是同步阻塞的你在函数里发一个请求线程就卡在那里等响应。如果一条测试用例里要串联调用两三个接口每个接口耗时300ms那么串行跑下来就是900ms如果你有100条类似的用例那就是90秒。而在aiohttp里协程可以在等待IO的时候让出控制权让同一个线程去处理别的请求——换句话说异步并发不是在“变魔术”式地同时发出100个请求而是把“每个请求等待IO”的这段空闲时间利用了起来。我的框架设计里aiohttp的封装只做三件事发请求、验状态、提变量。其他的业务逻辑全部由生成用例代码来组织。这样做的目的是确保请求层保持纯净不会被各类业务断言逻辑污染。4.2 请求器的边界重试、超时、连接池很多人封装请求层时动不动就把重试、日志、鉴权、加解密全塞进去最后这个类变得比业务代码还复杂。我踩过这个坑重构之后把请求器的边界梳理成了四块第一个是超时控制。aiohttp的ClientTimeout支持分阶段超时我设置了total15和connect5两个值。15秒是整体超时5秒是连接超时。如果接口本身逻辑就要跑十秒以上可以单独在用例里给这个接口重设超时全局配置的默认值只兜底。第二个是重试策略。不是所有失败都值得重试我只对网络层错误和5xx错误做重试4xx客户端错误一律直接抛失败。重试次数默认2次间隔用指数退避第一次重试等待0.5秒第二次等待1秒。关键是要注意重试幂等性——POST创建资源的接口如果网络层超时但服务端实际已经处理了重试可能造成重复创建。所以我默认对非幂等请求关闭重试或者要求调用方显式注明idempotentTrue。async def request(self, method, url, *, retries2, idempotentFalse, **kwargs): for attempt in range(retries 1): try: async with self.session.request(method, url, **kwargs) as resp: data await resp.json() return resp.status, data except (aiohttp.ServerConnectionError, asyncio.TimeoutError) as e: if attempt retries or not idempotent: raise await asyncio.sleep(0.5 * (2 ** attempt))第三个是连接池的并发控制。前面说的TCPConnector(limit50)这个limit相当于是“同时最多建立50个TCP连接”。如果不设置默认是无限连接当并发请求数量很大时操作系统的文件句柄会被耗尽。如果你的测试环境在同一台机器上建议limit的取值范围控制在20到100之间基于目标环境能够接受的吞吐来调整。第四个是请求日志。我在请求层默认输出一条结构化日志记录请求方法、路径、状态码、耗时。这些日志没进allure报告而是写到单独的文件里方便排查问题时对比时间线。有时候allure报告显示断言失败但你不知道请求到底发了多少次、重试了几次有这个日志文件就很容易定位。4.3 变量提取与用例间依赖的表达方式接口自动化的难处往往不在单个接口的测试而在于接口之间常有数据依赖登录接口返回token后续接口要用这个token创建订单接口返回orderId查询订单接口要用这个orderId。我在框架里设计了一套轻量级的变量池来做这件事。变量池是一个模块级的全局对象提供set_var(name, value)和get_var(name)两个方法。用例执行时通过request层的extract配置来提取响应的指定字段并存入变量池。因为aiohttp的并发特性这里要特别小心变量提取是写操作多个并发的用例同时写同一个变量名会发生覆盖。所以我在变量池内部用asyncio.Lock保证线程安全同一变量名的写入做一个简单的last-write-win策略同时支持scopemodule级别的命名空间隔离。class VariablePool: def __init__(self): self._vars {} self._lock asyncio.Lock() async def set_var(self, name, value): async with self._lock: self._vars[name] value async def get_var(self, name, defaultNone): async with self._lock: return self._vars.get(name, default)依赖顺序的问题也要正面处理。接口A依赖接口B的返回值如果用例并发运行这种依赖就会被打破。最稳妥的做法是给依赖链路相关的用例设置执行顺序优先级用pytest的pytest.mark.order插件或者用自定义的fixture依赖注入。我实测后发现用pytest-order比手写依赖逻辑省心很多它能在收集阶段就识别出用例顺序的环避免运行到一半才发现死循环。5. allure报告让自动化结果能直接拿去做缺陷分级5.1 装饰器与步骤语义很多团队用allure只是简单加个allure.feature报告出来了就是一片绿色出了问题也不知道到底是哪一步抽风。要把allure的价值发挥出来得理解它的一整套展示逻辑。allure报告里最顶层是feature功能模块下面挂story用户故事再下面才是具体的测试用例。在接口自动化场景里我一般让feature对应服务模块story对应接口名用例名就是具体的测试场景。这样报告里第一眼就能看到哪个模块挂了多少用例、失败集中在哪里。步骤的沉淀同样重要。aiohttp请求和响应数据比较大直接全部塞进allure会污染报告。我的做法是只在请求失败或断言失败时附加完整请求报文和响应报文成功的用例只记录一个简短的请求说明。这里用到了allure的attach机制import allure if status_code ! 200: allure.attach( bodyrequest_text, namerequest_payload, attachment_typeallure.attachment_type.TEXT, ) allure.attach( bodyresponse_text, nameresponse_payload, attachment_typeallure.attachment_type.TEXT, )另外关于allure.step它不只是好看还能在失败时把步骤链路展示出来。我对依赖提取环节、请求发送环节、断言校验环节分别加了step装饰一旦用例失败报告里能直接看到是请求失败还是断言写错还是变量提取失败。5.2 环境信息与历史趋势allure报告只在本机跑没有太大意义真正有价值的是把每次测试结果放到同一个面板上看趋势。这里有两个细节经常有人忽略。第一个是environment.properties文件。在生成allure结果前我会把当前测试环境地址、测试数据版本、执行人、分支名这些信息写入allure-results/environment.properties。这样报告打开后左侧就能显示一条环境信息栏回看历史记录时一眼就知道那次失败是跑在测试环境还是预发环境避免对着旧报告瞎猜。第二个是history目录。allure里的趋势图并不是自动从历史结果里算出来的——你需要把上一次的allure-report/history目录拷贝到本次的allure-results/history里。我写了个小函数每次生成报告前自动拷贝趋势图才开始有数据。rm -rf allure-results/history cp -r allure-report/history allure-results/history || true5.3 与CI流水线的整合流水线上跑自动化测试最关注的不是“怎么跑”而是“跑挂了怎么让人知道”。我在流水线上的做法是pytest输出JUnit XML用于流水线解析用例数allure-results用于生成可视化报告这两个产物上传到对应平台后还要设置一个门槛——如果核心用例的失败率超过阈值流水线直接失败阻止合入。我个人的阈值经验是smoke用例的通过率必须100%core用例的通过率不能低于98%。full用例的失败率仅记录不阻断。这样做的好处是既保住了最关键的回归质量又不会因为一些偏僻的环境问题导致发版流程经常性地卡住。流水线脚本里我还会执行一段收集逻辑把失败的用例按模块维度汇总成一个Markdown列表直接通知到对应的后端开发。allure报告自己不一定会有人主动打开但汇总表的点击率和解决问题的效率是实打实的。截图、请求日志、环境信息这些都能从allure的报告链接里点进去看但从通知到定位的第一跳是用小结表完成的。6. 真实跑下来的坑三处最容易翻车的地方6.1 pytest-asyncio和xdist的兼容问题这个坑我印象最深。刚开始我给框架接上pytest-xdist想跑多进程并发结果用例一多随机出现“event loop is closed”或者task attached to a different loop的报错。查了一整天才明白问题出在pytest-asyncio的事件循环fixture和xdist的worker进程模型上。xdist会把用例分发到多个worker子进程每个worker都会执行conftest.py里的fixture。如果event_loop是session级别的那么每个worker会创建各自的事件循环但pytest-asyncio在某些版本里默认给每个测试函数重新创建新的事件循环这就会导致session级的ClientSession持有的连接池跟新的事件循环对不上。解决的方法有两个要么把event_loop改为function级让每个异步用例共享一个事件循环但这样ClientSession的复用就失效了连接池效果大打折扣要么升级pytest-asyncio到0.21及以上显式声明asyncio_mode auto然后在conftest里固定事件循环的策略。我最终选的是后者同时在xdist启动参数上加--distloadscope让同一模块的用例尽量分配到同一个worker减少跨worker的session状态问题。如果你用的是旧版本还有一个兜底方案不用xdist的进程级并发全部依赖aiohttp的协程级并发这其实已经能满足绝大多数回归场景了。6.2 生成用例和pytest收集器的时间差这个坑更隐蔽。一开始我把生成器做成了一个独立的命令行入口先执行python -m gen_cases再执行pytest。手动跑没问题但只要有人忘了先执行生成器或者CI脚本里顺序写得不严谨就会出现pytest报“no tests collected”的空跑现象。后来我把生成器挂到pytest_configure钩子里心想万事大吉结果又遇到一个新问题pytest在收集用例之前需要确定rootdir和conftest的加载路径pytest_configure这个钩子虽然在收集之前但如果你在钩子里生成用例文件文件系统变更的时间点和收集器遍历目录的时间点之间还是有race condition。最终的可靠做法是分两步在pytest_configure里先调用生成器生成完成后再调用一次pytest_collection_modifyitems钩子在收集结束后检查是否至少有一个生成的用例被收集到如果没有就抛出明确的错误提示。这样既保证了生成和收集的顺序又能在收集阶段验证生成的用例确实存在。def pytest_collection_modifyitems(items): if not any(item.nodeid.startswith(tests/api/) for item in items): raise RuntimeError(用例生成器未产出有效用例请检查接口定义文件)6.3 aiohttp的Traceback误导与连接数控制aiohttp的异常Traceback在接口测试场景里有一个很误导人的特点连接池满导致的超时报错可能会指向你业务代码里封装的request方法看起来像是你自己的逻辑写错了。实际上问题出在连接池的limit设置或者对方的并发吞吐能力上。我出现过一次状况并发50个请求环境那边的服务端突然变慢每个响应要2秒连接池里的50个连接全被占满第51个请求开始排队等待最终引发的不是任务完成的超时而是连接池获取连接的超时报错。报错信息全部指向我的请求封装层排查了半天才发现是连接池被占满而不是代码有bug。这里分享一个实操调试技巧给连接池的获取加一个单独的日志每次从池里取连接时打印可用连接数和等待连接的任务数。平时静默当出现超时或排队的时候你能一眼看到瓶颈在哪。另外TCPConnector的limit_per_host参数也要留意同一个域名下的并发连接数默认是由limit控制的但多域名场景下limit_per_host0表示不限制单域名连接数这可能造成对某个服务的连接数量失控。我在框架里统一设置limit_per_host为limit的一半避免好处被一个域名全部吃掉。7. 关于这套框架后期的演进方向和我的几点体会现在这套框架的落地方案基本稳定了但我心里很清楚它的边界在哪里。最让我庆幸的是当时把用例定义和生成器做成了分离的两层这使得后续如果要支持GraphQL接口或者gRPC协议不会导致已有的REST用例体系推倒重来。如果再接新需求我的第一优先级一定是把接口定义文件与Mock数据的联动做上。用例自动生成当前面向的是真实后端环境的回归测试但如果生成的用例可以先打在Mock接口上再切到真实环境就能实现接口联调前置化和回归自动化打通。这一步的产出对研发团队的影响比单纯增加测试用例数量大得多。第二优先级是把生成用例的断言从“返回码和设备字段校验”扩展成“数据库巡检校验”。很多接口测试断言通过了但数据库里的数据状态其实并不正确这种问题往往要靠人为经验去发现。如果接口断言生成器能同时生成对应的数据库校验SQL用例的发现问题的能力会上升一个量级。最后想说的是接口测试框架的终点不是“用例写得少”而是“用例暴露问题的速度足够快”。如果你正在搭建类似的框架别急着堆功能和插件先把接口定义、用例生成、报告可视化这条主链路走通再逐步往上加东西。主链路稳定之后其余的都是锦上添花。