1. 前言:測試全部綠燈,為什麼客戶還是被扣兩次錢?
先講一個大家都遇過的場景。
所有 API 測試都回 200 OK,欄位格式一個不差,CI 上的報告一片漂亮的綠。結果客服那邊開始收到抱怨:使用者因為網路卡頓連點了兩次,購物金被扣了兩次;或是明明已經沒貨的商品,竟然被買走了。
問題出在哪?一支 API 測試只能證明「這一次呼叫回得對」,但真實的業務是一連串動作串起來的。
所以最可怕的 Bug 從來不是某支 API 回 500,那種一眼就看得到。可怕的是每支 API 都乖乖回 200,但使用者的錢和系統的庫存就是對不起來。
先前那篇 告別 Postman 收費陷阱!開源 Git 原生 API 測試神器 Bruno 實戰指南 聊的是工具選擇與基本設定。這篇要往下一層走:怎麼從「測單支 API」升級成「測整串流程」,而且讓這件事可以規模化。
2. AI 時代真正卡住的地方:不是寫,是驗
這一年最大的體感變化是,寫程式已經不是瓶頸了。需求進來,AI 半小時內就能把一支 API、一個頁面寫到七八成。
但是「把這些東西驗過一遍」的速度,完全沒有跟著變快。於是瓶頸整個往後移了。
寫得越快,要驗的組合越多。而人工點畫面、手動開資料庫比對,成本是線性成長的,追不上 AI 的產出速度。真正該投資的,是一套可以重複跑、可以用程式生出來、可以塞進 CI 自動執行的驗證流程。
我找了一輪工具,最後停在 Bruno,理由很單純:
| 我需要什麼 | Bruno 給了什麼 |
|---|---|
| 讓 AI 好改 | .bru 就是純文字檔,AI 讀得懂、改得動、可以一次生一百支。Postman 那包巨大 JSON 做不到 |
| 能查資料庫 | 測試腳本本身就是 Node.js,把 DB 連線套件 require 進來就能下 SQL |
| 能接 CI | 有官方 CLI 2,跑完可以輸出 HTML / JSON / JUnit 報告,也有官方 Docker image 3,GitLab CI 掛上去就能跑 |
| 好版控 | 檔案就是測試,git diff 看得懂,不用雲端帳號也不用買席次 |
整套方法論其實只有三個動作,而且順序不能顛倒:
第一關擋掉壞掉的 API,第二關擋掉回錯資料的 API,第三關擋的是最陰險的那種:API 回 200、欄位也對,但資料庫裡的帳是歪的。文章後面會用一個實際跑出來的例子證明,只有第三關擋得住它。
這篇的截圖都不是示意圖。我另外寫了一支 Node.js + SQLite 的假電商 API 當靶場,下面每張圖都是這套測試真的打上去跑出來的畫面。
3. 核心觀念:不要比對欄位,要「對帳」
如果整合測試只是把每支 API 的回傳欄位逐一檢查一遍,很容易見樹不見林。真正該驗的,是系統的不變式,講白話一點就是「怎麼算都應該成立的那條算式」6。
像對帳一樣測試
想像你出門買東西:
- 出門前先數錢包,記下有多少錢(我們叫它 B0,也就是起始值)。
- 出去買一輪,下單、付款、退一件、再取消一張。
- 回家再數一次錢包,看看少掉的金額,是不是剛好等於你應該花的錢。
寫成測試就長這樣:
為什麼要記「差多少」,不記「剩多少」
寫斷言的時候,很直覺會寫成 balance == 1000。但只要測試環境是共用的,別人動一筆資料,你的測試就紅了。
改成記錄差額,例如 balance == B0 - 600,不管起始餘額是 1000 還是 5000 都會過。這樣測試就不怕別人污染資料,也不怕換順序執行。
三條實務規則
- 每個情境自己讀自己的起始值:不要共用全域變數,每個測試資料夾一開始都自己呼叫 API 讀一次,情境之間才不會互相干擾。
- 小數點先四捨五入再比:電商到處都是折扣和稅率,兩邊都先跑一次
Math.round(x * 100) / 100,才不會因為 JavaScript 的浮點數誤差莫名其妙變紅燈。 - 把預期數字寫進斷言名稱:例如取名
Verify balance (expected 850)。這樣測試掛掉時,看報告就知道差多少,不用回頭翻 log。
4. Bruno 的三個關鍵功能
要跑上面這套流程,工具得能傳遞狀態、能組織結構。開源的 Bruno 1 這兩件事都做得不錯。
上一步的結果,傳給下一步
整合測試本質上就是一串接力(Request Chaining)5。Bruno 用 bru.setVar 和 bru.getVar 在請求之間傳值。
第一步讀完餘額,先存起來:
// 讀取初始餘額並存成變數
bru.setVar('s01_bal0', Number(res.getBody().member.balance));
最後一步驗證時,先算出「應該剩多少」,再交給宣告式的 assert 去比:
// 在 pre-request 算出期望值
const b0 = Number(bru.getVar('s01_bal0'));
bru.setVar('s01_expBal', Math.round((b0 - 600) * 100) / 100);
// 在 assert 區塊比對
assert {
res.status: eq 200
res.body.member.balance: eq {{s01_expBal}}
}
複雜的計算用程式寫,最後的比對用宣告式寫。報告乾淨,彈性也留著。
整包共用的開頭腳本
有些事情每支請求都要做,例如登入拿 token。與其每支都寫一遍,不如拉到 Collection(整個集合)的最上層做一次:
- Token 只算一次:後面所有請求直接重用,不用每次都跑登入流程。
- 防呆閘門:整合測試會真的改到資料,所以絕對不能誤打正式站。在集合最上層加這幾行擋住:
// Collection 層級的 script:pre-request
const url = (bru.getEnvVar('baseUrl') || '').toLowerCase();
if (!url) {
throw new Error('No baseUrl - select the UAT environment first.');
}
if (/prod|production|live/.test(url)) {
throw new Error('BLOCKED: baseUrl looks like production. UAT only!');
}
這道閘門不是寫爽的,它真的會擋。下面是我把網址硬改成一個看起來像正式站的位址之後的結果,第一支請求就被打斷,一筆資料都沒進去:

再加上「乾脆不要建立正式站的環境設定檔」這條規矩,就很難出事了。
資料夾就是測試結構
Bruno 的 .bru 檔裡有個 seq 決定執行順序,資料夾則天然就是情境分組。一個業務情境放一個資料夾:
requests/
├─ 00-launch/ # 先建立 Session,後面都要用
├─ 02-shop-a/ # 平台 A
│ ├─ 01-regular-checkout/ # 正常買一次
│ │ ├─ 1-read-stock.bru # 記下庫存起始值
│ │ ├─ 2-read-balance.bru# 記下餘額起始值
│ │ ├─ 3-create-order.bru# 下單
│ │ ├─ 4-pay.bru # 付款
│ │ ├─ 5-ship.bru # 出貨
│ │ ├─ 6-verify-stock.bru# 核對庫存
│ │ └─ 7-verify-ledger.bru # 核對餘額 + 資料庫
│ ├─ 02-duplicate-payment/ # 重複付款
│ └─ 03-out-of-order/ # 順序顛倒
└─ 03-shop-b/ # 平台 B,一樣六個情境,規則不同
跑整套的時候 Bruno 會照順序走完,報告的結構就跟業務情境長得一模一樣,很好讀。
5. 六種「最容易出事」的情境
寫整合測試不要照著 API 規格書一支一支敲,要照著「系統會怎麼壞」來設計。電商系統除了正常購買,真正會出事的是連點、網路延遲、以及各種被拒絕的請求。
參考業界做法(例如 Stripe 的冪等性設計 4),我整理成六個情境:
| 分類 | 測什麼 | 在防哪一種真實災情 |
|---|---|---|
| 正常流程 | 下單 → 付款 → 出貨 | 基本功能壞掉,或庫存、金額算錯 |
| 重複送出 | 同一筆付款(帶同一組收據編號)送兩次 4 | 使用者連點、Webhook 重試造成的重複扣款 |
| 順序顛倒 | 還沒收到付款成功,先收到出貨請求 | 網路延遲讓狀態機錯亂,沒付錢就出貨 |
| 金額變動 | 取消訂單、部分退款、運費與折價券 | 退款金額算錯,運費該不該退各說各話 |
| 被拒絕(庫存) | 只剩 2 件卻下訂 7 件 | 超賣,以及「被拒絕的請求偷偷改了資料」 |
| 被拒絕(餘額) | 餘額只剩 120 卻要買千元商品 | 餘額變負數,或被拒絕後還是寫了一筆帳 |
這裡插一個關於「冪等性」的白話解釋:它講的是同一個動作做幾次,結果都應該一樣。就像電梯按鈕按十下,電梯還是只來一次。付款 API 的做法是讓前端帶一組「收據編號」(Idempotency Key),伺服器看到重複的編號,就把第一次的結果原封不動再回一次,而不是再扣一次錢。
六個情境乘上兩組平台設定,就是這套 demo 的全部。跑起來長這樣:

55 支請求、55 個 test、85 個 assert,不到 0.4 秒跑完。重點不是快,是這 140 個檢查點每次 commit 都可以無痛重跑。這就是「系統化驗證」跟「人工點一點」的差別。
6. 別在測試裡寫死數字
整合測試最容易爛掉的地方,是滿天飛的 Magic Number:
// 三個月後沒有人知道 600 是怎麼算出來的
expect(res.getBody().member.balance).to.equal(b0 - 600);
只要換一家合作電商、改一次運費規則,這些數字就全錯,而且錯得無聲無息。
解法是把「這家平台怎麼算」抽出來變成一張規則表,讓期望值由程式推導:
{
"shop-a": {
"cancelSemantics": "refund-all",
"insufficientStockBehavior": "block-order",
"shippingFee": 60,
"taxRate": 0
},
"shop-b": {
"cancelSemantics": "keep-shipping-fee",
"insufficientStockBehavior": "block-order",
"shippingFee": 80,
"taxRate": 0.05
}
}
翻成白話就是:A 店取消訂單全額退,運費 60,不含稅;B 店取消訂單運費不退,運費 80,另外加 5% 稅。
接著把算法寫成單純的函式,整包測試共用:
// lib/expectations.js
const round2 = (n) => Math.round(n * 100) / 100;
// 訂單金額 = 商品金額 x (1 + 稅率) + 運費 - 折價
function orderAmount(profile, unitPrice, qty, coupon) {
const goods = round2(unitPrice * qty);
return round2(goods * (1 + profile.taxRate) + profile.shippingFee - (coupon || 0));
}
// 取消訂單退多少,看這家平台的規則
function cancelRefund(profile, amount) {
return profile.cancelSemantics === 'keep-shipping-fee'
? round2(amount - profile.shippingFee)
: round2(amount);
}
module.exports = { round2, orderAmount, cancelRefund };
測試裡就不再有硬寫死的數字,只剩「按照這家的規則,答案應該是多少」:
// script:pre-request
const P = { cancelSemantics: 'refund-all', shippingFee: 60, taxRate: 0 };
const exp = require(bru.cwd() + '/lib/expectations.js');
bru.setVar('s01_expAmount', exp.orderAmount(P, Number(bru.getVar('s01_price')), 2, 100));
好處有兩個。第一,換平台只要換那張規則表,測試一行都不用改。第二,推導出來的數字會直接出現在報告的斷言名稱上,例如 Refund follows profile.cancelSemantics=keep-shipping-fee (expected 189)。紅燈時一眼就知道是我對規則理解錯了,還是系統真的算錯。
7. 測試不用一支一支手刻
到這裡,整套測試已經很規律了:六個情境、固定的三段式驗證、期望值由規則表推導。既然這麼規律,就沒有理由用手刻。
Postman 的 Collection 是一整包巨大的 JSON,程式生成它不是不行,但生出來沒人看得懂 diff。Bruno 的 .bru 是純文字,用字串模板就能吐:
// generate.mjs(節錄)
function req({ name, seq, method, url, body, assert = [], tests }) {
const parts = []
parts.push(`meta {\n name: ${name}\n type: http\n seq: ${seq}\n}`)
parts.push(`${method.toLowerCase()} {\n url: ${url}\n body: ${body ? 'json' : 'none'}\n auth: inherit\n}`)
if (body) parts.push(`body:json {\n${indent(body)}\n}`)
if (assert.length) parts.push(`assert {\n${assert.map((a) => ` ${a}`).join('\n')}\n}`)
if (tests) parts.push(`tests {\n${indent(tests)}\n}`)
return parts.join('\n\n') + '\n'
}
於是多接一家合作電商的成本,就從「重刻一整套測試」變成「填一張規則表」:

