1. 项目概述:为什么我们需要WireMock?

在开发一个依赖外部API的微服务时,你遇到过多少次这样的场景?第三方服务的沙箱环境不稳定,返回的数据格式总是变;上游团队的后端接口还没开发完,你的前端或下游服务只能干等着;或者,你想测试一下自己的服务在收到一个特定的、甚至是“诡异”的HTTP响应时,是否还能优雅地处理,而不是直接崩溃。这些问题,每天都在消耗着开发者的时间和耐心。

这就是API模拟测试工具存在的意义。它们让你能够创建一个可控的、可预测的“假”服务器,来模拟真实API的行为。而在众多工具中, WireMock 以其轻量、灵活和强大的特性脱颖而出。它不仅仅是一个“挡板”工具,更是一个完整的HTTP服务器模拟框架。你可以用它来模拟任何基于HTTP/HTTPS的API,无论是RESTful、SOAP还是简单的RPC风格。从定义精确的请求匹配规则,到构造复杂的响应(包括状态码、头部、JSON/XML体,甚至动态延迟),WireMock都能胜任。

我最初接触WireMock是在一个金融支付项目中,我们需要与多个外部银行网关对接。在联调阶段,银行提供的测试环境不仅响应慢,还经常不可用,严重拖慢了我们的测试进度。引入WireMock后,我们为每个外部接口都创建了对应的模拟桩(Stub),开发团队可以并行工作,测试团队也能在完全隔离的环境中进行自动化测试,效率提升了数倍。更重要的是,我们可以轻松模拟各种边界情况和异常场景(如超时、大报文、错误码),这在真实测试环境中是难以甚至无法触发的。

本指南将带你从零开始,深入WireMock的每一个核心功能,并通过一系列贴近实战的示例,让你不仅会用,更能精通,最终将其无缝集成到你的开发、测试乃至CI/CD流程中。

2. 核心概念与架构解析

在动手之前,理解WireMock的核心思想至关重要。这能帮助你在遇到复杂场景时,知道该从哪个“工具箱”里找工具。

2.1 核心组件:Stub、Mapping与Request Journal

WireMock的核心工作流程围绕着三个关键概念: Stub(存根) Mapping(映射) Request Journal(请求日志)

Stub(存根) 是WireMock的灵魂。它定义了一个规则:“当收到一个这样的请求时,就返回一个那样的响应”。一个Stub由两部分构成:

  1. 请求匹配规则(Request Matching) :指定什么样的请求会被这个Stub处理。这可以非常精细,包括URL、HTTP方法、查询参数、请求头、请求体(支持JSON、XML、文本的精确匹配或模糊匹配)。
  2. 响应定义(Response Definition) :指定当请求匹配成功后,返回什么样的HTTP响应。包括状态码(200, 404, 500等)、响应头、响应体,以及可选的响应延迟(用于模拟网络延迟或慢服务)。

Mapping(映射) 是Stub在WireMock内部的持久化表现形式。当你通过API或配置文件创建一个Stub时,WireMock会将其保存为一个Mapping。你可以将Mappings视为WireMock的“路由表”。

Request Journal(请求日志) 是WireMock的“黑匣子”。它记录了所有接收到的请求的详细信息,包括时间戳、请求方法、URL、头部、体等。这个功能对于调试至关重要。你可以通过查询Journal来验证你的应用是否发出了预期的请求,或者查看某个Stub是否被正确触发。

2.2 运行模式:独立运行与嵌入式运行

WireMock提供了两种主要的运行模式,以适应不同的使用场景。

独立运行模式 :这是最简单的入门方式。你可以将WireMock作为一个独立的JAR包运行,它会在本地启动一个HTTP服务器(默认端口8080)。这种方式非常适合:

  • 快速原型验证 :临时模拟一个API供前端或客户端调用。
  • 手动测试 :在浏览器或Postman中直接测试你的模拟接口。
  • 作为常驻测试服务 :在测试环境中部署一个独立的WireMock实例,供多个服务或测试套件共用。

启动命令非常简单: java -jar wiremock-jre8-standalone-2.35.0.jar 。你可以通过 --port 指定端口, --https-port 启用HTTPS。

