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

自架 API mock 與測試:WireMock、Hurl VPS 部署

了解 mock server 與 API test runner 的差異,在 VPS 以 Docker Compose 執行 WireMock 與 Hurl,將 stub 存入 git,讓 CI 測試報告在重建後仍可保留。

共用同一個 repository 的兩項工作

自架 API mock 與測試是兩項不同的工作。把它們當成同一件事,可能浪費一週時間。mock server 代表 CI 無法呼叫的相依服務,例如付款供應商、合作夥伴 API、受速率限制的上游服務,或其他團隊尚未發布的服務。API test runner 會依固定順序呼叫自有端點,驗證回應內容,並將一個回應中的值帶入下一個請求。

兩者沒有重疊。mock server 不會回報通過或失敗。test runner 也不會判斷付款供應商在卡片遭拒時應回傳什麼。多數已租用伺服器的團隊,最後會各自執行一個服務,使用同一個 Docker Compose 檔案啟動,並在同一個 pull request 中進行審查。

為什麼要自行託管 API mock 與測試?

你的 fixture 是符合正式環境形狀的資料。API 測試中的 request body,可能是真實的客戶紀錄,只是修改了姓名;也可能連姓名都沒有修改,因為沒有人檢查。錄製的 stub 更危險:proxy recording 會儲存 upstream 實際回傳的內容,因此透過錄製建立的 stub 目錄,可能一直包含有效 token 與客戶電子郵件地址,直到有人逐一讀取每個檔案為止。在 hosted service 上,這些資料會成為他人的資安事件,也會造成你的資料外洩。

第二個原因是可連線性。繫結至私有位址的服務,無法從 hosted runner 連線,因此測試根本無法執行。每種替代方案都有成本。為了測試 API 而將其公開到網際網路,會失去原本設為私有的理由。tunnel 或公開的 staging 副本則是另一個需要維護的系統,而且 staging 副本會在每次發布之間逐漸偏離 production。同一個私有網路上的 runner 可直接呼叫該服務,不需要這些額外措施;這正是 自行託管 GitHub Actions runner 的實際理由。

應該執行哪一種自架 mock server?

這些工具都會以容器形式執行在你擁有的主機上。真正重要的是,各工具將什麼視為真實來源,因為這會決定重建容器時是毫無成本,還是需要耗上一個下午。

  • 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 映像檔,並將該檔案 bind mount 進容器。桌面應用程式也會編輯相同的檔案,因此可以在 UI 中設計,再提交結果,兩者相容。
  • MockServer 會從 mockserver/mockserver 映像檔啟動,並監聽 port 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 是功能較完整的選項:提供網頁 UI,可匯入 OpenAPI 文件與 Postman collections,接著將其提供為 mock,並執行 contract tests。完整安裝需要 MongoDB 和 Keycloak;若使用非同步功能,還需要 Kafka。整合式 microcks-uber 映像檔內含記憶體內 MongoDB,專案文件說明該映像檔適合暫時性使用。因此,請將在該 UI 中建立的任何內容視為可丟棄資料,並將來源檔案保存在 git 中。

應該執行哪個自架 API 測試執行器?

