quarkus+wiremock

quarkus-wiremock

最近项目中接触到wiremock的测试,之前一直是写Mockito,也就是最传统的对 本地代码(类、接口、方法)进行行为模拟。
比如我现在有一个TaobaoService接口,我可以对其方法进行模拟,当请求特定或任意资源的时候,返回我们想要返回的东西。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@InjectMock  
@RestClient
lateinit var taobaoService: TaobaoService

@BeforeEach
fun setUp() {

val taobaomockResponse = Fixture.loadPOJOFromFile<TaobaoTradeApi>("/examples/api_mock.json")
// 配置 mock 行为
`when`(taobaoService.taobaoTradeFullinfoGet(anyString(), anyString(), anyString()))
.thenReturn(taobaomockResponse)
`when`(taobaoService.taobaoTradeFullinfoGet(anyString(), anyString()))
.thenReturn(taobaomockResponse)
}

但是我们遇到了一种情况,此时无法使用Mockito:

在 MVC 项目里,我们想在 Model 层直接注入一个 TaobaoService 来方便调用,但 Model 并不在应用作用域(appScope)内,导致依赖注入失效。于是我们只能自己 new 一个 TaobaoService。问题是,这个手动创建的实例在单元测试里无法被 Mockito 的 @InjectMock 替换,Mock 失效,测试就会去请求真实的 Taobao API。

此时就可以用到WireMock来实现我们想要的功能。WireMock 是针对 HTTP 服务 的模拟服务器。
它启动一个内嵌的 HTTP 服务器,配置请求─响应映射(stub),拦截真正的 HTTP 调用并返回预先定义的 mock 数据。

使用场景

框架 主要用途
Mockito 单元测试中替代依赖对象:DAO、Service、工具类等。
WireMock 集成测试或端到端测试中,替代外部 HTTP 服务(第三方 API、微服务)

优劣对比

维度 Mockito 优势 Mockito 劣势 WireMock 优势 WireMock 劣势
依赖范围 - 直接在 JVM 内部拦截、速度快
- 无需网络,启动轻量
- 只针对本地方法,无法模拟 HTTP 底层细节 - 真正启动 HTTP 服务,完全模拟外部 HTTP 接口
- 支持延迟、故障注入等
- 启动一个 HTTP Server,整体 启动&执行 较慢
- 需占用端口
配置方式 - 代码级别声明
- 灵活控制单个方法的调用和返回值
- 无法对 URL、Header、Body 级别进行精细匹配 - 支持 URL、Query、Header、Body 等多维度匹配
- Stub 配置直观
- Stub 配置较多时文件/代码会较冗长
断言能力 - 可以直接对方法调用次数、入参、顺序进行精确断言 - 仅限 Java 对象,不支持对 HTTP 原始报文断言 - 可以对 HTTP 请求日志做断言(请求次数、Body 内容等) - 断言次数或内容变化时,需要额外调用 admin API
故障模拟 - 可抛出任意异常模拟下游组件故障 - 无法模拟网络超时、延迟等真实 HTTP 场景 - 原生支持延迟、连接失败、按比例返回错误码等 - 对非 HTTP 场景无能为力
集成成本 - 几行代码即可完成 Mock
- 与测试框架无缝集成
- 仅组件级 Mock,不具备对外部调用的覆盖 - 可用独立进程或内嵌;支持 Docker 化 - 需要管理服务器生命周期;CI 环境中要配置端口和依赖

引入依赖

官方文档
官方文档中是Maven导入,我们用Gradle的话,可以这样写:

1
testImplementation("io.quarkiverse.wiremock:quarkus-wiremock-test:1.0.0")

也可以使用libs.versions.toml来配置版本:

1
2
3
4
[versions]
wiremock-test = "1.0.0"
[libraries]
wiremock = {group = "io.quarkiverse.wiremock", name = "quarkus-wiremock-test", version.ref = "wiremock-test" }

然后再build.gradle.kts中写:
1
testImplementation(libs.wiremock)