這是生成器真的跑一次的畫面:兩張規則表進去,12 個情境資料夾、55 支 .bru、75 個檔案出來,不到一秒。
程式生成帶來的好處是會累積的:
- 多接一家平台幾乎不花錢:覆蓋率立刻補齊,不用人重寫一遍。
- 格式完全一致:所有平台的資料夾結構、變數命名、斷言措辭都一樣,報告可以並排比對。
- 方法論可以整批升級:想讓每個情境都多檢查一條「訂單最後必須是 SHIPPED」?改生成器一行,重跑,55 支測試同步更新。
這也是我說 Bruno「AI 友善」的真正意思。純文字讓 AI 和腳本都能安全地大量改測試,而測試本身是可執行的,改壞了立刻紅給你看。
8. API 說成功,資料庫真的有記到嗎?
到目前為止,我們驗的都還是「API 說的話」。
但是餘額查詢 API 回得正確,不代表資料庫裡的交易明細有被正確寫入。少寫一筆對帳紀錄、多寫一筆重複紀錄,在 API 那一層完全看不出來。
這就是 Bruno 用 Node.js 當腳本引擎最值錢的地方:測試可以直接連資料庫。
// lib/ledger.js
const { createRequire } = require('module');
function openDb(collectionPath, dbRelPath) {
const nodeRequire = createRequire(collectionPath + '/bruno.json');
const { DatabaseSync } = nodeRequire('node:sqlite');
return new DatabaseSync(collectionPath + '/../' + dbRelPath, { readOnly: true });
}
這邊示範用 SQLite,實際專案換成 mssql、mysql2 或 pg 寫法完全一樣,差別只在連線字串。
接著定義四條資料庫層級的檢查,我把它們編號成 C1 到 C4:
| 代號 | 白話說明 | 在抓什麼 |
|---|---|---|
| C1 有扣就要有紀錄 | 每一筆成功扣款,明細裡都要找得到 | 錢扣了卻沒留紀錄,事後對不了帳 |
| C2 不能憑空多出來 | 明細裡的每一筆,都要追得回測試建立的訂單 | 系統偷偷寫入來路不明的交易 |
| C3 同一件事只能記一次 | 同一張訂單的同一個動作,最多一筆 | 資料庫層級的冪等性破功 |
| C4 加總要對得起來 | 明細金額加總,要等於餘額實際少掉的錢 | API 跟帳本各說各話 |
把它們掛在每個情境的最後一支請求上,一支請求就同時做完三件事:呼叫 API、檢查回傳、確認資料庫。

C4 才是真正的守門員
前面三條看起來都很合理,但它們其實都還在問「系統有沒有自打嘴巴」。真正致命的是 C4,因為它把兩個獨立的事實綁在一起比對:明細的加總,必須等於餘額實際少掉的金額。
我故意把假伺服器的冪等性拿掉,模擬一次真實的 regression(有人改了付款流程,忘記檢查收據編號),然後重跑「重複付款」情境:

注意這張圖的細節,開頭那個客服災情在這裡完整重現:
- 兩次付款 API 都回 200,回傳的金額也都正確。
- 資料庫的唯一索引擋掉了第二筆明細,所以 C3「只能記一次」還是綠的。
- 但會員餘額被扣了兩次:
AssertionError: expected -380 to equal -760。明細說這張單只動了 380,實際錢包卻少了 760。
如果測試只做到「檢查 API 回傳」,這個 Bug 會全綠上線。只有把資料庫拉進來對帳,它才會在 CI 就被攔下來。
9. 密鑰怎麼放,怎麼接進 CI
整合測試會碰到真實密鑰,所以在接 CI 之前,先把規矩定死。
密鑰從哪裡讀,優先順序
- 系統環境變數(
bru.getProcessEnv('SHOP_API_KEY')):CI 用這個,由 GitLab 的 CI/CD Variables(Masked + Protected)注入。 - 專案根目錄的
.env:本機開發用。 - Bruno 環境檔:只放不敏感的預設值,例如
baseUrl。
// Collection 層級:CI 優先吃環境變數,本機才 fallback
bru.setVar('apiKey', bru.getProcessEnv('SHOP_API_KEY') || bru.getEnvVar('apiKey'));
三條不能破的規矩
- 密鑰不進 Git,repo 裡只留
.env.example。 - 密鑰不放命令列參數,不然會出現在 process 清單和 CI log 裡。
- 測試報告(HTML / JSON)裡面有 request 和 response 內容,一律進
.gitignore,或用--reporter-skip-headers之類的參數過濾掉。
接上 GitLab CI
SHOP_API_KEY 在 GitLab 專案的 Settings → CI/CD → Variables 建一個 Masked 變數就好,Runner 會自動注入成環境變數,.gitlab-ci.yml 裡不用再寫一次:
stages:
- test
api-integration:
stage: test
image: node:22
script:
- cd collection
- >
npx @usebruno/cli run requests -r
--env uat --sandbox developer
--reporter-html ../report.html
--reporter-junit ../junit.xml
artifacts:
when: always
expire_in: 1 week
paths:
- report.html
- junit.xml
reports:
junit: junit.xml
artifacts:reports:junit 讓 GitLab 把失敗案例直接標在 Merge Request 的 Test summary 上,--reporter-html 則產出這份可以丟給非工程角色看的報告(放在 paths 裡,Job 頁面可以直接下載):