嵌入式运行模式 :在这种模式下,WireMock作为一个库(Library)运行在你的JUnit测试或应用程序代码内部。这是自动化测试(尤其是单元测试和集成测试)中最常用的方式。它的优势在于:

  • 生命周期管理 :测试开始时启动WireMock,测试结束时自动关闭,完全隔离,互不干扰。
  • 编程式配置 :你可以直接在Java/Kotlin代码中动态创建、修改Stub,使测试逻辑和模拟数据紧密结合。
  • 无外部依赖 :不需要额外部署和维护一个独立的WireMock服务。

在Java测试中,你可以使用 @Rule @ClassRule (JUnit 4)或 @RegisterExtension (JUnit 5)来管理WireMock的生命周期。对于Spring Boot项目,还有 @AutoConfigureWireMock 这样更便捷的注解。

2.3 状态行为:模拟有状态的服务

默认情况下,WireMock的Stub是无状态的,同一个请求每次都会返回相同的响应。但现实中的API往往是有状态的,例如:

  • 登录接口,第一次调用返回成功和Token,第二次用相同参数调用可能返回“已登录”错误。
  • 一个计数器接口,每次调用返回递增的数字。
  • 一个查询订单状态的接口,在订单支付后,状态应从“待支付”变为“已支付”。

WireMock通过 “Scenarios”(场景) “State”(状态) 来模拟这种行为。你可以定义一个场景,其中包含多个状态(如 Started , LoggedIn )。每个Stub可以关联到某个场景的特定状态,并且在返回响应后,还可以触发状态迁移。

例如,你可以这样模拟一个简单的登录流程:

  1. 场景 authentication 初始状态为 NotLoggedIn
  2. 定义一个Stub,匹配 POST /login ,且场景状态为 NotLoggedIn 。响应返回 200 OK 和一个Token,并设置 "newScenarioState": "LoggedIn"
  3. 再定义一个Stub,同样匹配 POST /login ,但场景状态为 LoggedIn 。这个Stub返回 400 Bad Request ,提示“已登录”。

这样,你的测试就能模拟出完整的用户会话行为。

3. 环境搭建与快速入门

理论说再多,不如动手跑一遍。让我们从最直接的独立运行模式开始,快速感受WireMock的能力。

3.1 独立服务器部署与验证

首先,你需要获取WireMock的独立运行JAR包。访问 WireMock官网的下载页面 或直接去 Maven中央仓库 下载最新版本。例如,下载 wiremock-jre8-standalone-2.35.0.jar

打开终端,进入JAR包所在目录,执行:

java -jar wiremock-jre8-standalone-2.35.0.jar --port 9999

这里我们指定端口为9999,避免与本地其他服务冲突。看到控制台输出“Server is running”之类的信息,说明启动成功。

现在,WireMock就是一个运行在 http://localhost:9999 的HTTP服务器了。但它还没有任何Stub,所以访问任何路径都会返回标准的404响应。WireMock提供了一个管理API,默认在 /__admin 端点下。我们可以用它来配置Stub。

打开另一个终端,使用 curl 命令来创建我们的第一个模拟API:

curl -X POST http://localhost:9999/__admin/mappings \
  -H "Content-Type: application/json" \
  -d '{
    "request": {
      "method": "GET",
      "url": "/api/hello"
    },
    "response": {
      "status": 200,
      "body": "Hello from WireMock!",
      "headers": {
        "Content-Type": "text/plain"
      }
    }
  }'

这个命令向WireMock的管理接口发送了一个POST请求,创建了一个Mapping。其含义是:当收到一个对 /api/hello 的GET请求时,返回状态码200,内容为“Hello from WireMock!”。

现在,用浏览器或 curl 访问 http://localhost:9999/api/hello ,你应该立刻看到返回的问候语。恭喜,你的第一个模拟API已经工作了!

注意 :独立运行时,所有通过API动态创建的Stub在服务器重启后会丢失。如果需要持久化,可以在启动时添加 --root-dir 参数指定一个目录,WireMock会将该目录下的 *.json 文件加载为初始的Stub映射,并且在运行时通过API创建的Stub也会保存到该目录。

3.2 在Java单元测试中嵌入WireMock

对于后端开发者,在单元测试或集成测试中使用嵌入式WireMock更为常见。假设我们有一个 UserService ,它通过HTTP客户端调用一个外部的 UserInfoAPI

首先,在你的Maven或Gradle项目中添加WireMock依赖。

Maven:

<dependency>
    <groupId>com.github.tomakehurst</groupId>
    <artifactId>wiremock-jre8</artifactId>
    <version>2.35.0</version>
    <scope>test</scope>
