- Published on
前置驗證與後置驗證:你的程式碼有說清楚它的承諾嗎?

測試系列文章
假設你接手了一段計算折扣金額的程式碼
function applyDiscount(price, discountRate) {
return price * (1 - discountRate)
}
這段程式碼看起來很簡單,但它藏著幾個「沒說出口的假設」
price應該要是正數嗎?discountRate是 0 到 1 之間的小數,還是 0 到 100 的百分比?- 如果傳入
null或負數,結果會是什麼?
這些假設存在於原作者的腦袋裡,但沒有任何地方明確記載。當你傳入 applyDiscount(100, 1.5) 得到 -50,你會知道這是「合理的行為」還是「程式的 bug」嗎?
前置驗證(Precondition) 與 後置驗證(Postcondition) 要做的,就是把這些沒說出口的假設,用程式碼明確地寫出來
什麼是前置驗證與後置驗證?
基本定義
前置驗證(Precondition) 是函式執行之前必須成立的條件,是呼叫方(caller)的責任。你想用這個函式,就得先滿足它的前提
後置驗證(Postcondition) 則是函式對自己的承諾:只要呼叫方達成了前置條件,函式保證執行後的結果一定符合預期
去銀行提款就是個好例子。你需要先確認帳戶有足夠餘額(前置驗證),而銀行承諾給你現金後,帳戶餘額一定會對應扣除(後置驗證)
從程式碼看演進
回到剛才的例子,一步步把驗證加進去
第一步:原始狀態(沒有任何驗證)
function applyDiscount(price, discountRate) {
return price * (1 - discountRate)
}
這個函式對輸入完全沒有假設,靜靜地接受任何參數,算出一個不知道對不對的結果
第二步:加上前置驗證
function applyDiscount(price, discountRate) {
// Preconditions
if (typeof price !== 'number' || price < 0) {
throw new Error('price 必須是正數')
}
if (typeof discountRate !== 'number' || discountRate < 0 || discountRate > 1) {
throw new Error('discountRate 必須是 0 到 1 之間的數字')
}
return price * (1 - discountRate)
}
現在函式會在「被錯誤使用」的當下立即報錯,而不是默默算出一個錯誤答案
第三步:加上後置驗證
function applyDiscount(price, discountRate) {
// Preconditions
if (typeof price !== 'number' || price < 0) {
throw new Error('price 必須是非負數')
}
if (typeof discountRate !== 'number' || discountRate < 0 || discountRate > 1) {
throw new Error('discountRate 必須是 0 到 1 之間的數字')
}
const result = price * (1 - discountRate)
// Postcondition
if (result < 0 || result > price) {
throw new Error(`後置條件違反:折扣後價格 ${result} 不合理`)
}
return result
}
後置驗證在這裡像是函式的「自我檢查」——即使邏輯有漏洞,也能在回傳前被攔截下來
Design by Contract — 讓假設變成明文契約
概念起源
1980 年代,Bertrand Meyer 在設計 Eiffel 語言時提出了 Design by Contract(DbC,契約式設計) 的概念。核心思想是:軟體元件之間的互動應該像法律契約一樣明確,每一方都有義務(obligation)和利益(benefit)
契約由三個部分組成
- Precondition(前置條件):呼叫方需要保證的事
- Postcondition(後置條件):函式需要保證的事
- Invariant(不變量):無論何時,物件的某些狀態必須始終成立
JavaScript 中的實作方式
JavaScript 本身沒有內建 DbC 機制,但我們可以用幾種方式實作
方式一:Guard Clause(防衛子句)
最常見的做法,在函式開頭提前返回或拋出錯誤
function withdraw(account, amount) {
// Guard Clauses(前置驗證)
if (!account) throw new Error('帳戶不存在')
if (amount <= 0) throw new Error('提款金額必須大於 0')
if (account.balance < amount) throw new Error('餘額不足')
account.balance -= amount
return account.balance
}
Guard Clause 的好處是讓「正常流程」不需要被 if-else 嵌套包裹,程式碼可讀性大幅提升
方式二:自訂 assert 工具函式
function assert(condition, message) {
if (!condition) {
throw new Error(`[Assertion Failed] ${message}`)
}
}
function applyDiscount(price, discountRate) {
// Preconditions
assert(typeof price === 'number' && price >= 0, 'price 必須是正數')
assert(
typeof discountRate === 'number' && discountRate >= 0 && discountRate <= 1,
'discountRate 必須是 0 到 1 之間'
)
const result = price * (1 - discountRate)
// Postcondition
assert(result >= 0 && result <= price, `折扣後價格應介於 0 和 ${price} 之間`)
return result
}
assert 讓驗證的語意更清晰,閱讀時一眼就知道這是「條件斷言」而非「業務邏輯」
不變量(Invariant)的簡單範例
不變量通常用在類別(class)的狀態管理上
class BankAccount {
#balance
constructor(initialBalance) {
assert(initialBalance >= 0, '初始餘額不能為負')
this.#balance = initialBalance
this.#checkInvariant()
}
deposit(amount) {
assert(amount > 0, '存款金額必須大於 0')
this.#balance += amount
this.#checkInvariant() // 每次操作後檢查不變量
}
withdraw(amount) {
assert(amount > 0, '提款金額必須大於 0')
assert(this.#balance >= amount, '餘額不足')
this.#balance -= amount
this.#checkInvariant()
}
// Invariant:餘額永遠不能是負數
#checkInvariant() {
assert(this.#balance >= 0, '[Invariant] 帳戶餘額不能為負')
}
get balance() {
return this.#balance
}
}
不變量保護的是物件的基本承諾,外部怎麼操作都不能破壞它
與 AAA 測試結構的對應
單元測試的 AAA 結構(Arrange, Act, Assert) 和前後置驗證其實高度對應,這不是巧合
| 測試結構 | DbC 概念 | 目的 |
|---|---|---|
| Arrange | Precondition | 建立函式執行的合法前提 |
| Act | 函式執行 | 觸發行為 |
| Assert | Postcondition | 驗證結果符合預期承諾 |
良好的測試本質上就是在描述:「在某個前提下(Arrange),執行某個行為(Act),應該得到某個承諾的結果(Assert)」
用 Vitest 來看這個對應關係
import { describe, it, expect } from 'vitest'
import { applyDiscount } from './discount'
describe('applyDiscount', () => {
it('正常折扣計算:100 元打八折應得到 80 元', () => {
// Arrange(建立 Precondition 的合法環境)
const price = 100
const discountRate = 0.2 // 八折
// Act(執行函式)
const result = applyDiscount(price, discountRate)
// Assert(驗證 Postcondition)
expect(result).toBe(80)
expect(result).toBeGreaterThanOrEqual(0) // 結果應為大於 0
expect(result).toBeLessThanOrEqual(price) // 結果應不超過原價
})
it('前置驗證:傳入負數 price 應拋出錯誤', () => {
// Arrange(刻意建立違反 Precondition 的情境)
const invalidPrice = -100
const discountRate = 0.2
// Act & Assert(測試 Precondition 的防禦行為)
expect(() => applyDiscount(invalidPrice, discountRate)).toThrow('price 必須是非負數')
})
it('前置驗證:discountRate 超過 1 應拋出錯誤', () => {
// Arrange
const price = 100
const invalidRate = 1.5 // 超過 100% 折扣,不合理
// Assert
expect(() => applyDiscount(price, invalidRate)).toThrow('discountRate 必須是 0 到 1 之間的數字')
})
})
測試前置驗證本身(即「傳入非法參數時要拋出錯誤」)也是單元測試,你在驗證函式的防禦機制是否如預期運作
有了前後置驗證,還需要單元測試嗎?
答案很直接:兩者都需要,而且各自解決不同的問題
前後置驗證是「執行期防禦」,單元測試是「設計期證明」
前後置驗證在程式執行時運作。誰呼叫錯了,當下就報錯,直接告訴你哪裡出了問題
單元測試不一樣。它在開發時(或 CI 過程中)跑,目的是在任何人使用這個函式之前,先確認它的行為是正確的
一個是事後攔截,一個是事前驗證。時間點不同,解決的問題也不同
前後置驗證無法驗證「邏輯正確性」
假設有一個計算月薪的函式
function calculateMonthlySalary(annualSalary, bonus) {
assert(annualSalary > 0, 'annualSalary 必須大於 0')
assert(bonus >= 0, 'bonus 必須大於 0')
const result = annualSalary / 13 + bonus // Bug!應該是 12,不是 13
assert(result > 0, '月薪必須大於 0')
return result
}
後置驗證只能確認「結果是正數」,但它無法知道「除以 13」是個邏輯錯誤。只有單元測試能夠明確斷言
it('年薪 120000,無獎金,月薪應為 10000', () => {
const result = calculateMonthlySalary(120000, 0)
expect(result).toBe(10000) // 這裡會失敗,揭露 bug
})
前後置驗證只能保護邊界,不能保證邏輯正確
單元測試是活文件,前後置驗證是程式碼本身的一部分
單元測試描述的是「這個函式應該如何行為」,是給開發者看的規格書。在 TDD 的情境下,測試甚至先於實作存在
前後置驗證沒有這個功能。它只說現在這個函式怎麼保護自己,溝通的對象是呼叫方,而不是未來接手的開發者
好的前後置驗證讓單元測試更精準
當函式有清楚的前置驗證,測試就能更聚焦。傳入 null 的情境已由 Precondition 處理,不用在每個測試案例裡重複覆蓋
describe('applyDiscount', () => {
// 這些測試只關注「合法輸入下的邏輯」
// 不合法的輸入已經由 Precondition 處理,另外有測試覆蓋
it('折扣率為 0,應回傳原價', () => {
expect(applyDiscount(100, 0)).toBe(100)
})
it('折扣率為 1,應回傳 0', () => {
expect(applyDiscount(100, 1)).toBe(0)
})
it('折扣率為 0.5,應回傳原價的一半', () => {
expect(applyDiscount(200, 0.5)).toBe(100)
})
})
Precondition 劃清了邊界,每組測試只需要關心自己的那塊邏輯
前後置驗證在 Production 可能被關閉,測試永遠不會
有些團隊在生產環境會移除 assert(為了效能),這時前後置驗證就消失了。但單元測試從不跑在生產環境,它的價值在於建置過程中持續把關
前後置驗證本身是程式碼,所以也可能寫錯
這個問題很直覺:前置驗證是程式碼,它本身也可能有 bug
比如這個前置驗證
if (typeof discountRate !== 'number' || discountRate < 0 || discountRate >= 1) {
throw new Error('discountRate 必須是 0 到 1 之間的數字')
}
注意這裡用的是 >= 1 而不是 > 1,導致 discountRate = 1(全額折扣,合法情境)會被錯誤拒絕。前置驗證的邊界畫錯了
單元測試會發現這個問題
it('折扣率為 1,應回傳 0', () => {
expect(applyDiscount(100, 1)).toBe(0) // 這裡會失敗,揭露 Precondition 的 bug
})
這個測試不是在驗業務邏輯,而是在確認「防守的邊界有沒有畫對」
所以結構是這樣:單元測試保護前後置驗證,前後置驗證保護業務邏輯。沒有哪一層可以省
兩者的互補關係
| 前後置驗證 | 單元測試 | |
|---|---|---|
| 運作時機 | 執行期(Runtime) | 建置期(Build time) |
| 主要目的 | 快速定位錯誤使用 | 證明邏輯正確性 |
| 驗證對象 | 輸入輸出的合法範圍 | 預期行為與業務邏輯 |
| 文件價值 | 程式碼本身即說明 | 活文件、規格書 |
| 能取代對方嗎? | 不能 | 不能 |
為什麼工程師普遍只寫單元測試?
知道了兩者的互補關係,一個自然會出現的問題是:如果前後置驗證這麼有用,為什麼大多數工程師都沒在用?
語言沒有內建支援
Eiffel 有 require 和 ensure 關鍵字,前後置驗證是一等公民。但 JavaScript、Python、Java 都沒有。你想寫,就得自己包一個 assert,或用 Guard Clause 模擬。多一層摩擦,就少一批人去做
測試框架太成熟了
Jest、Vitest、JUnit 有完整的工具鏈、IDE 整合、覆蓋率報告。整個開發流程都圍繞「測試框架」轉。相比之下,前後置驗證沒有對應的標準工具,也沒有「覆蓋率」可以看,自然不在考量範圍內
「測試」被框定成一個獨立的活動
很多工程師把「測試」這件事和「測試框架」畫上等號。在業務程式碼裡加驗證邏輯,感覺像在做別的事——不算測試,又不算業務邏輯,身份曖昧。分類不清楚的事,通常就被省掉了
型別系統部分替代了需求
TypeScript 和 Java 可以在編譯期攔截型別錯誤,工程師會覺得「型別夠了,不需要再加執行期驗證」。但型別只能檢查結構,不能檢查業務合法性。price 是 number,但值是 -100,型別系統毫無反應
小規模的時候效益不明顯
個人專案或單一功能,函式的呼叫方就是自己,出錯很快就能追到根源。當函式被跨模組共用、被第三方整合、被不同團隊呼叫,前置驗證的價值才會真正浮現——只是到那個時候,補起來已經很痛了
連測試都沒時間寫,怎麼會有時間寫這個
這大概是最誠實的原因。很多團隊的現實是:deadline 壓著,連單元測試都在砍,前後置驗證更是奢侈品。這不是技術問題,是優先級問題。但結果通常是:省下來的時間,之後花在追 bug 上
驗證邏輯已經在,只是形式不同
還有一種情況是:前後置驗證其實已經存在了,只是沒被當作「前後置驗證」來看待。工程師寫了一堆 if (!input) return 或 if (result < 0) throw,散落在業務邏輯中間,和功能程式碼混在一起。它在運作,但沒有清楚的語意邊界,閱讀時也看不出哪裡是「防守」、哪裡是「邏輯」
不必每個函式都寫,但知道什麼情況下該寫,是進階工程師和新手的分野之一
實務建議
什麼情況下要寫 Guard Clause / 前置驗證?
- 函式是對外公開的 API 或被多處呼叫
- 輸入來自使用者、外部系統、或不可信任的來源
- 非法輸入的後果難以追蹤(例如靜默地產生錯誤結果)
- 想明確表達函式的使用前提(即使是給未來的自己看)
什麼情況下單元測試是主角?
- 複雜的業務邏輯需要多個情境覆蓋
- 邊界值行為需要明確定義與驗證
- 重構前後需要確保行為不變
- TDD 流程中,測試驅動實作
組合使用的實際策略
// 1. 用 Guard Clause 處理非法輸入
// 2. 用單元測試覆蓋業務邏輯的各種情境
// 3. 用 Postcondition 對關鍵結果做自我檢查
function transferFunds(fromAccount, toAccount, amount) {
// === Preconditions(Guard Clauses)===
if (!fromAccount || !toAccount) throw new Error('帳戶不能為 null')
if (amount <= 0) throw new Error('轉帳金額必須大於 0')
if (fromAccount.balance < amount) throw new Error('來源帳戶餘額不足')
if (fromAccount.id === toAccount.id) throw new Error('不能轉帳給自己')
const originalFromBalance = fromAccount.balance
const originalToBalance = toAccount.balance
// === 業務邏輯 ===
fromAccount.balance -= amount
toAccount.balance += amount
// === Postconditions ===
assert(fromAccount.balance === originalFromBalance - amount, '來源帳戶餘額未正確扣除')
assert(toAccount.balance === originalToBalance + amount, '目標帳戶餘額未正確增加')
assert(
fromAccount.balance + toAccount.balance === originalFromBalance + originalToBalance,
'轉帳前後總金額應保持一致'
)
}
// 對應的單元測試,專注於業務邏輯正確性
describe('transferFunds', () => {
it('正常轉帳:來源扣款,目標收款', () => {
const from = { id: 'A', balance: 1000 }
const to = { id: 'B', balance: 500 }
transferFunds(from, to, 300)
expect(from.balance).toBe(700)
expect(to.balance).toBe(800)
})
it('前置驗證:餘額不足應拋出錯誤', () => {
const from = { id: 'A', balance: 100 }
const to = { id: 'B', balance: 500 }
expect(() => transferFunds(from, to, 500)).toThrow('來源帳戶餘額不足')
})
it('前置驗證:不能轉帳給自己', () => {
const account = { id: 'A', balance: 1000 }
expect(() => transferFunds(account, account, 100)).toThrow('不能轉帳給自己')
})
})
重新定義「測試」的邊界
測試不是只發生在測試框架裡。程式碼本身也可以是驗證的一部分
前置驗證是函式對呼叫方說明使用規則,後置驗證是函式對自身結果的自我檢查,單元測試則確認這些規則在各種情境下都真的成立
三者分工不同,也不能互相取代。契約說清楚了,測試才能聚焦在邏輯本身
如果你發現為一個函式寫前置驗證很困難,通常是因為這個函式承擔了太多責任。這本身就是一個重構的訊號
圖片來源:AI 產生