契约测试:接口兼容、多端功能一致性与 AI Agent 编码

This article is extracted from the chat log with AI. Please identify it with caution.

AI 参与说明(Agent:Codex):本文由 Codex 基于 Pact、Eiffel、Cucumber、Promptfoo、Apple 与 Android 官方资料协助整理。接口测试术语与工具能力以来源为准;“跨端功能契约”及工作流是本文的工程组织方式,不代表统一行业标准。

本次修订运行记录(2026-09-07,Agent:Codex):模型 gpt-6-astra;reasoning effort:medium;执行入口:Codex Desktop;提供方:openai;运行记录中的 CLI 版本:0.153.4(不代表桌面 App 版本)。参数取自本次任务运行记录,仅归属于本次修订与校验,原有撰写参与说明保留。

整理日期:2026-09-07。本文面向 Web、iOS、Android、服务端及共享业务模块。Python 最小示例仅验证共享测试向量与规则执行流程;未运行真实 iOS / Android 工程、Pact 双端集成或 LLM Judge,不能据此认定某个客户端已经通过验收。

核心观点#

契约是对输入、输出、行为或边界责任的明确约定;测试检查实际实现是否满足这些约定。通常所说的 Contract Testing 重点是系统交互边界的兼容性,但团队也可以用“功能契约”组织多端共同遵守的业务规则。必须说清验证对象、执行范围和判定机制,不能把所有测试都重新命名为 Contract Test。

自然语言是规格的表达方式。工具可以把自然语言规格转换成测试、绑定到可执行步骤,或用作 LLM Judge 的评分规则;这些方式是否属于接口契约测试,取决于它们实际验证的对象。

先分清三种契约关系#

关系主要问题适合的验证方式
iOS / Android / Web 与服务端请求、响应、错误码是否与使用方兼容Consumer-driven Contract Testing 或 Specification Conformance
iOS 与 Android 实现同一功能同样条件下,业务决策、状态转换和副作用是否符合共同规格共享测试向量、状态序列、各端 Conformance Test
一个函数或模块的调用者与实现前置条件、后置条件和不变量是否成立Design by Contract、断言、Unit / Property-based Test

如果两个客户端并不直接交换消息,它们通常不是彼此的 Consumer 与 Provider。它们是同一份业务规格的两个实现。若它们通过文件、二维码或设备协议互通,则该交换边界本身也需要接口或协议契约验证。

接口契约:两边各自通过测试仍然可能不兼容#

假设客户端读取订单接口返回的 totalCents,约定它是以分计的整数;后端重构时把它改成了 total 字符串。如果客户端只测试自己写的 Mock,后端只测试自己的新实现,两边的 Unit Test 都可能通过,集成后却失败。Contract Test 的价值在于检查边界上的约定是否仍成立,而不是只检查各自内部是否自洽。

Consumer-driven Contract Testing 的典型流程是:

  1. Consumer Test 调用真实的客户端 API Client,让它与契约工具提供的 Mock Provider 交互。
  2. 工具从这些 Interaction 生成 Contract,记录 Consumer 实际依赖的 Request / Response 约束。
  3. Provider Verification 在准备好对应状态的真实 Provider 实现上验证同一份 Contract。
  4. CI 使用与待交付版本相关的验证结果,判断这组 Consumer / Provider 是否满足已覆盖的交互约定。

只有客户端 Mock Test 通过,不能证明后端履约;只验证后端符合自己写的 Schema,也不能证明现有 Consumer 可以使用它。Provider 对已发布 Specification 的符合性检查仍可以称为 Contract Testing,但应交代它不是 Consumer-driven 的完整证据。Pact Consumer Tests How Pact works

契约不必把整个 JSON 精确冻结:需要约束 Consumer 依赖的字段、类型、状态码、Header 和明确的取值规则,同时在兼容性策略允许时容忍新增无关字段。totalCents 仍是整数却从“分”改为“元”,单纯 Shape Check 就可能漏过;需要能识别单位错误的样例或语义断言。金额计算本身是否正确、订单是否真正写入数据库,则还需要 Functional / Integration Test。Pact 官方明确区分 Message Agreement 与 Provider Side Effect 的验证。Contract tests are not functional tests

另一个相关但不同的概念是 Design by Contract:用 Precondition、Postcondition 和 Invariant 表达调用者与被调用者的责任。例如输入数量必须为正、成功后库存按约定减少、库存不得为负。这是一种设计方法,可以通过断言和测试落实;它不等同于 Pact 的 Consumer / Provider 验证流程。Eiffel:Design by Contract and Assertions

iOS 与 Android:共享功能规格,各自验证真实实现#

