bigywater.tech
Java · 377

契约测试:验证 API 双方的共同假设

消费者与提供者之间的假设写在哪里?这篇用一个很小的订单接口量出:消费者的桩测试对提供者的 8 个版本给出了多少信息,契约能拦住什么、拦不住什么,以及 ObjectMapper 的宽松默认值如何把契约违反变成静默的错误值。

0
手写桩的消费者测试对提供者 8 个版本变化提供的信息(始终通过)
5 / 8
消费者契约在提供者 CI 里拦住的版本数
19、0、0
宽松 ObjectMapper 把 19.99 / null / 改名字段解析成的 total 值,不报错

桩测试与真实提供者

实测 · 本机执行输出
  消费者单元测试(手写桩 = v1 JSON):通过 —— 桩的内容不会随提供者变化
  v1 基线                  解析成功且值正确 id=7 status=PAID total=1999 note=gift
  v2 total 改名 amount     抛异常 UnrecognizedPropertyException: Unrecognized field "amount" (class Main$Order), not marked as ignorable (4 known propertie…
  v3 total 变成字符串         解析成功且值正确 id=7 status=PAID total=1999 note=gift
  v4 新增 currency         抛异常 UnrecognizedPropertyException: Unrecognized field "currency" (class Main$Order), not marked as ignorable (4 known propert…
  v5 删除 note             解析成功且值正确 id=7 status=PAID total=1999 note=null
  v6 新增枚举值               抛异常 InvalidFormatException: Cannot deserialize value of type `Main$Status` from String "REFUNDED": not one of the valu…
  v7 total 变成小数          解析成功但值不对 id=7 status=PAID total=19 note=gift
  v8 total 为 null        解析成功但值不对 id=7 status=PAID total=0 note=gift
  合计:值正确 3,抛异常 3,解析成功但值不对 2

消费者写的契约

实测 · 本机执行输出
  v1 基线                  契约通过
  v2 total 改名 amount     契约失败:total 缺失
  v3 total 变成字符串         契约失败:total 应为 integer,实际 STRING
  v4 新增 currency         契约通过
  v5 删除 note             契约通过
  v6 新增枚举值               契约失败:status 值 "REFUNDED" 不在 NEW|PAID|SHIPPED
  v7 total 变成小数          契约失败:total 应为 integer,实际 NUMBER
  v8 total 为 null        契约失败:total 应为 integer,实际 NULL
  合计:通过 3,失败 5

新增字段

实测 · 本机执行输出
  默认 ObjectMapper:抛异常 UnrecognizedPropertyException: Unrecognized field "currency" (class Main$Order), not marked as ignorable (4 known propert…
  FAIL_ON_UNKNOWN_PROPERTIES=false:id=7 status=PAID total=1999 note=gift
  契约验证 v4:通过(契约只约束消费者用到的字段,不禁止多出的字段)

宽松与严格的解析

实测 · 本机执行输出
  v2 total 改名 amount   宽松:成功 total=0
                       严格:抛异常 MismatchedInputException: Missing creator property 'total' (index 2); `DeserializationFeature.FAIL_ON_MISSING_CREATO…
  v3 total 变成字符串       宽松:成功 total=1999
                       严格:抛异常 MismatchedInputException: Cannot coerce String value ("1999") to `int` value (but might if coercion using `CoercionC…
  v7 total 变成小数        宽松:成功 total=19
                       严格:抛异常 InvalidFormatException: Cannot coerce Floating-point value (19.99) to `int` value (but could if coercion was enabl…
  v8 total 为 null      宽松:成功 total=0
                       严格:抛异常 MismatchedInputException: Cannot map `null` into type `int` (set DeserializationConfig.DeserializationFeature.FAIL_O…

只写用到的字段

实测 · 本机执行输出
  消费者 A 的契约 vs v5:通过
  消费者 B 的契约 vs v5:失败:note 缺失
  默认 ObjectMapper 解析 v5:id=7 status=PAID total=1999 note=null(note 缺失被当成 null,没有报错)
  不看消费者、只比较 v1 与 v5 的完整字段:删除了 [note] → 一律判为破坏性变更

新枚举值

实测 · 本机执行输出
  默认:抛异常 InvalidFormatException: Cannot deserialize value of type `Main$Status` from String "REFUNDED": not one of the valu…
  READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE + @JsonEnumDefaultValue UNKNOWN:成功 status=UNKNOWN total=1999
  契约验证 v6:status 值 "REFUNDED" 不在 NEW|PAID|SHIPPED

分步演示

契约测试
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64,JDK 21.0.12;Jackson databind 2.17.2;提供者是进程内的 JDK com.sun.net.httpserver,通过 java.net.http.HttpClient 真实发 HTTP 请求;契约是一个自写的字段 / 类型清单与验证函数
    • 没有验证:Pact、Spring Cloud Contract 等工具(没有运行,也没有 Pact Broker)、消息队列的契约、契约的版本管理与“can-i-deploy”流程、多个消费者的契约合并、状态(provider state)的准备、契约内容本身写错的风险
    京ICP备2026067502号-1京公网安备11010502063487号