implementation & testImplementation

这里WireMock是用implementation还是testImplementation呢?

  • implementation(“…”)

    • 作用范围:对项目的 主代码src/main/java / src/main/kotlin)可见,也会出现在运行时和最终打包的产物里。
    • 典型用途:那些在生产环境里真正需要、无论是编译还是运行都离不开的库,比如 Quarkus 运行时、数据库驱动、核心工具库等。
  • testImplementation(“…”)

    • 作用范围:仅对 测试代码src/test/java / src/test/kotlin)可见,既会加入测试编译时的 classpath,也会加入测试执行时的 classpath;但不会出现在生产运行时,也不会被打包到发布产物里。
    • 典型用途:只能在测试里用到的库,比如 JUnit、Mockito、WireMock、测试专用的工具集等。

这里我们在开发完毕后需要将wiremock放到testImplementation中,但是在开发的时候,为了方便调试,我们可以将其放到implementation中,查看mappings是否正常运行。

配置WireMock

这是整个步骤中我觉得最困难的地方。由于我们是在测试中用到wiremock所以放在test下的application.properties更好。

quarkus.devservices.enabled

这个全局开关默认为true,千万不能为诶false。否则wiremock就不会再测试或者本地开发模式下自动启动。

quarkus.wiremock.devservices.port

这个配置是定义wiremock启动后监听在哪个端口上,如果不配置,那么就随机制定。
通常我们不需要配置,但是如果要配置的话我们可以这样写:

1
2
%test.quarkus.wiremock.devservices.port=9081
%dev.quarkus.wiremock.devservices.port=9081

bug

这里注意了,当我们使用implementation的时候,如果不配置test port,运行./gradlew quarkusDev进行测试时会报错:

  • 因为此时implementationquarkus-wiremock-test 扩展放进 主应用的 classpath。
  • dev 模式下若没显式配置端口,启动时会直接尝试绑定 8089(官方默认)。
  • 如果随后又运行测试(r 快捷键),而测试也会再启一个 WireMock, 两份 WireMock 都想占 8089 → BindException
    testImplementation 情况下
  • WireMock 只出现在 test classpathquarkusDev 启动的主应用里根本“看不到”它,所以不会在开发模式就抢占端口。
  • 只有在Continuous Testing(r)运行时,Quarkus 会为测试 fork/隔离一个专用的 测试 classloader,此时才加载 WireMock 扩展。
  • 如果没配 quarkus.wiremock.devservices.port,扩展会自动找 一个空闲端口,并把实际端口注入 ${quarkus.wiremock.devservices.port} 占位符

因此最好使用testImplementation 如果一定要在调试时使用implementation的话,也请显示定义%test.quarkus.wiremock.devservices.port

quarkus.wiremock.devservices.files-mapping

先说现象

配置 连续测试 (./gradlew quarkusDev ➜ 键 r) 结果
没有显式配置files-mapping
Dev 模式能启动 WireMock,但 测试一跑就报 Un-recognized token ‘No’/__admin/mappings 为空 失败

quarkus.wiremock.devservices.files-mapping=${pwd}/build/resources/test
Dev 模式照常启动;按 r 运行测试也能找到所有 stub,错误消失 成功
  • ./gradlew test

    • Gradle 在运行 test 任务时,会把
      • src/main/resources
      • src/test/resources
      • 测试类与测试依赖
        一起打包到 测试 JVM 的 classpath 上。
    • WireMock Dev Service 默认会扫描 classpath 根下的 mappings/__files/ 目录,
      所以放在 src/test/resources/mappings/… 的 stub 能被自动找到并注册,测试自然通过。
  • ./gradlew quarkusDev + 按 r(Continuous Testing)

    • Quarkus Dev Mode 会先把 主应用src/main/java + src/main/resources)打包成 Runner Jar 并启动,
    • Continuous Testing 再在这个运行时环境里 热重载执行测试,但它并不把 src/test/resources 一并打到 Runner Jar 中。
    • 因此,WireMock 在 Dev Mode 的 classpath 根下 看不到 测试资源里的 mappings/ ——
      它启动了空的 stub 存储,所有请求都返回 404 “Not Found”