假设两端都有“提交离线草稿”功能。可以共同约定:离线时保存为待同步状态;恢复网络后提交;请求超时后使用同一业务操作标识重试;收到成功确认后再标记已同步。两端不必具有相同按钮位置或相同导航动画,但持久化结果与重试语义应符合相同业务要求。

建议把共同要求分成三层,并为平台差异建立显式条目:

层次共享内容每端验证什么
纯业务规则输入、预期输出、错误优先级、单位、边界值真实业务函数,而不是测试里重写的函数
有状态流程初始状态、事件序列、依赖响应、最终状态、允许的副作用状态机、持久化、重试与恢复行为
平台体验用户目标与可观察业务结果原生 UI、生命周期、权限交互与平台集成

例如,可以共同规定“提交请求发出前取消编辑,不产生提交操作”,并在各端检查对应的取消入口。请求已经发出时能否取消、如何处理成功响应,需要另列规则;返回上一页本身不能证明请求或服务端写入已取消。UI 截图基线通常应按平台管理,不能把两端像素相等当成功能一致的必要条件。权限能力、后台执行和版本差异也应明确范围;不适用的规则需要记录理由,不能悄悄跳过后计入通过。

用共享测试向量落地#

一个低成本起点是:业务规格用 Markdown 表达,同一批输入与期望值用 JSON 保存,各端测试适配层读取它们,并调用各自生产代码。三者职责不同:

  • 规格说明为什么这样规定,以及规则优先级。
  • 测试向量提供可以复现的例子,包括边界与反例。
  • Adapter 只做数据转换、依赖注入、调用和结果提取,不能重新实现业务判断。

期望值应来自经过审阅的规格,不从当前任一客户端的实际输出自动生成。仅比较 iOS 与 Android 输出是否相等属于 Differential Testing 思路:它能发现分歧,却不能排除两端一起出错。因此最好分别比较“iOS 与规格”“Android 与规格”,再把跨端差异作为辅助信号。

最小可运行示例:共同的草稿提交规则#

以下示例使用 Python 3.9+ 标准库,无第三方依赖。它只演示规格与实现的连接方式,不是 iOS / Android 测试替身。为了独立运行,规则函数与校验器放在同一文件;接入实际项目时,应把规则函数替换为对生产模块的调用,不能保留一份测试专用的业务实现。业务规则为:未登录优先返回 sign_in_required;已登录且离线返回 queued;已登录且在线返回 send。输入限定为两个布尔值,非法输入策略不在此示例范围内。

将下面内容保存为 submission-cases.json

{
  "contractVersion": "1.0.0",
  "cases": [
    {"id": "SUBMIT-01", "input": {"signedIn": false, "online": false}, "expected": "sign_in_required"},
    {"id": "SUBMIT-02", "input": {"signedIn": false, "online": true}, "expected": "sign_in_required"},
    {"id": "SUBMIT-03", "input": {"signedIn": true, "online": false}, "expected": "queued"},
    {"id": "SUBMIT-04", "input": {"signedIn": true, "online": true}, "expected": "send"}
  ]
}

将下面内容保存为 verify_submission.py,与 JSON 文件放在同一目录:

import json
from pathlib import Path


def decide_submission(signed_in, online):
    if not signed_in:
        return "sign_in_required"
    if not online:
        return "queued"
    return "send"


def verify(decide, cases):
    failures = []
    for case in cases:
        data = case["input"]
        actual = decide(data["signedIn"], data["online"])
        if actual != case["expected"]:
            failures.append(f'{case["id"]}: {actual} != {case["expected"]}')
    if failures:
        raise AssertionError("\n".join(failures))


if __name__ == "__main__":
    source = Path(__file__).with_name("submission-cases.json")
    contract = json.loads(source.read_text(encoding="utf-8"))
    if contract["contractVersion"] != "1.0.0":
        raise ValueError("Unsupported contract version")
    cases = contract["cases"]
    if not cases:
        raise ValueError("No test cases discovered")
    verify(decide_submission, cases)
    print(f'{len(cases)} cases passed; contract {contract["contractVersion"]}')

运行 python3 verify_submission.py,预期输出 4 cases passed; contract 1.0.0。若故意把离线判断放到登录判断之前,SUBMIT-01 应失败:这证明用例可以发现这一种规则优先级错误,不能据此推断对其他缺陷也有同等检出能力。四个向量覆盖了两个布尔输入的全部组合,但不覆盖持久化、并发、生命周期或网络副作用。

实际接入时,iOS 测试读取同一 JSON,调用 Swift 业务模块;Android 测试读取同一 JSON,调用 Kotlin 业务模块。两端都输出 Case ID、契约版本、实现提交与实际结果。Swift Testing 支持以参数集合运行同一测试,可作为 iOS 纯业务用例的承载方式;Android 则应按是否依赖设备能力选择 Local Test 或 Instrumented Test。Apple Parameterized Testing Android Testing Fundamentals

