自架 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 的 HEALTHCHECK。mappings 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 是主機上的一般程序,因此該主機必須安裝 docker 和 hurl。託管映像中的任何內容都不會繼承。
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。