测试代码

经过了漫长的配置,我们终于要开始mock http了,首先,我们需要对需要mock的RestClient对象上,不能设置BaseURI,而是要配置configKey,如下所示。
如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
@RegisterRestClient(configKey = "taobao")  
xxx
interface TaobaoService {
@POST
@ClientFormParam(name = "method", value = ["taobao.trade.fullinfo.get"])
@ClientFormParam(name = "include_oaid", value = ["include_oaid"])
@ClientFormParam(name = "fields", value = [DEFAULT_API_FIELDS])
fun taobaoTradeFullinfoGet(
@RestForm("session") session: String,
@RestForm("tid") tid: String,
): TaobaoTradeApi
}

application.properties 里,我们给 Taobao 的客户端设置了两套地址:

  1. 测试环境(用 %test. 前缀)
    1
    %test.quarkus.rest-client.taobao.url = http://localhost:${quarkus.wiremock.devservices.port}/router/rest/taobao
  • ▶️ 当跑测试时,Quarkus 会自动把所有对 TaobaoService 的调用指向本地 WireMock 服务器,这样就能返回我们事先准备好的模拟数据。
  1. 开发/生产环境(用 %dev. 前缀)
    1
    `%dev.quarkus.rest-client.taobao.url = https://eco.taobao.com/router/rest`
  • ▶️ 在平时的开发或上线时,客户端会调用真正的淘宝 API。

Quarkus 会根据当前运行的 Profile(test 还是 dev/prod)自动选用对应的那一行配置,不需要手动切换。

在跑测试的时候,我们不想真打到淘宝 API,而是把所有 HTTP 请求都指向本地启动的 WireMock。

为了让 WireMock “知道” 遇到什么请求该怎么回,它需要一份“剧本”——这就是 stub

stub 就像一套“如果看到这个请求,就给我返回那个响应”的规则:

1. **请求格式**:请求方法(GET/POST)、路径、Query 参数、Header、甚至 Body 长啥样
2. **返回结果**:要回哪个 HTTP 状态码,Body 是哪段 JSON,Header 应该带什么

我们把这些 stub 文件都放在 src/test/resources/mappings/ 里,WireMock 启动时会自动读取它们。这样,测试一跑:

  1. 代码里发出的每个 HTTP 调用,都会被 WireMock 拦截;
  2. WireMock 根据 mappings 里的 stub 规则,马上返回我们预先写好的假数据;
  3. 测试就能在离线、可控的环境下稳稳地跑通,不会再去真正打淘宝接口。

