SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor

VPS 自托管 API Mock 与测试:WireMock 和 Hurl

了解 API Mock 与测试运行器的区别:用 WireMock 将存根提交到 Git,用 Hurl 在 CI 中执行测试,并让报告在服务器重建后仍可保留。

共享一个代码仓库的两个任务

自托管 API 模拟和测试是两项不同的任务。将它们混为一谈会浪费一周时间。模拟服务器用于替代 CI 无法调用的依赖项,例如支付提供商、合作方 API、受速率限制的上游服务,或另一团队尚未发布的服务。API 测试运行器按固定顺序调用您自己的端点,并验证响应结果,再将一个响应中的值带入下一个请求。

两者没有交集。模拟服务器不会报告通过或失败。测试运行器也不关心支付提供商在银行卡被拒绝时返回什么。对于已经租用服务器的大多数团队,最终都会各运行一个服务,并由同一个 Docker Compose 文件启动,在同一个 pull request 中审查。

为什么要自行托管 API 模拟和测试?

您的固定测试数据通常与生产数据结构一致。API 测试中的请求体可能是真实客户记录,只是改了姓名;也可能连姓名都没有改,因为没人检查。录制生成的存根更危险:代理录制会保存上游实际返回的内容,因此通过录制构建的存根目录可能一直包含有效令牌和客户电子邮件地址,直到有人逐个读取所有文件。在托管服务上,这些数据会变成他人的安全事件,也会变成您的数据泄露事件。

第二个原因是网络可达性。绑定到私有地址的服务无法从托管运行器访问,因此测试根本无法运行。每种变通方案都有成本。为了测试 API 而将其发布到互联网,会破坏原本保持私有的原因。隧道或公开的预发布副本都需要额外维护,而且预发布副本会在两次发布之间逐渐偏离生产环境。同一私有网络中的运行器可以直接调用该服务,不需要这些额外组件。这正是使用 自行托管的 GitHub Actions 运行器的实际原因。

应运行哪个自托管 mock 服务器?

这些服务都以容器形式运行在您拥有的服务器上。关键问题是每个服务将什么作为事实来源,因为这决定了重建容器是无需额外处理,还是会耗费您一个下午。

  • WireMock 将每个 stub 保存为 mappings/ 目录中的 JSON 文件,大型响应正文保存在 __files/ 中。其镜像为 wiremock/wiremock,容器内的根目录为 /home/wiremock,同时还可作为录制代理运行。文件保存在磁盘上,意味着 mock 可以像其他代码一样纳入 git。
  • Mockoon CLI 将完整的 mock API 保存为一个 JSON 数据文件。使用 npm install -g @mockoon/cli 安装,使用 mockoon-cli start --data ./data-file.json 启动;也可以运行 mockoon/cli 镜像,并将该文件绑定挂载到容器中。桌面应用会编辑同一个文件,因此可以在 UI 中设计,再提交结果,二者保持兼容。
  • MockServer 使用 mockserver/mockserver 镜像运行,并监听端口 1080。Expectations 通过其 REST API 写入,这便于测试代码调用,但不适合作为部署方式:通过 HTTP 请求创建的 expectation 会在容器重启后消失。对于需要永久保留的 stub,应使用其 JSON 初始化文件。
  • Prism 根据 OpenAPI 文档构建 mock,而不是读取单独的 stub 文件。使用 npm install -g @stoplight/prism-cli 安装,然后运行 prism mock openapi.yaml。在容器中运行时还需添加 -h 0.0.0.0,因为 Prism 默认绑定到 localhost,否则容器外部无法访问。
  • Microcks 是功能较完整的选项:它提供 Web UI,可导入 OpenAPI 文档和 Postman 集合,然后将其作为 mock 提供服务并运行契约测试。完整安装需要 MongoDB 和 Keycloak,异步功能还需要 Kafka。全 in one microcks-uber 镜像内置内存 MongoDB,项目文档说明该配置适合临时使用。因此,您在 UI 中创建的内容应视为临时数据,并将源文件保存在 git 中。