有兩個地方容易忘記。第一,when: always 一定要加,不然測試紅燈時 Job 直接失敗,報告根本不會被收走,等於最需要看的時候看不到。第二,Bruno CLI 測試失敗時回非 0 的 exit code,Job 就會是紅的,這是我們要的行為;如果只想先觀察一陣子不擋合併,加 allow_failure: true 而不是把指令用 || true 吞掉。
官方另外也有 Docker image 3,不想在 CI 處理 Node 版本的話,把 image: 換成官方 image 就能跑。
10. 我們踩過的雷
| # | 踩到什麼 | 怎麼解 |
|---|---|---|
| 1 | 規則用猜的。文件寫「取消全額退」,實際會扣運費 | 每個規則都用一次真實呼叫實測確認,不要相信文件 |
| 2 | 從錯誤回應讀狀態。庫存不足時拿 error body 裡的數字去對帳 | 錯誤回應只檢查 status 和錯誤碼,狀態一律重新 GET 一次 |
| 3 | 看起來成功但其實沒做事。重複付款回 200,卻沒真的扣款 | 靠最後的餘額與明細加總去抓,不要相信中間步驟的 200 |
| 4 | 17 位數的訂單編號被 JSON.parse 吃掉精度 | 編號一律 String() 之後再傳,檢查長度而不是數值 |
| 5 | 只跑子資料夾,因為少了 00-launch 沒建立 session 而全滅 | 執行時把 00-launch 一起帶上,或在集合層級做 token 快取 |
| 6 | 浮點數:567 * 1.05 兩邊算出來差一點點 | 兩邊都先 Math.round(x * 100) / 100 再比 |
| 7 | PowerShell 5.1 跑腳本中文變亂碼 | .ps1 只寫英文,中文說明放 Markdown |
| 8 | Bruno 的 sandbox 載不進 node:sqlite | 用 require('module').createRequire(...) 繞過,或改用一般的 npm 套件 |
第 8 條是這次現踩的。Bruno 判斷「這是不是 Node 內建模組」時,會把 node: 前綴剝掉再去查清單,但 node:sqlite 在清單裡剛好是帶前綴登錄的,於是被誤判成 npm 套件,直接噴找不到檔案。createRequire 一行就解掉。
這也提醒一件事:沙盒能做什麼不能做什麼,要用跑的去確認,不要用猜的。
11. 結語:防禦可以加速,但邊界還是要人來定義
回到開頭那個問題。這套東西真正改變的不是「測試寫得比較快」,而是驗證從一次性的動作,變成一道可以重複執行的防線。
| 項目 | 這套 demo 的實測數字 |
|---|---|
| 平台規則表 | 2 張 |
| 情境資料夾 | 12 個(6 情境 × 2 平台) |
生出來的 .bru 請求 | 55 支 |
| 檢查點(test + assert) | 140 個 |
| 全套執行時間 | 0.4 秒 |
| 多接一個平台的成本 | 一張規則表 |
但有件事要講清楚:防禦可以加速,邊界不行。
生成器可以幫你把 55 支測試變成 550 支,AI 可以幫你把斷言寫得又快又漂亮,CI 可以每次 push 都跑一遍。
但是「這家平台取消訂單到底該不該退運費」、「重複付款算不算錯」、「帳對不起來要不要擋上線」,這些邊界機器沒辦法幫你決定。你定義錯了,它只會很有效率地把錯的規則驗證一千次。
所以工具會換,Bruno 也不會是終點。真正留得住的是那套方法論:用對帳取代欄位比對、照著故障模式設計情境、讓期望值由規則推導出來,最後一定要打完 API 就回頭問資料庫一次。
這三個動作的順序,就是這篇文章想留下的東西。























留言