到了 Day 10,我們的「AI 個人財務追蹤器」已經擁有了 API 與 Day 9 加上去的 Zod 防禦機制。但身為一個現代工程師,最怕的就是「今天改了 A 邏輯,明天 B 功能默默壞掉」。
手寫測試很枯燥,但這正是 AI 的強項!今天我們要開啟 Vibe Mode,讓 AI 成為我們的 QA 自動化助手,使用超快的 Vitest 為交易紀錄 API(POST /api/transactions)撰寫 Unit Test(單元測試) 與 Integration Test(整合測試),把邊界條件全部鎖死!
(Test Suite) 生成
我們正在使用 Vitest 測試 Next.js 14 App Router 的 API Route Handler。
請幫我為 `app/api/transactions/route.ts` 撰寫完整的單元與整合測試檔 `route.test.ts`:
1. 使用 `vi.mock()` Mock 掉 Supabase Client,避免測試連線到真實資料庫。
2. 測試案例需涵蓋:
- [成功案例] 傳入合法資料,成功回傳 201 與新增資料。
- [失敗案例] 傳入負數金額,經 Zod 驗證失敗回傳 400。
- [失敗案例] Supabase 拋出錯誤時,回傳 500。
3. 語法請使用 Vitest 的 `describe`, `it`, `expect`, `vi`。
AI 很快地為我生成了測試框架:
// app/api/transactions/route.test.ts
import { describe, it, expect, vi } from 'vitest'
import { POST } from './route'
// Mock Supabase
vi.mock('@/utils/supabase/server', () => ({
createClient: () => ({
from: () => ({
insert: () => ({
select: () => ({ data: { id: '123', amount: 100 }, error: null })
})
})
})
}))
describe('POST /api/transactions', () => {
it('應成功建立交易紀錄', async () => {
const req = new Request('http://localhost/api/transactions', {
method: 'POST',
body: JSON.stringify({
amount: 100,
type: 'expense',
category: 'Food',
date: new Date().toISOString()
})
})
const res = await POST(req)
const json = await res.json()
expect(res.status).toBe(201)
expect(json.success).toBe(true)
})
})
看似完美的測試檔,在執行 npx vitest 後卻立刻崩潰。檢查後會發現 AI 寫測試時常犯的兩個致命缺點:
* Static Mock 無法測試失敗情境:AI 將 Supabase 的 Mock 寫死了(寫死永遠回傳 error: null)。這樣當我要測試「資料庫壞掉回傳 500」時,根本無法動態修改 Mock 的回傳值!
* 缺乏測試間的狀態隔離:沒有使用 beforeEach(() => vi.clearAllMocks()),導致第一個測試呼叫 Mock 的紀錄會干擾第二個測試,引發測試間的連帶失敗(Test Pollution)。
💥 翻車與除錯過程 (Debug Experience)
🐛 踩坑:TypeError: Cannot read properties of undefined (reading 'select')
當我要求 AI 補上「資料庫出錯(500)」的測試時,它寫了以下動態 Mock 修改:
// AI 的錯誤嘗試
const mockInsert = vi.fn().mockReturnValue({ select: vi.fn() })
// 💥 這裡出事了!當 Promise 鏈拉長,Promise/Chaining 結構沒有被完整模擬,導致 select 回傳 undefined
因為 Next.js 裡的 Supabase 查詢使用了 Fluent API 鏈式呼叫(from().insert().select()),AI 生成的 Mock 只鏈結到第二層,後續的 .select() 直接吐出 undefined,引發 TypeError,測試根本沒走到我們的 API 邏輯就炸了!
不用補寫複雜的 Mock 結構,而是叫 AI 運用 vi.hoisted() 與彈性的 Mock Helper 重新構建鏈式呼叫:
你寫的 Supabase Mock 破壞了 Supabase 的 Fluent Chain (.from().insert().select()),導致 TypeError。
請改用 vi.hoisted 建立一個可動態控制回傳值的 mockSelect 與 mockInsert 函式,確保:
it() 測試中設定 .mockResolvedValueOnce() 以模擬資料庫報錯。// app/api/transactions/route.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { POST } from './route'
// 1. 使用 vi.hoisted 確保 Mock 變數在模組載入前提升
const { mockSelect, mockInsert } = vi.hoisted(() => {
const mockSelect = vi.fn()
const mockInsert = vi.fn(() => ({ select: mockSelect }))
return { mockSelect, mockInsert }
})
vi.mock('@/utils/supabase/server', () => ({
createClient: () => ({
from: () => ({ insert: mockInsert })
})
}))
describe('POST /api/transactions Integration Tests', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('【成功】傳入正確資料應回傳 201', async () => {
mockSelect.mockResolvedValueOnce({
data: [{ id: 'tx_123', amount: 250, type: 'expense' }],
error: null,
})
const req = new Request('http://localhost/api/transactions', {
method: 'POST',
body: JSON.stringify({
amount: 250,
type: 'expense',
category: 'Dinner',
date: new Date().toISOString(),
}),
})
const res = await POST(req)
const json = await res.json()
expect(res.status).toBe(201)
expect(json.data[0].id).toBe('tx_123')
})
it('【失敗】負數金額應被 Zod 擋下回傳 400', async () => {
const req = new Request('http://localhost/api/transactions', {
method: 'POST',
body: JSON.stringify({ amount: -50, type: 'expense' }),
})
const res = await POST(req)
const json = await res.json()
expect(res.status).toBe(400)
expect(json.errors.amount).toBeDefined()
// 驗證 Zod 攔截成功,資料庫連叫都沒叫
expect(mockInsert).not.toHaveBeenCalled()
})
it('【失敗】資料庫異常時應妥善捕捉回傳 500', async () => {
mockSelect.mockResolvedValueOnce({
data: null,
error: { message: 'Database Connection Lost' },
})
const req = new Request('http://localhost/api/transactions', {
method: 'POST',
body: JSON.stringify({
amount: 100,
type: 'income',
category: 'Salary',
date: new Date().toISOString(),
}),
})
const res = await POST(req)
expect(res.status).toBe(500)
})
})
Why Vitest instead of Jest?
在現代 Vite / Next.js 生態中,Vitest 逐漸成為主流。它原生支援 ES Modules (ESM) 與 TypeScript,無需複雜的 babel-jest 轉譯設定,執行速度比 Jest 快上數倍,且 API 幾乎與 Jest 100% 相容。
Mock Hoisting(模擬提升):
在 JavaScript 中,vi.mock() 的呼叫會在任何 import 之前被「提升(Hoisted)」至檔案頂端執行。如果在 Mock 工廠函式內引用了頂層宣告的普通變數,會因為「尚未初始化」而丟出 ReferenceError。利用 vi.hoisted() 可以確保變數在提升階段就完成初始化。
編寫測試是驗證專案健壯度的終極指針。
讓 AI 生成測試腳本不僅能省下 80% 寫重複 boilerplate 的時間,更能透過「看測試案例」反思自己的 API 設計是否有漏掉邊界。