应运行哪种自托管 API 测试运行器?

这里的任务是一个连续流程:进行身份验证、创建订单、读回订单,并断言状态已更改。后一个请求需要使用前一个响应中捕获的值。无法在调用之间传递状态的工具只能执行健康检查,不能用于 API 测试。

  • Hurl 使用单个二进制文件运行纯文本格式的 HTTP 请求文件。[Captures] 部分从响应中提取值,[Asserts] 部分对这些值进行检查,--test 将其转换为带有摘要和退出码的测试运行器。截至 2026 年 8 月,当前版本为 8.0.1。
  • Bruno CLI 运行包含 .bru 文件的目录。使用 npm install -g @usebruno/cli 安装,然后运行 bru run folder --env Local --reporter-junit results.xml。其集合格式按设计就是目录中的文本文件,因此代码审查中的差异清晰易读。
  • Newman 可在 Postman 外运行 Postman 集合:npm install -g newman,然后运行 newman run collection.json -r cli,junit --reporter-junit-export results.xml。问题在于文件格式。集合是一个导出的 JSON blob,因此编辑工作在 Postman 中完成,而 git 中的文件只是副本,容易过时。
  • Schemathesis 属于另一类检查工具。它读取 OpenAPI schema,并生成测试用例,尝试获得 schema 声明为不可能出现的响应:uvx schemathesis run https://your.api/openapi.json。它可以发现崩溃和契约违规,但不了解您的业务规则,因此应与脚本化测试套件并用,而不是替代后者。
  • Hoppscotch 自托管版本提供 Web UI,但需要一个 Postgres 实例。安装前应了解这一取舍:集合存储在数据库中,而不是您的代码仓库中。

有一个工具应避免使用。Step CI 仍会出现在各种工具汇总中,其 YAML 工作流格式也比较易读,但该仓库上次收到提交是在 2024 年 8 月。位于 CI 与 API 之间的程序不应使用无人维护的代码。

将模拟服务器置于防火墙之后

下面的配置使用 WireMock 代替支付提供商。如果您不熟悉 compose 文件格式,请参阅 VPS 上的 Docker Compose。本节会用到其中的生命周期命令。

services:
  mock-payments:
    image: wiremock/wiremock:3.13.2
    command: ["--verbose"]
    volumes:
      - ./mocks/payments:/home/wiremock
    ports:
      - "127.0.0.1:8080:8080"
    restart: unless-stopped

端口前的 127.0.0.1: 前缀很重要。直接使用 8080:8080 会将模拟服务器发布到所有网络接口,包括公网 IP。即使 ufw 拒绝该端口,服务仍然可以访问,因为 Docker 会将自己的规则写入 DOCKER iptables 链,并且这些规则会先于 ufw 的 INPUT 规则进行匹配。请改为绑定回环地址,或绑定私有接口地址。这样,内核就不会接受来自外部的连接。

然后让待测服务指向模拟服务器。当服务与模拟服务器运行在同一个 compose 项目中时,模拟服务器的基础 URL 是 http://mock-payments:8080,因为 compose 会在自己的网络中解析服务名。当服务运行在宿主机上时,基础 URL 是 http://127.0.0.1:8080。请通过环境变量设置该值,不要写入代码,否则测试 URL 可能会随应用发布到生产环境。

将存根放在 ./mocks/payments/mappings/ 中,每个 JSON 文件包含一个存根。

{
  "request": {
    "method": "POST",
    "urlPath": "/v1/charges",
    "bodyPatterns": [{ "matchesJsonPath": "$.amount" }]
  },
  "response": {
    "status": 201,
    "headers": { "Content-Type": "application/json" },
    "jsonBody": { "id": "ch_test_001", "status": "succeeded", "amount": 4200 }
  }
}

启动服务,然后检查实际加载的内容。

docker compose up -d --wait mock-payments
curl -fsS http://127.0.0.1:8080/__admin/mappings

