Mark Ku's Blog
Podcast 對話本文 AI 對話朗讀版

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 看得懂,不用雲端帳號也不用買席次

整套方法論其實只有三個動作,而且順序不能顛倒:

Loading diagram…

第一關擋掉壞掉的 API,第二關擋掉回錯資料的 API,第三關擋的是最陰險的那種:API 回 200、欄位也對,但資料庫裡的帳是歪的。文章後面會用一個實際跑出來的例子證明,只有第三關擋得住它。

這篇的截圖都不是示意圖。我另外寫了一支 Node.js + SQLite 的假電商 API 當靶場,下面每張圖都是這套測試真的打上去跑出來的畫面。

3. 核心觀念:不要比對欄位,要「對帳」

如果整合測試只是把每支 API 的回傳欄位逐一檢查一遍,很容易見樹不見林。真正該驗的,是系統的不變式,講白話一點就是「怎麼算都應該成立的那條算式」6

像對帳一樣測試

想像你出門買東西:

  1. 出門前先數錢包,記下有多少錢(我們叫它 B0,也就是起始值)。
  2. 出去買一輪,下單、付款、退一件、再取消一張。
  3. 回家再數一次錢包,看看少掉的金額,是不是剛好等於你應該花的錢。

寫成測試就長這樣:

Loading diagram…

為什麼要記「差多少」,不記「剩多少」

寫斷言的時候,很直覺會寫成 balance == 1000。但只要測試環境是共用的,別人動一筆資料,你的測試就紅了。

改成記錄差額,例如 balance == B0 - 600,不管起始餘額是 1000 還是 5000 都會過。這樣測試就不怕別人污染資料,也不怕換順序執行。

三條實務規則

  1. 每個情境自己讀自己的起始值:不要共用全域變數,每個測試資料夾一開始都自己呼叫 API 讀一次,情境之間才不會互相干擾。
  2. 小數點先四捨五入再比:電商到處都是折扣和稅率,兩邊都先跑一次 Math.round(x * 100) / 100,才不會因為 JavaScript 的浮點數誤差莫名其妙變紅燈。
  3. 把預期數字寫進斷言名稱:例如取名 Verify balance (expected 850)。這樣測試掛掉時,看報告就知道差多少,不用回頭翻 log。

4. Bruno 的三個關鍵功能

要跑上面這套流程,工具得能傳遞狀態、能組織結構。開源的 Bruno 1 這兩件事都做得不錯。

上一步的結果,傳給下一步

整合測試本質上就是一串接力(Request Chaining)5。Bruno 用 bru.setVarbru.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 collection 層級安全閘門攔截 production baseUrl 的 CLI 輸出
Bruno collection 層級安全閘門攔截 production baseUrl 的 CLI 輸出

再加上「乾脆不要建立正式站的環境設定檔」這條規矩,就很難出事了。

資料夾就是測試結構

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 的全部。跑起來長這樣:

Bruno CLI 跑完 55 支 request、85 個 assertion 全綠的執行結果
Bruno CLI 跑完 55 支 request、85 個 assertion 全綠的執行結果

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'
}

於是多接一家合作電商的成本,就從「重刻一整套測試」變成「填一張規則表」:

測試生成器讀取兩組 profile 後產生 12 個情境資料夾、55 支 .bru 檔的輸出
測試生成器讀取兩組 profile 後產生 12 個情境資料夾、55 支 .bru 檔的輸出

這是生成器真的跑一次的畫面:兩張規則表進去,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,實際專案換成 mssqlmysql2pg 寫法完全一樣,差別只在連線字串。

接著定義四條資料庫層級的檢查,我把它們編號成 C1 到 C4:

代號白話說明在抓什麼
C1 有扣就要有紀錄每一筆成功扣款,明細裡都要找得到錢扣了卻沒留紀錄,事後對不了帳
C2 不能憑空多出來明細裡的每一筆,都要追得回測試建立的訂單系統偷偷寫入來路不明的交易
C3 同一件事只能記一次同一張訂單的同一個動作,最多一筆資料庫層級的冪等性破功
C4 加總要對得起來明細金額加總,要等於餘額實際少掉的錢API 跟帳本各說各話

把它們掛在每個情境的最後一支請求上,一支請求就同時做完三件事:呼叫 API、檢查回傳、確認資料庫。

Bruno 一支 request 內完成 API 斷言與 C1 至 C4 資料庫帳本驗證的執行結果
Bruno 一支 request 內完成 API 斷言與 C1 至 C4 資料庫帳本驗證的執行結果

C4 才是真正的守門員