</dependency>

Gradle (Kotlin DSL):

testImplementation("com.github.tomakehurst:wiremock-jre8:2.35.0")

接下来,我们编写一个JUnit 5测试用例:

import com.github.tomakehurst.wiremock.client.WireMock;
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class UserServiceTest {

    // 注册一个WireMock扩展,动态分配端口
    @RegisterExtension
    static WireMockExtension wm = WireMockExtension.newInstance()
            .options(wireMockConfig().dynamicPort()) // 使用动态端口,避免冲突
            .build();

    @Test
    void testGetUserById() {
        // 1. 配置Stub:当收到指定请求时,返回模拟的JSON响应
        wm.stubFor(get(urlPathEqualTo("/users/123"))
                .willReturn(aResponse()
                        .withStatus(200)
                        .withHeader("Content-Type", "application/json")
                        .withBody("{\"id\": 123, \"name\": \"John Doe\", \"email\": \"john@example.com\"}")));

        // 2. 获取WireMock运行的实际地址
        String apiBaseUrl = "http://localhost:" + wm.getPort();

        // 3. 创建你的服务实例,并注入模拟的API地址
        UserService userService = new UserService(apiBaseUrl);
        // 假设UserService内部会调用 http://localhost:{port}/users/123

        // 4. 执行测试逻辑
        User user = userService.getUserById("123");

        // 5. 验证业务逻辑
        assertEquals("John Doe", user.getName());
        assertEquals("john@example.com", user.getEmail());

        // 6. (可选) 验证WireMock是否收到了预期的请求
        wm.verify(1, getRequestedFor(urlPathEqualTo("/users/123")));
    }
}

在这个测试中:

  • @RegisterExtension 确保在每个测试方法运行前启动WireMock,方法结束后关闭它。
  • dynamicPort() 让WireMock自动选择一个空闲端口,完美解决了测试端口冲突的问题。
  • stubFor 方法用于定义Stub,语法非常直观,接近于自然语言描述。
  • 测试的最后,我们使用 verify 方法来断言我们的服务确实向WireMock发送了一次预期的请求。这是确保你的客户端调用逻辑正确的有力工具。

3.3 基础配置与常用启动参数

无论是独立运行还是嵌入式运行,都可以通过配置来调整WireMock的行为。以下是一些常用配置:

  • 端口与绑定

    • --port 8080 :设置HTTP端口。
    • --https-port 8443 :设置HTTPS端口。启用后,WireMock会使用自签名的证书。
    • --bind-address 0.0.0.0 :绑定到所有网络接口,允许远程连接(默认是 localhost ,仅本地访问)。 生产或测试环境慎用,注意安全
  • 持久化与文件映射

    • --root-dir ./wiremock :指定根目录。WireMock会加载该目录下 mappings/ 子目录中的所有 .json 文件作为初始Stub, __files/ 子目录中的文件可以作为响应体文件被引用。这是实现Stub“代码化”管理的基础。
    • --record-mappings :开启录制模式。WireMock会作为代理,将匹配不到的请求转发到指定的真实服务,并将请求和响应自动保存为Stub映射文件。这是快速创建初始Stub的神器。
  • 异步响应与性能

    • --async-response-enabled true :启用异步响应。当Stub配置了固定延迟( fixedDelay )时,WireMock会使用单独的线程池来处理请求,避免阻塞。
    • --container-threads 10 :设置处理请求的线程数。
  • 请求日志

    • --max-request-journal-entries 100 :设置请求日志记录的最大条数。防止内存占用过高。
    • --no-request-journal :完全禁用请求日志,适用于性能要求极高或不需要调试的场景。

对于嵌入式测试,这些配置可以通过 WireMockConfiguration 对象来设置:

WireMockExtension wm = WireMockExtension.newInstance()
    .options(wireMockConfig()
        .port(9090)
        .usingFilesUnderClasspath("wiremock-resources") // 从类路径加载文件
        .maxRequestJournalEntries(50))
    .build();

4. 核心功能深度实战

掌握了基础运行后,我们来深入WireMock最强大的部分:如何精确地匹配请求和构造响应。

4.1 请求匹配:从精确匹配到模糊匹配

WireMock的请求匹配能力极其丰富,你可以组合多个匹配条件来锁定你想要的请求。