--wait 会一直等待,直到容器报告健康状态。之所以可行,是因为 WireMock 镜像为其 /__admin/health 端点提供了 HEALTHCHECKmappings 命令会列出服务器读取的所有存根。如果您编写的存根不在列表中,说明它从未被加载:请确认文件位于 mappings/ 下,而不是挂载目录的根目录中,并确认 JSON 可以正确解析。

请求到达但没有匹配的存根时,WireMock 会返回 404,响应正文以 Request was not matched 开头,后面附带与它所持有的最接近存根的差异。请先阅读该差异,再修改任何内容,因为其中会指出具体不一致的字段。该字段通常是一个包含 /v1/charge 的路径,而存根中写的是 /v1/charges

将测试编写为在调用之间传递状态的序列

Hurl 文件是纯文本文件。从项目的 releases 中安装 deb 软件包。

VERSION=8.0.1
curl --location --remote-name https://github.com/Orange-OpenSource/hurl/releases/download/$VERSION/hurl_${VERSION}_amd64.deb
sudo apt update && sudo apt install ./hurl_${VERSION}_amd64.deb

tests/checkout.hurl 中,有一个测试套件会通过 mock 测试您自己的 API。

POST {{base_url}}/orders
Content-Type: application/json
{
  "sku": "ssd-1tb",
  "amount": 4200
}
HTTP 201
[Captures]
order_id: jsonpath "$['id']"

GET {{base_url}}/orders/{{order_id}}
HTTP 200
[Asserts]
jsonpath "$.status" == "paid"
jsonpath "$.charge_id" == "ch_test_001"

[Captures] 块使这成为 API 测试,而不是两个互不相关的请求。order_id 从第一个响应中读取,并插入到第二个请求的 URL 中。对 charge_id 的断言是整个测试的核心:它证明您的服务调用了支付提供商,并保存了返回的数据;它比较的值就是您写入 WireMock stub 的值。现在,一个文件即可覆盖整个流程的两部分。

hurl --test --variable base_url=http://127.0.0.1:3000 \
  --report-junit reports/junit.xml \
  --report-json reports/json \
  tests/

测试通过时,每个文件输出一行,最后输出摘要。

tests/checkout.hurl: Success (2 request(s) in 61 ms)
Executed files:    1
Executed requests: 2 (30.1/s)
Succeeded files:   1 (100.0%)
Failed files:      0 (0.0%)
Duration:          64 ms

测试失败时,会输出 error: Assert failure,其中包含文件名和行号,然后显示实际值与预期值的对比;随后 hurl 以非零状态退出,使 CI 停止。如果 status 读取到 pending,而您预期的是 paid,说明您的服务没有处理 mock 的响应。接下来应查看 /__admin/requests 中的 WireMock 请求日志,其中会显示请求是否实际到达 mock。

从您自己的 CI runner 触发测试套件

如果 runner 注册在同一台主机上,工作流很简单。runner 是主机上的普通进程,因此该主机必须安装 dockerhurl。托管镜像中的任何内容都不会自动继承。

name: api-tests
on: [push]
jobs:
  hurl:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v4
      - name: Start the mock
        run: docker compose up -d --wait mock-payments
      - name: Run the suite
        run: hurl --test --variable base_url=http://127.0.0.1:3000 --report-junit reports/junit.xml tests/
      - name: Archive the reports
        if: always()
        run: install -d /srv/api-tests/reports/$GITHUB_SHA && cp -r reports/. /srv/api-tests/reports/$GITHUB_SHA/
      - name: Stop the mock
        if: always()
        run: docker compose down

存档步骤中的 if: always() 很重要。如果没有它,测试运行失败时会跳过复制操作,您正好会丢失想要查看的报告。复制目标还必须位于工作区之外,因为 runner 会在下一个作业开始前清理工作区,报告也会随之删除。

保留结果,而不仅是最近一次运行结果