各端 Adapter 的职责与失败报告#

共享的是规格与用例数据,不要求共享测试框架。接入每个客户端时,按以下顺序建立证据:

  1. 将固定版本的 JSON 用例纳入测试资源,并核对必需 Case ID、重复 ID 与空集合,防止漏加载被误报为通过。
  2. 把输入转换为生产模块接受的类型,注入可控的时钟、网络与存储依赖,保持每个用例的初始状态独立。
  3. 调用真实业务入口,提取返回值、状态或副作用;只按规格允许的规则归一化结果。例如可以统一时间的序列化格式,但不能抹掉业务相关的时区差异。
  4. 逐项与共享期望值比较,在失败报告中保留 Case ID、期望值、实际值、契约版本和实现版本。

若同一逻辑已由共享模块实现,模块测试可以减少重复,但各平台的绑定、序列化、存储与生命周期集成仍需验证。共享实现只能减少部分行为漂移,不能代替平台集成证据。

从纯函数扩展到状态与副作用#

上面的 queued 只是决策结果,不能证明草稿已经落盘。完整功能还需要固定初始数据与事件序列,并观察持久化和真实依赖边界:

Case ID初始状态与事件应验证的结果
SYNC-01已登录、离线,提交草稿后重新打开应用草稿仍存在且处于待同步状态
SYNC-02同步请求在服务端成功,客户端未收到响应后重试重用业务操作标识;服务端只有一条对应业务记录
SYNC-03待同步时退出账号,再切换账号按明确的账号隔离策略处理,不把旧账号草稿上传到新账号

这是示意性场景规格,尚未绑定平台执行代码。应明确请求超时的故障注入点、时钟、网络恢复事件、存储状态与观察时限;用明确的完成信号或有上限的条件等待,避免依赖任意长的 Sleep。

“只写入一次”需要覆盖服务端幂等逻辑和实际持久化,不能只检查客户端发了几次请求。客户端规则测试、接口契约测试、集成测试和设备场景测试分别提供不同证据,不能互相替代。

移动端版本矩阵:不只测最新两个版本#

移动端应根据实际支持策略列出仍在使用的客户端版本与契约版本。下面只是示意,不是具体产品的兼容承诺:

实现业务契约接口契约发布前要求
仍受支持的 iOS 旧版1.xAPI v1 的实际依赖新服务端继续满足其依赖
iOS 新版2.xAPI v1 或 v2新行为符合 2.x,并覆盖升级与旧数据
Android 当前版1.xAPI v1 的实际依赖保留 1.x 行为;明确尚未支持的能力

共同规格不意味着所有客户端必须同时升级。某个新功能暂时只在一端提供时,应声明 Feature / Capability 的适用版本、Fallback 和退出支持条件;不能把“尚未支持”写成无说明的通过。

CI 应固定测试向量版本或摘要,验证必需 Case ID 已被发现,区分通过、失败、未运行和经批准的不适用。兼容性判断以仍受支持的版本组合为准;只让同一个 Agent 同时更新最新前后端并跑绿,会漏掉旧客户端。

自然语言测试方案能否称为契约测试#

自然语言描述的是表达方式,Contract Testing 描述的是验证目标。判断名称时,应分开看 Specification、执行器和 Test Oracle(判定实际结果是否满足预期的机制)。

方案如何执行与判断更准确的称呼
AGENTS.md 写“接口必须向后兼容”仅供 Agent 阅读,没有独立执行证据项目规则或行为约束,还不是 Test
给 Agent 一段需求,让它生成并运行断言Agent 翻译需求,Test Runner 执行具体断言自然语言驱动的测试生成;测试类型取决于断言对象
Gherkin 场景绑定 Step DefinitionStep Definition 执行动作与断言Executable Specification / BDD 场景;覆盖验收目标时也是 Acceptance Test
自然语言定义接口交互,再验证 Consumer / Provider检查双方对 Request / Response 的约定可以是 Contract Test,关键是实际验证接口边界
用自然语言评分规则让 LLM 判断结果模型输出评分、理由或 Pass / FailLLM-as-a-judge Evaluation / Semantic Assertion

因此,“用户点击保存后看到成功提示”即便以自然语言写成并自动执行,通常仍是 UI Acceptance Test;“订单接口在指定状态下返回整数 totalCents,且现有 Consumer 能解析”才具有接口契约测试的目标。Gherkin 并不决定测试处在哪一层,Step Definition 才连接实际被测对象。Cucumber Step Definitions