1. URL匹配:

  • urlEqualTo(“/api/resource”) :精确匹配完整路径。
  • urlPathEqualTo(“/api/resource”) :只匹配路径,忽略查询参数。这是最常用的,因为查询参数通常用其他条件匹配。
  • urlPathMatching(“/api/users/[0-9]+”) :使用正则表达式匹配路径。适合匹配带有ID的RESTful路径。
  • urlMatching(“/api/items.*param=value.*”) :使用正则表达式匹配整个URL(含查询参数)。

2. 方法匹配:

  • any() :匹配任何HTTP方法。
  • get() , post() , put() , delete() , patch() 等:匹配特定方法。

3. 查询参数匹配:

  • withQueryParam(“page”, equalTo(“2”)) :匹配名为 page ,值等于 ”2″ 的查询参数。
  • withQueryParam(“search”, containing(“wire”)) :匹配值包含 ”wire” 的参数。
  • withQueryParam(“sort”, matching(“(asc|desc)”)) :用正则匹配参数值。
  • withQueryParam(“optional”, absent()) :匹配该参数不存在的请求。

4. 请求头匹配:

  • withHeader(“Authorization”, equalTo(“Bearer xyz”)) :精确匹配头部值。
  • withHeader(“Content-Type”, containing(“json”)) :匹配头部值包含特定字符串。
  • withHeader(“X-Custom-Header”, absent()) :匹配该头部不存在的请求。

5. 请求体匹配(这是重头戏):

  • 相等匹配 withRequestBody(equalToJson(“{\”key\”: \”value\”}”)) 。注意 equalToJson 是JSON感知的,它忽略格式差异(如空格、换行)和属性顺序。
  • 包含匹配 withRequestBody(containing(“\”status\”: \”active\””)) 。检查请求体中是否包含某个字符串。
  • JSON路径匹配 withRequestBody(matchingJsonPath(“$.user[?(@.age > 18)]”)) 。使用JsonPath表达式来匹配JSON体中的特定部分,功能非常强大。
  • XML相等匹配 withRequestBody(equalToXml(“<root><item>1</item></root>”))
  • XPath匹配 withRequestBody(matchingXPath(“/root/item”))
  • 二进制体匹配 withRequestBody(binaryEqualTo(byteArray)) 。用于匹配图片、PDF等二进制数据。

实操示例:组合匹配一个复杂的登录请求

wm.stubFor(post(urlPathEqualTo(“/auth/login”))
    .withHeader(“Content-Type”, equalTo(“application/json”))
    .withQueryParam(“source”, equalTo(“mobile”))
    .withRequestBody(matchingJsonPath(“$.username”)) // 要求请求体JSON中有username字段
    .withRequestBody(matchingJsonPath(“$.password”)) // 要求有password字段
    .willReturn(aResponse()
        .withStatus(200)
        .withBody(“{\”token\”: \”fake-jwt-token\”, \”expiresIn\”: 3600}”)));

这个Stub只匹配来自移动端( source=mobile )、内容类型为JSON、并且请求体包含 username password 字段的登录请求。

4.2 响应构造:静态、动态与故障注入

匹配到请求后,我们需要返回一个响应。WireMock的响应构造同样灵活。

1. 基础响应:

  • withStatus(404) :设置HTTP状态码。
  • withHeader(“Content-Type”, “application/json”) :添加响应头。
  • withBody(“Hello World”) :设置文本响应体。
  • withBodyFile(“response.json”) :从 __files 目录下的文件读取响应体内容。这是管理复杂响应体的最佳实践,保持测试代码简洁。
  • withJsonBody(objectNode) :直接设置一个Jackson的 ObjectNode 作为JSON响应体。

2. 响应延迟:模拟网络延迟与慢服务 这是测试系统超时和熔断机制的关键。

  • withFixedDelay(2000) :固定延迟2秒。
  • withRandomDelay(new UniformDistribution(500, 1500)) :随机延迟,在500到1500毫秒之间均匀分布。
  • withLogNormalRandomDelay(90, 0.1) :使用对数正态分布生成延迟,可以模拟具有长尾效应的延迟。

3. 故障注入:测试系统的鲁棒性

  • withFault(Fault.MALFORMED_RESPONSE_CHUNK) :发送一个格式错误的HTTP响应块,模拟网络传输错误。
  • withFault(Fault.EMPTY_RESPONSE) :建立连接后立即关闭,不发送任何数据。
  • withFault(Fault.RANDOM_DATA_THEN_CLOSE) :先发送一些随机数据,然后关闭连接。
  • withFault(Fault.CONNECTION_RESET_BY_PEER) :模拟连接被对端重置。