每个提交对应一个 JUnit XML 文件,可以回答一个问题:测试是否通过。但它无法说明某个端点从什么时候开始变慢,因为停止打开这些文件后,不会有任何程序读取它们。要分析趋势,可以在同一台服务器上的小型数据库中为每次运行追加一行记录。使用一张表保存提交 SHA、文件名、通过数量、失败数量和持续时间就足够了;将其放在 VPS 上的 SQLite 生产环境 中也是合理选择:它只有一个文件,不需要服务器进程,完整历史记录还会随现有备份一并保存。应解析 Hurl 的 --report-json 输出,而不是 JUnit XML,因为前者是两者中面向机器读取的格式。

容器重建后必须保留的内容

Mock 定义和测试套件都是源代码。它们应与所描述的服务一起存放在代码仓库中;修改端点时,也应在同一个 pull request 中修改这些内容。通过 Web UI 编辑的 stub,或在运行时通过 MockServer REST API 推送的预期,只存在于该容器的内存或该工具的数据库中。运行 docker compose down 后,这些内容就会消失,直到某个测试因错误原因通过时,才有人发现问题。如果您的代码仓库也运行在自有硬件上,自托管 git 服务器可以让 fixture 和服务处于同一个信任边界内。

接下来是实用规则。固定镜像标签,因为 latest 可能在代码仓库未发生变化的情况下改变 mock 匹配请求的方式,而这种失败很难追溯到根本原因。工具不需要写入 stub 目录时,应以只读方式挂载这些目录。不要将 mock 的 stub 放入命名 Docker 卷,因为这样卷会成为事实来源,而 git 中的副本会在不知情的情况下变得不正确。

还有一点很容易被忽略。如果您通过代理记录真实流量来生成 stub,请在提交前阅读每个生成的文件。记录会完整保存上游返回的内容,包括 bearer token 和客户电子邮件地址。提交后,这些内容会永久进入代码仓库,因为 git 会在历史记录中保留已删除的内容。

FAQ

API mock server 和 API 测试运行器有什么区别?

Mock server 会响应请求。它用于替代 CI 无法调用的依赖项,并且不会报告通过或失败。API 测试运行器会向您自己的服务发送请求,验证响应,将一次调用中的值传递到下一次调用,并在断言失败时以非零状态退出。两者解决的是不同问题。典型配置会同时运行两者:测试运行器调用您的服务,而您的服务调用 mock。

我可以从托管 CI 运行器测试内部 API 吗?

如果不将其暴露到外部,则不能。托管运行器位于您的网络之外,因此无法访问绑定到私有地址的服务。您可以发布 API、运行隧道,或维护一个公开的 staging 副本,但每种方式都会增加一个可能发生故障或造成泄露的系统。位于同一私有网络中的运行器可以直接调用该服务,这也是团队自行托管此类工作的主要实际原因。

Mock stub 和 API 测试套件应存放在哪里?

存放在 git 中,与其描述的服务放在一起。将定义存储为文件的工具,例如 WireMock 的 mappings/ 目录、Mockoon 的数据文件、Hurl 文件和 Bruno 的 .bru 文件夹,可以支持代码审查,并且无需额外成本即可重建容器。将定义存储在数据库或 Web UI 中的工具需要备份方案和导出步骤,而导出往往会被遗忘,直到容器已经丢失。

为什么我的 mock 返回 404,而 stub 看起来是正确的?

WireMock 只有在请求完全匹配时才会提供 stub。未匹配的请求会收到 404,响应正文以 Request was not matched 开头,随后给出与最接近 stub 的差异;该差异会指出不一致的字段。常见原因包括路径末尾多了斜杠、stub 要求但客户端未发送的 Content-Type 标头、在 stub 需要 urlPathPattern 表示可变路径段的位置使用了 urlPath,以及与请求负载不匹配的正文匹配器。首先检查 /__admin/requests,确认请求确实到达了 mock。

如果我有 staging 环境,还需要 mock 吗?

需要,原因有两个。您无法控制的上游服务的 staging 副本仍可能宕机或触发限流,导致测试套件因与您的代码无关的原因失败。它也无法生成您最需要测试的响应,例如拒付或网关超时。Mock 可以按需返回这些响应,并以本地网络速度运行,从而将针对 sandbox 需要数分钟的测试套件缩短到数秒。发布前的最终检查使用 staging,CI 中使用 mock。