昨天談重構時提到一個關鍵前提:每改一小步,就要重新跑測試,確認外部行為沒有改變。但這句話藏著一個沒講清楚的問題——程式碼要「容易被測試」,本身也是一種需要刻意設計出來的特質。今天要談的純函式與副作用,就是決定一段程式碼好不好測試的核心因素。
定義:副作用是指一個函式在回傳結果之外,還對「外部世界」做了某些改動——例如寫入資料庫、修改外部變數、發送網路請求、印出 log。副作用不是壞事,系統終究需要真的把資料存進資料庫、把通知送出去,但副作用會讓函式的行為跟「外部環境的狀態」綁在一起。
定義:純函式必須滿足兩個條件:
測試一個函式,本質上是「給定輸入,檢查輸出是否符合預期」。如果函式是純函式,測試只需要準備輸入、呼叫函式、比對輸出,完全不需要先架設資料庫、不需要清理測試資料、也不用擔心測試執行的順序會不會互相干擾。反過來,如果函式內部直接呼叫資料庫或外部 API,測試就必須想辦法「模擬」這些外部依賴,測試的複雜度跟著一起上升——這也是為什麼好的架構,會盡量把「計算邏輯」跟「有副作用的操作」分開存放。
延續 Day 18 的 OrderService。訂單的金額計算是一段純邏輯,不需要碰資料庫,把它抽成一個獨立的純函式,就能直接測試:
// pure/calculateTotal.js —— 純函式:相同輸入永遠得到相同輸出,不碰資料庫、不發通知
export function calculateTotal(price, quantity, shippingFee) {
return price * quantity + shippingFee;
}
// 測試這個函式完全不需要資料庫,只要給輸入比對輸出
// calculateTotal(100, 2, 30) 永遠等於 230
// services/orderService.js —— 有副作用的部分集中在這裡,跟純邏輯分開
async function createOrder(input) {
const total = calculateTotal(input.price, input.quantity, input.shippingFee); // 呼叫純函式
const order = await orderRepository.save({ ...input, total }); // 副作用:寫入資料庫
await notifier.notify(order.buyerId, `訂單總額 ${total} 元`); // 副作用:發送通知
return order;
}
把金額計算抽成純函式之後,calculateTotal 本身幾乎不需要特別的測試技巧;而真正需要處理「如何模擬副作用」的,只剩下 createOrder 裡那兩行跟資料庫、通知服務打交道的程式碼——測試的複雜度被集中管理,而不是散落在每一個函式裡。
純函式之所以重要,不是因為它比較「優雅」,而是因為它把「驗證邏輯是否正確」這件事,跟「外部世界的狀態」徹底切開。能分離出來的邏輯,就儘量寫成純函式;真正需要碰資料庫、網路的部分,則集中、縮小範圍地存放,這正是讓 Day 20 講的「重構後重新跑測試」這件事,能夠又快又可靠地執行下去的前提。