4. 代理与录制:

  • .proxiedFrom(“https://real-api.example.com”) :将未匹配的请求转发到真实的后端服务。这在“录制模式”下特别有用,可以快速生成一批Stub。
  • 启动独立服务器时使用 –record-mappings –proxy-all=”http://real-service” ,WireMock会自动将所有请求代理到真实服务,并将交互记录保存为Stub文件。

5. 状态码模板: 你可以直接返回一个包含状态码描述的文件。在 __files 目录下创建文件,如 not-found.json ,内容为 {“error”: “Not Found”} 。然后在Stub中配置: .willReturn(aResponse().withStatus(404).withBodyFile(“not-found.json”))

4.3 高级特性:状态机、响应模板与Webhook

1. 场景与状态(Stateful Behavior) 如前所述,通过Scenarios模拟有状态API。在JSON定义中如下所示:

{
  “scenarioName”: “OrderState”,
  “requiredScenarioState”: “CREATED”,
  “newScenarioState”: “PAID”,
  “request”: { … },
  “response”: { … }
}

在Java API中,使用 inScenario(“OrderState”).whenScenarioStateIs(“CREATED”).willSetStateTo(“PAID”) 来链式配置。

2. 响应模板(Response Templating) 这是WireMock的王牌功能之一,允许你动态生成响应内容。需要先启用该特性(独立运行加 –enable-stub-cors –global-response-templating ,嵌入式模式配置 wireMockConfig().extensions(new ResponseTemplateTransformer()) )。

在响应体中,你可以使用Handlebars模板语法引用请求中的信息:

{
  “status”: “success”,
  “requestId”: “{{request.requestLine.baseUrl}}{{request.path}}”,
  “timestamp”: “{{now}}”,
  “queryValue”: “{{request.query.search}}”,
  “headerValue”: “{{request.headers.X-Custom-Header}}”,
  “jsonBodyField”: “{{jsonPath request.body ‘$.user.name’}}”
}

你还可以使用内置助手函数进行字符串操作、数学计算、条件判断等,生成高度动态化的响应。

3. 回调与Webhooks WireMock可以在返回响应后,向一个外部URL发送回调(Webhook)。这可以用来模拟异步通知,或者在你的测试中触发后续的验证逻辑。

wm.stubFor(post(urlPathEqualTo(“/order”))
    .willReturn(ok())
    .withPostServeAction(“webhook”, webhook()
        .withMethod(POST)
        .withUrl(“http://my-test-listener/callback”)
        .withHeader(“Content-Type”, “application/json”)
        .withBody(“{\”orderId\”: \”{{jsonPath request.body ‘$.id’}}\”}”)));

4. 请求验证(Verification) 在测试中,除了断言业务结果,验证是否发出了正确的HTTP请求同样重要。

// 验证至少发送了1次POST请求到 /webhook,且请求体包含特定文本
wm.verify(moreThanOrExactly(1), postRequestedFor(urlPathEqualTo(“/webhook”))
    .withRequestBody(containing(“callback”)));

// 获取所有捕获的请求详情,进行更复杂的断言
List<LoggedRequest> requests = findAll(postRequestedFor(urlPathEqualTo(“/api/data”)));
assertEquals(1, requests.size());
LoggedRequest request = requests.get(0);
assertTrue(request.getBodyAsString().contains(“expected”));

5. 集成与最佳实践

将WireMock用起来是一回事,用得好、用得稳是另一回事。下面分享一些在真实项目中总结的集成模式和最佳实践。

5.1 与测试框架深度集成

JUnit 4/5: 如上文所示,使用 @Rule @RegisterExtension 是标准做法。对于Spring Boot测试, @AutoConfigureWireMock 注解是绝配,它能自动将模拟服务的地址注入到Spring的环境属性中。

@SpringBootTest
@AutoConfigureWireMock(port = 0) // 随机端口
public class MyIntegrationTest {
    @Test
    void testWithWireMock() {
        // 你的服务中通过 @Value(“${external.api.url}”) 注入的地址会自动被替换为WireMock地址
        // 直接使用 wireMockServer 来配置Stub
        wireMockServer.stubFor(...);
        // … 执行测试
    }
}

Pact测试(消费者驱动契约): WireMock是实现Pact Provider测试的理想工具。你可以将Pact契约文件直接转换为WireMock的Stub,从而验证你的服务实现是否符合消费者期望。

Cucumber / BDD: 在Cucumber的步骤定义(Step Definitions)中启动和配置WireMock,可以为每个场景(Scenario)提供独立的、定义良好的模拟服务状态。

5.2 Stub管理策略:从JSON文件到代码化

随着项目规模扩大,Stub数量会急剧增长。良好的管理策略至关重要。

  1. 基于文件的静态映射 :这是最推荐的方式。将所有Stub定义以JSON文件的形式存放在 src/test/resources/wiremock/mappings/ 目录下。响应体文件放在 __files/ 子目录。这样做的优点是:

    • 版本控制 :Stub定义和代码一起被Git管理,变更历史清晰。
    • 可读性 :JSON文件易于阅读和修改。
    • 共享 :团队成员可以共享同一套模拟数据。
    • 独立运行 :独立运行的WireMock服务器可以直接加载这些文件。
  2. 编程式动态生成 :在测试类中通过Java API动态创建Stub。适用于那些需要根据测试用例参数化生成不同响应的场景。但要注意,过多的内联Stub代码会降低测试的可读性。

  3. 组合使用 :最佳实践是“基础Stub文件化,动态部分编程化”。将通用的、稳定的API模拟用JSON文件定义。在具体的测试类中,如果需要覆盖某个特定响应或添加额外的Stub,再使用编程式API。WireMock会合并两者,后定义的Stub优先级更高。

  4. 使用 WireMockClassRule :在JUnit 4中,如果你希望所有测试类共享一套初始Stub(从文件加载),可以使用 @ClassRule 配合 WireMockClassRule ,并配置 filesUnderClasspath

5.3 CI/CD流水线集成

在持续集成环境中,WireMock通常以独立容器的形式运行。

Docker化部署: WireMock提供了官方Docker镜像: wiremock/wiremock 。你可以在 docker-compose.yml 中这样定义:

version: ‘3.8’
services:
  mock-server:
    image: wiremock/wiremock:latest
    ports:
      - “8080:8080”
    volumes:
      - ./stubs:/home/wiremock/mappings # 将本地的stubs目录挂载为映射目录
      - ./files:/home/wiremock/__files # 挂载响应体文件目录
    command: [“–global-response-templating”, “–verbose”] # 传递启动参数

在CI脚本中,你只需要启动这个Compose服务,你的测试套件就可以连接到 http://mock-server:8080 进行测试了。

动态配置与健康检查: 在流水线中,你可能需要在测试开始前,根据代码分支或测试类型加载不同的Stub集合。可以通过在启动容器前,用脚本替换 /home/wiremock 目录下的文件来实现。同时,确保在测试开始前,先调用WireMock的 /__admin/health 端点确认服务已就绪。

性能考量: 在集成测试中,避免为每个测试方法都重新启动WireMock容器,这会显著增加测试时间。通常的做法是在整个测试套件开始时启动一次,并在所有测试中复用。但要小心处理Stub的状态,确保测试之间相互隔离。可以使用 WireMock.reset() API在每次测试后重置所有Stub和请求日志。

5.4 常见陷阱与性能优化

1. Stub匹配优先级与顺序 WireMock按照Stub被添加的顺序进行匹配,第一个匹配到的Stub生效。这可能导致一个宽泛的Stub(如 urlMatching(“.*”) )意外地“吃掉”了你期望被更具体Stub处理的请求。 最佳实践是:先定义具体的Stub,再定义通用的Stub。 在文件加载时,可以按文件名排序来控制加载顺序。

2. 请求体匹配的坑

  • JSON匹配的严格性 equalToJson 默认是“严格”模式,要求提供的JSON字符串和请求体完全一致(忽略格式和顺序)。如果你只想验证结构,可以使用 equalToJson(json, true, true) ,后两个参数分别控制是否忽略数组顺序和额外字段。
  • 二进制请求体 :匹配二进制请求体时,确保你的测试代码发送的数据和 binaryEqualTo 中定义的字节数组完全一致,一个字节都不能差。

3. 异步响应与超时 如果你为Stub配置了很长的延迟( fixedDelay ),而你的HTTP客户端设置了较短的读超时,那么测试就会因超时失败。确保你的客户端超时设置与模拟的延迟相匹配,或者使用WireMock的异步响应特性( –async-response-enabled ),它允许立即返回一个202 Accepted,然后再在后台处理延迟响应(但这需要客户端支持异步通知)。

4. 重置状态 在测试类中,如果使用了场景(Scenarios)模拟有状态API,一定要在 @Before @After 方法中重置WireMock的状态: WireMock.resetAllScenarios() 。否则,一个测试留下的状态会影响下一个测试。

5. 文件路径问题 当使用 withBodyFile(“myfile.json”) 时,文件路径是相对于WireMock运行时的“文件根目录”的。在独立模式下,是 –root-dir 参数指定的目录下的 __files 子目录。在嵌入式模式下,如果你配置了 usingFilesUnderClasspath(“wiremock”) ,那么它会从类路径的 wiremock/__files/ 目录下寻找 myfile.json 。务必确认文件路径正确。

6. 性能监控 在高并发测试中,WireMock本身可能成为瓶颈。可以监控其JVM内存和CPU使用情况。如果遇到性能问题,考虑增加 –container-threads ,启用异步响应,或者将多个简单的WireMock实例放在负载均衡器后面,分散压力。

6. 真实项目案例拆解:模拟一个电商订单支付流程

让我们通过一个模拟电商支付流程的复杂案例,将上述所有知识点串联起来。假设我们要测试的“订单服务”需要与三个外部系统交互: 用户服务 库存服务 支付网关

目标 :测试“创建订单并支付”这个核心流程。

外部依赖模拟设计:

  1. 用户服务(User Service) :提供用户信息验证。
  2. 库存服务(Inventory Service) :检查并扣减商品库存。
  3. 支付网关(Payment Gateway) :处理支付请求并返回结果。

我们将为每个依赖创建独立的Stub,并模拟一个包含成功和失败分支的完整流程。

步骤1:准备Stub映射文件 我们在 src/test/resources/wiremock/mappings/ 目录下创建JSON文件。

  • user-service-verify-user.json (验证用户)
{
  “priority”: 1,
  “request”: {
    “method”: “GET”,
    “urlPath”: “/users/verify”,
    “queryParameters”: {
      “userId”: {
        “matches”: “^U\\d+$”
      }
    }
  },
  “response”: {
    “status”: 200,
    “headers”: {
      “Content-Type”: “application/json”
    },
    “jsonBody”: {
      “valid”: true,
      “userName”: “{{request.query.userId}}的模拟用户”
    }
  }
}
  • inventory-service-reserve.json (预占库存)
{
  “scenarioName”: “InventoryScenario”,
  “requiredScenarioState”: “Started”,
  “newScenarioState”: “InventoryReserved”,
  “request”: {
    “method”: “POST”,
    “urlPath”: “/inventory/reserve”,
    “bodyPatterns”: [
      {
        “matchesJsonPath”: “$.items[?(@.sku)]”
      }
    ]
  },
  “response”: {
    “status”: 200,
    “headers”: {
      “Content-Type”: “application/json”
    },
    “jsonBody”: {
      “success”: true,
      “reservationId”: “RES-{{randomValue length=10 type=ALPHANUMERIC}}”
    },
    “fixedDelayMilliseconds”: 100 // 模拟一点处理延迟
  }
}
  • payment-gateway-pay.json (支付 - 成功分支)
{
  “scenarioName”: “PaymentScenario”,
  “requiredScenarioState”: “Started”,
  “newScenarioState”: “PaymentProcessed”,
  “priority”: 10,
  “request”: {
    “method”: “POST”,
    “urlPath”: “/payment/charge”,
    “headers”: {
      “Content-Type”: {
        “equalTo”: “application/json”
      }
    },
    “bodyPatterns”: [
      {
        “matchesJsonPath”: “$.amount”,
        “matchesJsonPath”: “$.orderId”
      }
    ]
  },
  “response”: {
    “status”: 201,
    “headers”: {
      “Content-Type”: “application/json”
    },
    “jsonBody”: {
      “transactionId”: “TXN-{{now format=’yyyyMMddHHmmss’}}-{{randomValue length=5 type=ALPHANUMERIC}}”,
      “status”: “SUCCESS”,
      “amount”: “{{jsonPath request.body ‘$.amount’}}”
    }
  }
}
  • payment-gateway-pay-failure.json (支付 - 失败分支)
{
  “scenarioName”: “PaymentScenario”,
  “requiredScenarioState”: “Started”,
  “newScenarioState”: “Started”, // 状态不变,支付失败
  “priority”: 5, // 优先级高于成功分支,用于模拟特定失败条件
  “request”: {
    “method”: “POST”,
    “urlPath”: “/payment/charge”,
    “bodyPatterns”: [
      {
        “matchesJsonPath”: “$.amount[?(@ < 1)]” // 模拟金额小于1时支付失败
      }
    ]
  },
  “response”: {
    “status”: 400,
    “headers”: {
      “Content-Type”: “application/json”
    },
    “jsonBody”: {
      “error”: “INVALID_AMOUNT”,
      “message”: “支付金额必须大于等于1元”
    },
    “fixedDelayMilliseconds”: 500
  }
}

步骤2:编写集成测试

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWireMock(port = 0)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
public class OrderServiceIntegrationTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void testCreateAndPayOrder_Success() {
        // 给定:一个有效的用户ID和商品列表
        String userId = “U1001”;
        List<OrderItem> items = List.of(new OrderItem(“SKU123”, 2));

        // 当:调用创建订单并支付
        OrderResponse response = orderService.createAndPayOrder(userId, items, new BigDecimal(“99.99”));

        // 那么:验证订单创建成功,包含交易ID
        assertNotNull(response);
        assertEquals(OrderStatus.PAID, response.getStatus());
        assertTrue(response.getTransactionId().startsWith(“TXN-”));

        // 验证WireMock确实收到了预期的请求序列
        // 1. 验证用户服务调用
        wireMockServer.verify(1, getRequestedFor(urlPathEqualTo(“/users/verify”))
                .withQueryParam(“userId”, equalTo(userId)));

        // 2. 验证库存服务调用
        wireMockServer.verify(1, postRequestedFor(urlPathEqualTo(“/inventory/reserve”))
                .withRequestBody(matchingJsonPath(“$.items[?(@.sku == ‘SKU123’)]”)));

        // 3. 验证支付网关调用
        wireMockServer.verify(1, postRequestedFor(urlPathEqualTo(“/payment/charge”))
                .withRequestBody(matchingJsonPath(“$.amount[?(@ == 99.99)]”)));
    }

    @Test
    void testCreateAndPayOrder_PaymentFailure() {
        // 给定:一个金额无效的支付请求
        String userId = “U1001”;
        List<OrderItem> items = List.of(new OrderItem(“SKU123”, 1));

        // 当 & 那么:期望支付失败,业务抛出特定异常
        assertThrows(PaymentFailedException.class, () -> {
            orderService.createAndPayOrder(userId, items, new BigDecimal(“0.5”)); // 金额0.5,触发失败Stub
        });

        // 验证支付网关收到了请求,但用户服务和库存服务可能未被调用(取决于业务逻辑设计)
        wireMockServer.verify(1, postRequestedFor(urlPathEqualTo(“/payment/charge”))
                .withRequestBody(matchingJsonPath(“$.amount[?(@ == 0.5)]”)));
        // 注意:这里库存可能已被预占,需要根据业务补偿逻辑设计对应的Stub来测试回滚
    }
}

步骤3:测试异常与补偿流程 一个健壮的系统还需要处理外部服务失败的情况。我们需要添加模拟超时和服务器错误的Stub。

  • inventory-service-timeout.json (库存服务超时)
{
  “request”: {
    “method”: “POST”,
    “urlPath”: “/inventory/reserve”
  },
  “response”: {
    “status”: 200,
    “fixedDelayMilliseconds”: 10000 // 10秒超时,大于我们客户端的读超时设置
  }
}

在测试中,我们可以使用这个Stub来验证订单服务是否正确地处理了库存服务超时,并执行了相应的补偿逻辑(如释放已占资源、返回友好错误)。

通过这个案例,你可以看到WireMock如何帮助我们构建一个完全可控的、覆盖各种正常和异常路径的测试环境。它让集成测试从“碰运气”的联调,变成了可重复、可预测、可自动化的高质量保障环节。

最后,我个人在实际项目中的体会是,WireMock的价值随着微服务架构的复杂度提升而指数级增长。它不仅仅是一个测试工具,更是一种促进团队协作的契约。前端和后端、上游和下游,可以基于WireMock的Stub定义先行对接,并行开发。将Stub文件视为一种“API模拟契约”纳入版本管理,是提升整个团队交付效率和系统质量的关键实践。

更多推荐