比如我这里定义的taobao_v0-stubs.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
{  
"id": "8a3782de-2eca-420b-9942-079e74919389",
"priority": 3,
"request": {
"method": "POST",
"urlPath": "/router/rest/taobao"
},
"response": {
"status": 200,
"jsonBody": {
"trade_fullinfo_get_response": {
"trade": {
"buyer_nick": "z**",
"buyer_open_uid": "AAEtbdvLAJmprSrbeTwWPXYC",
"created": "2024-12-25 17:02:02",
"modified": "2024-12-25 17:02:02",
"new_presell": false,
"orders": {
"order": [
{
"buyer_rate": false,
"cid": 50012587,
"num": 31,
"num_iid": 736399892063,
"oid": "3922360629974",
"outer_iid": "HWM60PQSNM",
"outer_sku_id": "1.01.08.2055",
"payment": "0.00",
"pic_path": "https://img.alicdn.com/bao/uploaded/i3/793375733/O1CN01hyrhao1sDlWBeKF7N_!!793375733.jpg",
"price": "24.87",
"refund_status": "NO_REFUND",
"sku_id": "5740608026382",
"sku_properties_name": "适用手机型号:华为 Mate 60 Pro;颜色分类:超清水凝膜2片【曲屏全覆盖*升级无气泡】送神器+防滑垫",
"status": "WAIT_BUYER_PAY",
"title": "【活动价】闪魔适用华为mate60pro钢化膜Mate60Pro手机膜mate 60高清60Pro+曲面RS非凡大师"
},
{
"buyer_rate": false,
"cid": 50023728,
"num": 32,
"num_iid": 727021460771,
"oid": "6880691104973",
"outer_iid": "9.09.02.0004",
"payment": "0.00",
"pic_path": "https://img.alicdn.com/bao/uploaded/i4/793375733/O1CN013d698Z1sDlV1Bk1Cp_!!0-item_pic.jpg",
"price": "9999.00",
"refund_status": "NO_REFUND",
"status": "WAIT_BUYER_PAY",
"title": "赠【30天贴坏包赔服务】-限时活动专属"
}
]
},
"payment": "19.90",
"receiver_city": "深圳市",
"receiver_country": "",
"receiver_district": "龙华区",
"receiver_state": "广东省",
"receiver_town": "观澜街道",
"seller_nick": "闪魔旗舰店",
"status": "WAIT_BUYER_PAY",
"tid": "12500568",
"total_fee": "10023.87",
"type": "fixed"
},
"request_id": "16l7c09wjvlxi"
}
},
"headers": {
"Content-Type": "application/json"
}
}
}

此stub包含如下信息

1. 基础元信息

  • id8a3782de-2eca-420b-9942-079e74919389
    • 这是 WireMock 内部给这条映射规则的唯一标识,方便查日志或管理。
  • priority3
    • 数值越小优先级越高,值相同则按照注册顺序。这里写 3,表示中等优先级。

2. 请求匹配(request

1
2
3
4
"request": {
"method": "POST",
"urlPath": "/router/rest/taobao"
}
  • HTTP 方法POST 需要和被mock的请求方法保持一致
  • 路径/router/rest/taobao 需要和properties中配置的taobao.url保持一致
  • 没有对请求头、查询参数、甚至请求体做进一步校验——只要是 POST 到这个路径,都能命中。

    3. 响应定义(response

1
2
3
4
5
6
7
"response": {
"status": 200,
"jsonBody": { …长 JSON… },
"headers": {
"Content-Type": "application/json"
}
}
  • HTTP 状态200 OK
  • 响应体
    • 这里直接把一个完整的 Java 对象格式的 JSON(即 trade_fullinfo_get_response 结构)内联在 jsonBody 里。
    • WireMock 会自动把它序列化成纯文本 JSON 响应。
  • 响应头Content-Type: application/json
    • 指明这是 JSON,方便客户端(你的 Quarkus Rest Client 或过滤器)正确解析。

4. 具体行为效果

  1. 客户端代码 发起 POST http://<wiremock-host>:<port>/router/rest/taobao
  2. WireMock 匹配到上面的 stub(方法和路径都符合)
  3. WireMock 立刻 返回
  4. 测试或业务代码拿到这个假数据,就不会再去真正调用淘宝 API,而是直接用这份“剧本”数据跑逻辑或断言。

    进一步拆分

    事实上,我们可以把大块的响应 JSON 放到 __files/ 目录下,然后在 mappings/ 里只用一个很短的映射文件指向它。目录结构大致是:
    1
    2
    3
    4
    5
    6
    7
    src
    └─ test
    └─ resources
    ├─ mappings
    │ └─ taobao_v0-stubs.json
    └─ __files
    └─ taobao-response.json

mappings/taobao_v0-stubs.json如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{  
"id": "8a3782de-2eca-420b-9942-079e74919389",
"priority": 3,
"request": {
"method": "POST",
"urlPath": "/router/rest/taobao"
},
"response": {
"status": 200,
"bodyFileName": "taobao-response.json",
"headers": {
"Content-Type": "application/json"
}
}
}

taobao-response即我们要返回的jsonbody

-------------本文结束,感谢您的阅读-------------