初入职场时,我接手过一套接口自动化测试代码。打开登录模块,赫然发现里面写着二十行RSA签名生成逻辑。再看订单模块,同样的二十行代码,甚至连变量名都原封不动。切换到支付模块,又是如出一辙的副本。
这并非简单的复制粘贴问题。根本原因在于,当时编写脚本的人,并未掌握代码复用的方法。
三年过去了,这种情况有所改善吗?
并没有。深入调研过七八个测试团队后,我发现每个团队的代码仓库里,都至少存有三四套不同的认证、签名或加密实现方案。有的采用HMAC-SHA256,有的使用MD5加盐,有的基于JWT,有的则应用自定义的非对称签名。几乎每个接口请求都需要附带这些逻辑,每个测试脚本都必须重新编写一遍。
更棘手的是,一旦认证逻辑发生变化,例如密钥轮换或算法升级,所有相关脚本都需要逐一修改。但凡遗漏一处,那个脚本就会永远处于失败状态。
上个月,我把项目中所有与认证、签名相关的逻辑都抽取成了Skills。现在,任何一个接口测试只需一行声明:@use_skill("auth_rsa")。签名参数会自动注入,请求会自动发送,响应也会自动验签。接口测试脚本的代码量从一百五十行锐减到了二十行。
这篇文章不讲空泛的理论。我会直接拆解,如何将认证、签名这类“横切关注点”封装成可复用的Skill。
一、每个接口测试脚本里,都潜藏着一段不敢轻易改动的签名代码
随意打开一个接口测试项目,你大概率会看到类似这样的结构:
def test_create_order():# 二十行签名生成timestamp = str(int(time.time()))nonce = str(random.randint(100000, 999999))body = json.dumps({"product_id": 123, "quantity": 2})sign_string = f"{timestamp}{nonce}{body}"signature = hmac.new(secret_key, sign_string.encode(), hashlib.sha256).hexdigest()# 两行业务请求headers = {"X-Timestamp": timestamp, "X-Nonce": nonce, "X-Signature": signature}resp = requests.post("/api/order", data=body, headers=headers)# 二十行响应验签(有时还有)# ...
看出问题的症结了吗?
真正描述业务意图的代码只有两行:post("/api/order", data=body)。其余的四十行代码全是认证、签名、验签的“杂音”。而且,这段杂音在每一个测试函数里都会重复出现。
更令人头疼的是,当开发修改了签名算法,从HMAC-SHA256换成SM3时,你就需要在三十个测试文件里,分别定位那段签名代码,逐一修改。改完之后还不一定能全部跑通。
很多人干脆就选择不动。新接口直接复制旧接口的签名代码,改一改变量名就直接使用。于是,代码库里就出现了三代签名算法并存的奇特景象。
问题的本质究竟是什么?
我们将本应属于“基础设施”的认证能力,硬编码进了业务脚本之中。这就像在每个业务函数里都重写一遍数据库连接池一样,没人会这么做。但在接口测试实践中,大家却默认如此行事。
观点句1:
二、认证并非业务逻辑,而是基础设施
我们需要明确一个概念:什么是业务逻辑,什么是基础设施。
业务逻辑是“下单时要扣减库存”、“支付成功后要发送通知”。认证签名则是“每个请求都必须附带的安全凭证”。前者每个接口各不相同,后者整个系统保持一致。
基础设施的特点是:可以被统一管理、统一变更、统一升级。数据库连接池是这样,日志记录是这样,认证机制也应如此。
但在大多数接口测试框架中,认证却被当作业务逻辑来处理。每个测试函数都自行负责生成签名、构造认证头、执行验签。造成这种现象的原因主要有两个。
第一,历史惯性问题。最初编写第一个测试脚本的人,为了图省事,将签名代码直接写在函数体内。后来者看到这种模式,便直接照搬。
第二,工具本身的限制。许多测试框架(比如requests的原生用法)并未提供“全局请求拦截器”的概念。若想统一处理认证,就只能自己封装一个session类。并非做不到,而是需要额外的设计工作。
Skills-first模式提供了一种新的抽象方式:将认证能力封装成一个独立的Skill,然后通过声明的方式将其附加到任意接口测试上。
Skill在此承担的角色是请求拦截器。它在请求发出前执行,能够修改请求头、请求体甚至URL。同时,它也可以在响应返回后执行,进行验签、解密等后处理工作。
关键点在于:Skill并不关心具体调用它的接口是什么。它只知道自己需要从当前上下文中获取哪些数据(如时间戳、随机数、请求体等),然后计算出签名,并将其注入到请求头中。
这种设计带来的直接效果是:接口测试脚本回归本源,只专注于描述业务逻辑本身。认证的事情,交给Skill去处理。
观点句2:
三、Skill作为请求拦截器:注入、签名、发送
下面我们来拆解一个认证Skill的完整技术实现。
核心架构如下:

Skill的定义规范
一个认证Skill需要实现两个接口:
class AuthSkill:def pre_request(self, context):# context包含:method, url, headers, body, params# 返回修改后的headers和bodytimestamp = str(int(time.time()))nonce = self._gen_nonce()body_str = json.dumps(context.body) if context.body else""sign = self._sign(f"{timestamp}{nonce}{body_str}")context.headers["X-Timestamp"] = timestampcontext.headers["X-Nonce"] = noncecontext.headers["X-Signature"] = signreturn contextdef post_response(self, context):# context包含:response对象# 可选:验签、解密响应体signature_from_server = context.response.headers.get("X-Signature")ifnot self._verify(context.response.text, signature_from_server):raise AuthFailedError("响应签名验证失败")return context
在测试脚本中的调用方式
测试脚本只需要声明依赖哪个Skill,并传入必要的业务参数即可:
@use_skill("hmac_sha256_auth")def test_create_order(product_id, quantity):resp = api.post("/order", json={"product_id": product_id, "quantity": quantity})assert resp.status_code == 200
@use_skill装饰器所做的工作:
- 在函数执行前,实例化指定的AuthSkill
- 将原始请求拦截,传入Skill的
pre_request方法 - 发送修改后的请求
- 将响应传入Skill的
post_response方法 - 返回最终响应给测试函数
支持多Skill链式组合
有些系统需要多层认证。例如,先进行JWT鉴权,再对请求体进行签名。此时可以将多个Skill串联起来:
@use_skill("jwt_auth")@use_skill("payload_signature")def test_pay():# ...
执行顺序:先从最内层的Skill开始,然后向外层执行。每个Skill都可以修改请求和响应。
这种设计解决了两个实际难题:
- 当认证逻辑发生变化时,只需修改Skill的实现,所有测试脚本无需任何改动
- 新接口接入认证时,只需添加一行装饰器,无需复制任何代码
观点句3:
四、三个真实场景的代码对比
我们选择三个典型的认证场景,对比传统写法与Skill写法的差异。
场景一:HMAC-SHA256签名(最常见)
传统写法(每个接口):
timestamp = str(int(time.time()))nonce = str(random.randint(100000, 999999))body = json.dumps({"amount": 100})sign_str = f"{timestamp}{nonce}{body}"sign = hmac.new(secret, sign_str.encode(), hashlib.sha256).hexdigest()headers = {"X-Timestamp": timestamp, "X-Nonce": nonce, "X-Signature": sign}resp = requests.post("/api/pay", data=body, headers=headers)
Skill写法:
@use_skill("hmac_sha256")def test_pay():resp = api.post("/pay", json={"amount": 100})assert resp.status_code == 200
代码行数对比:传统写法25行,Skill方式仅需3行(不含Skill定义部分)。
场景二:OAuth 2.0 Client Credentials
传统写法(先获取token,再携带token请求):
def test_get_user():# 获取tokenauth_resp = requests.post("/oauth/token", data={"grant_type": "client_credentials","client_id": "xxx","client_secret": "yyy"})token = auth_resp.json()["access_token"]# 业务请求headers = {"Authorization": f"Bearer {token}"}resp = requests.get("/api/user", headers=headers)# token过期还需处理刷新逻辑...
Skill写法:
@use_skill("oauth2_client")def test_get_user():resp = api.get("/user")assert resp.status_code == 200
Skill内部自动处理了token的获取、缓存、自动刷新以及过期重试等复杂逻辑。
场景三:国密SM2/SM3签名(金融行业常见)
传统写法需要引入复杂的加密库,编写十几行甚至几十行代码。而经过Skill封装后,业务脚本对此完全无感知。
一个可量化的对比数据
我们统计了单个项目中认证相关代码的重复率。重构前,15个接口测试文件,认证代码总行数超过600行。重构后,认证Skill仅需一个文件120行,每个测试文件平均减少40行重复代码。总代码量从600行降至120 + 15*10 = 270行。净减少55%。
更重要的是,后来开发将签名算法从HMAC-SHA256升级到SM3,改动只发生在Skill内部,测试团队实现了零改动。
五、封装认证Skill的三个层级
并非所有认证逻辑都需要做成通用Skill。过度设计,例如将简单的Basic Auth也封装成Skill,反而会增加团队的理解成本。
根据复杂度和复用频率,可以分为三个层级进行落地。
第一层:函数级复用(成本最低)
如果认证逻辑非常简单(例如固定的API Key),且仅在两三个测试文件中使用,则无需引入Skill。编写一个公共函数即可。
def add_auth_headers(headers):headers["X-API-Key"] = "fixed_key"return headers
这还不算Skill,但已经比到处复制粘贴好得多。
第二层:请求级拦截器(适用于大多数场景)
当认证逻辑涉及动态参数(如timestamp、nonce、签名计算),并且需要被五个以上接口使用时,就值得封装成真正的Skill了。
核心是设计好Skill的输入和输出。一个优秀的认证Skill应做到:
- 不依赖全局变量(密钥通过配置注入)
- 不硬编码算法(支持策略模式切换不同的签名方式)
- 能够独立进行测试
第三层:多Skill组合(适用于企业级复杂场景)
有些系统会同时使用多种认证机制。例如,内部服务之间使用mTLS,对外部请求使用JWT,对敏感操作再叠加一层签名。
此时需要支持Skill链。每个Skill只处理一件事情,执行引擎按顺序依次调用。在调试时,也可以单独关闭某个Skill来精准定位问题。
避坑指南
分享两个曾经踩过的常见坑,提醒一下大家。
第一个坑:不要在Skill里执行重量级操作。比如每次请求都去读取密钥文件或调用远程服务获取token。应该做好缓存,仅在token过期时进行刷新。
第二个坑:不要为了统一而统一。有些接口不需要认证(比如健康检查接口),有些接口则使用不同的认证方式。Skill框架需要支持“跳过认证”和“覆盖认证”的功能。可以实现一个@use_skill(None)来显式关闭继承而来的认证。
六、你的代码库里,还有多少“不敢删”的重复代码?
在写这篇文章时,我随手打开了一个旧项目的测试目录。输入grep -r "hmac" . | wc -l,结果是47。这意味着有47处HMAC签名代码,分布在14个文件里。
我将其中一个文件里的签名代码删掉,替换成一行@use_skill,运行测试。结果全部通过。
然后我批量替换了剩下的13个文件。整个过程总共花了四十分钟。而这四十分钟省下的,是未来每一次签名算法变更时可能耗费的一整天时间。
现在轮到你了。
请打开你正在维护的接口测试项目,搜索sign、signature、hmac、jwt、token这些关键词。看看有多少个文件在重复做着同一件事情。
然后问自己一个问题:
如果明天后端通知你,认证算法要更换,从A换成B。你需要改动多少个文件?
如果你的答案不是“1个”,那么你的测试框架里,已经积累了一笔技术债务。
至于如何将这“1个”改动写得足够通用、足够健壮、让团队里其他成员能直接使用——那是另一个话题了。欢迎在评论区聊聊,你遇到过的最离谱的重复代码是什么样的?