Promptfoo 的 llm-rubric 是后一类方案的具体例子:以自然语言给出评分标准,由模型评估输出。这里自然语言并非没有判定器,而是采用了模型作为判定器。与确定性断言相比,它需要额外管理评分规则、模型配置、输入证据和误判;即使输出一个布尔值,也不意味着判定已经确定化。Promptfoo LLM Rubric

工程上可以将这套方法称为“行为契约验证”,但应明确这是团队采用的广义称呼,不能据此宣称已经完成接口兼容性验证。精确字段、权限拒绝、数据条数和状态转换优先使用确定性断言;“解释是否清晰”这类语义要求可用模型评分,并用人工标注的正反例校准。验证代码功能时应提供实际运行结果、页面状态或持久化证据,不能只给 Judge 阅读编码 Agent 的完成报告。

Agent 编码让契约的用途怎样变化#

这里需要区分两个对象:Agent 编写的软件可以用常规 Unit、Contract、Integration 和 E2E Test 验证;Agent 本身的行动与回答则可能需要工具调用轨迹断言与 Evals。让 Codex 写普通应用,不意味着应用的测试也必须使用 LLM Judge。

下面是面向 Agent 编码的工程建议,不是新的行业测试分类:

  1. 把意图变成先于实现的验收基准。给关键要求分配稳定 ID,写清前置状态、动作、可观察结果和错误场景,再让 Agent 映射到合适的测试。无需机械地把每个小改动都变成完整 TDD 流程。
  2. 在接口边界约束生成的实现。客户端和服务端分开实现时,共享可验证的交互约定,并分别验证真实 Consumer 与 Provider。若旧版客户端仍在使用,不能只验证同一次改动后的新客户端和新服务端;还应覆盖仍需支持的 Consumer 契约。
  3. 让 Agent 寻找反例。要求它设计缺字段、错误类型、越权、超时或重试场景,并明确哪些属于接口契约,哪些属于业务集成验证。回归修复应证明新增测试在未修复版本上失败,修复后通过;必要时用人为注入的错误检查测试是否敏感。
  4. 保持验收标准的独立性。可以让同一个 Agent 编码和补测试,但降低断言强度、跳过测试或更新预期值,需要单独审阅并说明原因。第二个 Agent 可以辅助审阅,但不构成独立正确性的保证。
  5. 在 CI 中保留可复查证据。记录被测提交、实际运行的测试和结果;关键兼容性检查应成为 Required Check。仅在 Prompt 写“必须通过测试”并不能阻止不合格代码合入。

例如,可以给编码 Agent 以下任务模板。它是自然语言验收说明,不是可以直接运行的测试文件:

目标:在 iOS 和 Android 实现订单查询功能。
AC-API-01:订单存在时,Consumer 能解析字符串 id 与整数 totalCents;
           totalCents 以分计。Contract 同时验证真实 Consumer 与 Provider。
AC-API-02:订单不存在时,返回 404 与约定的错误 code。
AC-UI-01:加载失败时显示错误状态和重试入口,不显示成功状态。
AC-DATA-01:无权限用户不能读取订单内容;以真实权限边界的集成测试验证。
AC-CROSS-01:两端按同一份状态用例区分加载、成功、空结果与失败;
             各端调用真实业务模块,平台 UI 分别验收。

先列出每个 AC 的测试层级与可观察证据,再实现。
固定共享用例版本,核对两端应运行的 Case ID 与实际运行结果。
保留仍需支持的旧 Consumer 契约。
若实现与约定冲突,列出冲突,不通过删除断言或更新预期值掩盖。
交付时报告实际执行命令、结果,以及未覆盖的场景。

在需要精确重现“未授权时没有发生写入”或“重试没有重复创建订单”的场景中,应直接检查状态与副作用;这类证据不能由 Response Shape 或模型的自然语言判断替代。

选择起点与维护成本#

如果主要问题是 iOS / Android 业务规则漂移,先从一项高价值功能开始,共享少量覆盖边界的测试向量,让各端现有测试调用真实代码。无需先搭建庞大的跨平台框架。

当客户端与服务端独立发布、Mock 经常与真实响应不一致时,再引入 Consumer / Provider 契约验证。只有服务端对 Specification 的校验时,也应如实报告其覆盖范围,不能把它当成已验证所有客户端。

对于权限拒绝、金额、数据隔离和持久化次数等明确结果,优先采用确定性断言。自然语言评分适合语义目标,但要固定评分标准与模型配置,用人工标注的正反例校准,并保留实际运行证据。编码 Agent 的完成报告本身不是被测系统的行为证据。

关联阅读#

本文共 6363 字,创建于 Sep 7, 2026

相关标签: Tools, ByAI