這項工作是一連串步驟:驗證身分、建立訂單、讀取訂單,並確認狀態已變更。你需要擷取一個回應中的值,再用於下一個請求。無法在呼叫之間保留狀態的工具只能執行健康檢查,不能算是 API 測試工具。

  • Hurl 會由單一 binary 執行純文字格式的 HTTP 請求檔案。[Captures] section 會從回應中擷取值,[Asserts] section 會檢查這些值,而 --test 會將其轉換為具備摘要與 exit code 的測試執行器。截至 2026 年 8 月,目前版本為 8.0.1。
  • Bruno CLI 會執行資料夾中的 .bru 檔案。使用 npm install -g @usebruno/cli 安裝,接著執行 bru run folder --env Local --reporter-junit results.xml。其 collection 格式原本就設計為目錄中的文字檔案,因此在 code review 中容易閱讀差異。
  • Newman 可在 Postman 外執行 Postman collections:先執行 npm install -g newman,再執行 newman run collection.json -r cli,junit --reporter-junit-export results.xml。問題在於格式。collection 是單一匯出的 JSON blob,因此編輯工作會在 Postman 中進行,而 git 中的檔案只是可能逐漸過時的副本。
  • Schemathesis 是另一類型的檢查工具。它會讀取 OpenAPI schema,並產生測試案例,嘗試取得 schema 宣稱不可能出現的回應:uvx schemathesis run https://your.api/openapi.json。它能找出當機與契約違規,但不了解你的商業規則,因此應與腳本化測試套件並用,而不是取代它。
  • Hoppscotch self hosted 是網頁 UI 選項,而且需要 Postgres instance。安裝前先了解這項取捨:collections 儲存在資料庫,而不是你的 repository 中。

有一個工具應避免使用。Step CI 仍會出現在各種工具整理文章中,其 YAML workflow 格式也容易閱讀,但該 repository 上次收到 commit 是 2024 年 8 月。位於 CI 與 API 之間的程式若無人維護,不適合放在這個位置。

將 mock server 放在防火牆後方

以下設定會將 WireMock 作為 payment provider 的替代服務。如果你不熟悉 compose file format,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 會將 mock 發佈到所有介面,包括你的 public IP。即使 ufw 拒絕該連接埠,服務仍可連線,因為 Docker 會將自己的規則寫入 DOCKER iptables chain,而這些規則會在 ufw 的 INPUT 規則之前評估。請改為繫結 loopback address,或繫結 private interface address。如此一來,kernel 不會接受來自外部的連線。

接著,讓受測服務指向 mock。若服務執行於相同的 compose project,mock 的 base URL 是 http://mock-payments:8080,因為 compose 會在自己的 network 上解析 service name。若服務執行於 host,則是 http://127.0.0.1:8080。請透過 environment variable 設定,不要寫死在程式碼中,否則測試 URL 可能會隨版本發布到 production。

Stub 放在 ./mocks/payments/mappings/ 中,每個 JSON file 一個 stub。