前面三條看起來都很合理,但它們其實都還在問「系統有沒有自打嘴巴」。真正致命的是 C4,因為它把兩個獨立的事實綁在一起比對:明細的加總,必須等於餘額實際少掉的金額

我故意把假伺服器的冪等性拿掉,模擬一次真實的 regression(有人改了付款流程,忘記檢查收據編號),然後重跑「重複付款」情境:

關閉冪等性後重跑重複付款情境,API 回 200 但帳本淨額與餘額變動不符的紅燈輸出
關閉冪等性後重跑重複付款情境,API 回 200 但帳本淨額與餘額變動不符的紅燈輸出

注意這張圖的細節,開頭那個客服災情在這裡完整重現:

  • 兩次付款 API 都回 200,回傳的金額也都正確。
  • 資料庫的唯一索引擋掉了第二筆明細,所以 C3「只能記一次」還是綠的
  • 但會員餘額被扣了兩次:AssertionError: expected -380 to equal -760。明細說這張單只動了 380,實際錢包卻少了 760。

如果測試只做到「檢查 API 回傳」,這個 Bug 會全綠上線。只有把資料庫拉進來對帳,它才會在 CI 就被攔下來。

9. 密鑰怎麼放,怎麼接進 CI

整合測試會碰到真實密鑰,所以在接 CI 之前,先把規矩定死。

密鑰從哪裡讀,優先順序

  1. 系統環境變數bru.getProcessEnv('SHOP_API_KEY')):CI 用這個,由 GitLab 的 CI/CD Variables(Masked + Protected)注入。
  2. 專案根目錄的 .env:本機開發用。
  3. 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 頁面可以直接下載):

Bruno HTML 報告 dashboard,顯示 55 個 request、140 個檢查點全數通過
Bruno HTML 報告 dashboard,顯示 55 個 request、140 個檢查點全數通過

有兩個地方容易忘記。第一,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
417 位數的訂單編號被 JSON.parse 吃掉精度編號一律 String() 之後再傳,檢查長度而不是數值
5只跑子資料夾,因為少了 00-launch 沒建立 session 而全滅執行時把 00-launch 一起帶上,或在集合層級做 token 快取
6浮點數:567 * 1.05 兩邊算出來差一點點兩邊都先 Math.round(x * 100) / 100 再比
7PowerShell 5.1 跑腳本中文變亂碼.ps1 只寫英文,中文說明放 Markdown
8Bruno 的 sandbox 載不進 node:sqliterequire('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 就回頭問資料庫一次

這三個動作的順序,就是這篇文章想留下的東西。

Loading diagram…

作者

Mark Ku

擁有 10+ 年經驗的資深軟體工程師,現為 AI 應用 Builder,專注於大型平台架構與簡化複雜系統設計,從電商系統到訂閱與收費平台,結合 AI Agent、AI 整合與自動化開發,打造高效率且可持續演進的產品技術基礎。閱讀更多

覺得這篇有幫助?

作者做的免費工具、每日 Podcast 與電子報,都在這裡。

Mark Ku · 本文採用 CC BY 4.0 授權,轉載請註明作者並附上原文連結。

留言

訂閱電子報

訂閱後即時收到新文章通知,不錯過任何技術分享。

提交即表示同意接收電子報,隨時可

熱門文章

View all
Mark Ku
··599

Oracle Cloud 永久免費方案 Linux 主機及固定 IP :0 元打造雲端解決方案

Oracle Cloud 永久免費方案 Linux 主機及固定 IP :0 元打造雲端解決方案
Mark Ku
··493

告別 Postman 收費陷阱!開源 Git 原生 API 測試神器 Bruno 實戰指南

告別 Postman 收費陷阱!開源 Git 原生 API 測試神器 Bruno 實戰指南
Mark Ku
··333

一款免費開源類似於 Notion 類知識庫系統 — Outline Wiki 佈署與備份全攻略

一款免費開源類似於 Notion 類知識庫系統 — Outline Wiki 佈署與備份全攻略
Mark Ku
··271

訓練自己的 AI 語音:硬體門檻、開源模型比較與 LoRA 微調

訓練自己的 AI 語音:硬體門檻、開源模型比較與 LoRA 微調
Mark Ku
··222

在 Ubuntu 上設置 Samba 來共享資料夾,讓 Windows 11 用戶可以存取

在 Ubuntu 上設置 Samba 來共享資料夾,讓 Windows 11 用戶可以存取
Mark Ku
··217

打造高效 API 管理平台:從 0 開始部署 Kong Gateway - Part 1

打造高效 API 管理平台:從 0 開始部署 Kong Gateway - Part 1