昨天把純函式和有副作用的程式碼分開放之後,今天要正式談「測試」這件事本身:測試應該分成幾種層級、每一層各自驗證什麼、以及遇到無法避免的副作用時,該怎麼處理。
定義:測試金字塔把自動化測試分成三層,由下而上分別是:
形狀的理由:越上層的測試越慢、越容易因為環境問題而失敗(脆弱),維護成本也越高。理想的比例是「寫大量又快又穩定的單元測試,中等數量的整合測試,少量涵蓋關鍵流程的 E2E 測試」——如果反過來,大量依賴又慢又不穩的 E2E 測試,會讓整個測試套件變得又慢又難維護。
定義:斷言是測試裡「檢查結果是否符合預期」的那一行程式碼——給定輸入、呼叫函式,然後斷言輸出應該等於某個值。如果斷言失敗,測試就會回報錯誤,標示出程式碼跟預期行為不一致的地方。
定義:當測試的對象依賴某個外部服務(資料庫、API)時,為了不讓測試真的去打資料庫或呼叫外部 API,會用一個「假的」替身(Mock / Stub)取代真正的依賴——這個假替身看起來像真的(有同樣的方法可以呼叫),但回傳的是測試預先設定好的固定結果。
延續 Day 21 的 calculateTotal(純函式)與 createOrder(有副作用)。純函式可以直接寫斷言測試;有副作用的部分,則需要用 Mock 取代真正的 OrderRepository:
// __tests__/calculateTotal.test.js —— 單元測試,純函式不需要任何 Mock
import { calculateTotal } from '../pure/calculateTotal.js';
test('計算訂單總額:商品價格 x 數量 + 運費', () => {
const result = calculateTotal(100, 2, 30);
expect(result).toBe(230); // 斷言:輸出必須等於 230
});
// __tests__/orderService.test.js —— 用 Mock 取代真正的 OrderRepository 與 Notifier
test('建立訂單後會呼叫 repository.save 寫入正確的總金額', async () => {
const mockRepository = { save: jest.fn().mockResolvedValue({ id: 1 }) }; // 假的 repository
const mockNotifier = { notify: jest.fn() }; // 假的通知服務,不會真的打 LINE API
await createOrder({ price: 100, quantity: 2, shippingFee: 30 }, mockRepository, mockNotifier);
expect(mockRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ total: 230 }) // 斷言:存進去的 total 是否正確
);
});
calculateTotal 的測試完全不需要 Mock,因為它本來就是純函式;createOrder 的測試則需要把 OrderRepository 和通知服務換成假的實作——這正好呼應 Day 18 談的「依賴介面而非實作」:因為 createOrder 原本就是依賴抽象的 OrderRepository,測試時才能輕易地把它換成一個假的版本,而不需要真的連上資料庫。
測試金字塔告訴我們「該在哪一層寫多少測試」,斷言是驗證結果的手段,Mock 則是處理副作用、讓測試能獨立執行的技巧。這三者合起來,才能讓 Day 20 講的「重構後重新跑測試」真正落地,而且因為大部分測試都是又快又穩的單元測試,整個驗證的過程才能又快又可靠。