{
  "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 會等待,直到 container 回報 healthy。這是可行的,因為 WireMock image 內建針對 /__admin/health endpoint 的 HEALTHCHECKmappings call 會列出 server 讀取的所有 stub。你撰寫但未出現在清單中的 stub,代表從未載入:請確認檔案位於 mappings/ 下,而不是 mounted root 中,並確認 JSON 可正確解析。

收到 request 但沒有任何 stub 相符時,WireMock 會回應 404,body 以 Request was not matched 開頭,後面接著與其持有的最接近 stub 進行比對所得的 diff。請先閱讀該 diff,再修改設定,因為其中會指出不相符的確切欄位。該欄位通常是包含 /v1/charge 的 path,而 stub 中則寫成 /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 的斷言是整個測試的重點:它證明服務呼叫了 payment provider,並儲存了回傳內容;比較的值就是你寫入 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 request journal,確認請求是否真的到達 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() 很重要。若沒有這項設定,測試執行失敗時就會略過複製,因此您會遺失原本要查看的報告。複製目標也必須位於 workspace 之外,因為 runner 會在下一個工作前清理 workspace,報告也會一併被刪除。

保留結果,不只保留最近一次執行

每個 commit 一份 JUnit XML 檔案只能回答一個問題:是否通過。當你不再開啟這些檔案後,就沒有任何東西會讀取它們,因此無法得知某個 endpoint 是從何時開始變慢。若要追蹤趨勢,請在同一台主機上的小型資料庫中,每次執行新增一筆資料。一個資料表只要保存 commit SHA、檔案名稱、通過數、失敗數與執行時間即可。將 SQLite 用於 VPS production 環境 作為儲存位置也很合適:只需要一個檔案,不需要伺服器程序,而且完整歷史記錄會一併包含在你現有的備份中。請解析 Hurl 的 --report-json 輸出,而不是 JUnit XML,因為前者是兩者中供機器讀取的格式。

容器重建後必須保留的內容

Mock 定義與測試套件都是原始碼。它們應放在描述該服務的 repository 中,且修改端點時應在同一個 pull request 中一併更新。若在 Web UI 中編輯 stub,或在執行期間透過 MockServer 的 REST API 推送 expectation,這些內容只存在於該容器的記憶體或該工具的資料庫中。執行 docker compose down 後,內容就會消失,而且要等到測試因錯誤原因通過時,才有人發現問題。如果你的 repository 也執行在自有硬體上,自架 git 伺服器可讓 fixtures 與服務處於同一個信任邊界內。

接著是實務規則。固定 image tag,因為 latest 可能在 repository 未變更的情況下改變 mock 比對請求的方式,而這類失敗很難追溯原因。工具不需要寫入 stub 目錄時,請以唯讀方式掛載。不要將 mock 的 stub 放在具名 Docker volume 中,否則該 volume 會成為真實來源,而 git 中的副本會在不知不覺間失去正確性。

還有一點很容易被忽略。如果你透過 proxy 錄製實際流量來建立 stub,請在提交前讀過每個產生的檔案。錄製內容會完整保留 upstream 傳回的資料,包括 bearer token 與客戶電子郵件地址。提交後,這些資料就會永久存在於 repository 中,因為 git 會將刪除的內容保留在歷史記錄裡。

FAQ

API mock server 與 API test runner 有何不同?

mock server 會回應請求。它會代替 CI 無法呼叫的相依服務,且不會回報測試通過或失敗。API test runner 會向自己的服務傳送請求、驗證回應、將一次呼叫的值帶入下一次呼叫,並在驗證失敗時以非零狀態結束。兩者解決的是不同問題。典型的設定會同時執行兩者:runner 呼叫自己的服務,而自己的服務呼叫 mock。

我可以從代管的 CI runner 測試內部 API 嗎?

除非將 API 對外暴露,否則無法測試。代管 runner 位於網路外部,因此無法連線到繫結於私有位址的服務。可選擇公開 API、建立 tunnel,或維護公開的 staging 副本;但每種方式都會增加可能故障或洩漏資料的系統。位於相同私有網路的 runner 可直接呼叫服務,這也是團隊自行代管這類工作的主要實際原因。

mock stub 與 API test suite 應該放在哪裡?

放在 git 中,與所描述的服務放在一起。將定義儲存為檔案的工具,例如 WireMock 的 mappings/ 目錄、Mockoon 的資料檔案、Hurl 檔案及 Bruno 的 .bru 資料夾,可提供 code review,且重新建置容器不會增加成本。將定義儲存在資料庫或 web UI 中的工具,則需要備份計畫與匯出步驟;人們通常會忘記匯出,直到容器已經消失。

為什麼 mock stub 看起來正確,卻回傳 404?

WireMock 只會在完全符合條件時提供 stub。未符合的請求會取得 404,其內容開頭為 Request was not matched,後面接著與最接近 stub 的差異比較;該差異會指出不一致的欄位。常見原因包括路徑末尾多了斜線、stub 要求但 client 未傳送的 Content-Type header、在 stub 需要 urlPathPattern 表示變動區段時卻使用了 urlPath,以及不符合 payload 的 body matcher。先檢查 /__admin/requests,確認請求確實已到達 mock。

如果我有 staging environment,還需要 mock 嗎?

需要,原因有二。你無法控制的上游服務即使有 staging 副本,仍可能停止服務或限制速率,導致 suite 因為與程式碼無關的原因失敗。此外,它也無法產生最需要測試的回應,例如遭拒的信用卡或 gateway timeout。mock 可依需求在本機網路速度下回傳這些回應,讓原本對 sandbox 執行需要數分鐘的 suite 縮短為數秒。將 staging 留給發布前的最後檢查,並在 CI 中使用 mock。