Logo
Published on

單元測試的好壞:你的測試在保護你,還是在拖累你?

測試系列文章

不是所有的測試都有價值

「我們的測試覆蓋率有 85%。」

聽起來不錯,但覆蓋率跟品質是兩回事。壞的測試給你虛假的安全感,讓你以為程式碼被保護著,但真正出問題時它一點忙都幫不上。更慘的是,每次重構,壞的測試就會壞掉,你花更多時間修測試而不是修產品程式碼。久了之後,團隊開始覺得「測試就是累贅」,然後就不寫了

差別在哪?下面用八個原則來說明


原則一:測試名稱要能說出「預期行為」

測試名稱是你最重要的文件。三個月後回來看,你能不能只看名稱就知道這個測試在驗什麼?

// ❌ 壞的命名 — 在說實作或什麼都沒說
it('應呼叫 calculateDiscount', () => { ... })
it('測試1', () => { ... })
it('應回傳 true', () => { ... })

// ✅ 好的命名 — 在說行為和預期
it('金卡會員購買 1000 元商品,折扣金額應為 100 元', () => { ... })
it('密碼少於 8 個字元時,驗證應該失敗', () => { ... })
it('超過三次登入失敗,帳號應被鎖定', () => { ... })

好的測試名稱本身就是一個 specification。用 Given-When-Then 的思維來想命名,自然就會對了

試試這個方法:把測試名稱全部列出來,不看程式碼,能不能當成功能規格來讀?能的話,命名就沒問題了


原則二:測試行為,不要測試實作

測試應該驗證「做了什麼事會得到什麼結果」,不是「內部怎麼做到的」。測實作的測試會在你重構時不斷壞掉,即使行為完全沒變

// ❌ 測試實作 — 驗證內部怎麼算的
it('折扣計算應使用乘法', () => {
  const spy = new DiscountCalculatorSpy()
  orderService.checkout(order, spy)
  expect(spy.getLastOperation()).toBe('multiply') // 管太多了
  expect(spy.getLastFactor()).toBe(0.1) // 管太多了
})

// ✅ 測試行為 — 驗證結果是什麼
it('金卡會員的折扣金額應為訂單金額的 10%', () => {
  const calculator = new DiscountCalculator()
  const discount = calculator.calculate('gold', 1000)
  expect(discount).toBe(100)
})

第一個測試驗證的是「你用乘法算」,第二個驗證的是「算出來是 100」。哪天你把乘法換成查表,第一個測試壞掉,但行為沒變。第二個完全不受影響

這跟 Detroit vs London 學派直接相關。Detroit School 傾向驗證狀態 (state verification),London School 傾向用 mock 驗證互動 (behavior verification)。兩者各有場景,但「過度驗證互動」是 London School 最常被批評的問題——測試變成了實作的複製品,重構即死


原則三:測試對應「行為」,不是對應「方法」

很多人寫測試的直覺是:看到一個 class 有五個 method,就寫五個 test,一對一。這種寫法的問題是,你的測試結構變成了實作結構的鏡像,測試跟實作綁死了

PasswordValidator 為例,假設它的結構長這樣:

// PasswordValidator 的結構(四個 method)
class PasswordValidator {
  validateLength(password) { ... }      // 專門驗長度
  validateUppercase(password) { ... }   // 專門驗大寫
  validateDigit(password) { ... }       // 專門驗數字
  validate(password) { ... }            // 整合上面三個,回傳完整驗證結果
}

看到 4 個 method,直覺就是寫 4 個 test,一對一:

// ❌ 一個 method 一個測試 — 測試結構 = 實作結構
describe('密碼驗證器', () => {
  it('應驗證長度', () => { ... })
  it('應驗證大寫字母', () => { ... })
  it('應驗證數字', () => { ... })
  it('應驗證', () => { ... })     // 主方法
})

看起來很整齊,但問題在哪?

validate 這個方法在密碼太短、沒有大寫、沒有數字、全部符合時,各有不同的預期結果。用一個測試不可能涵蓋這些,硬塞在一起就是「一個測試驗太多事」。反過來,「登入失敗三次後帳號被鎖定」這個行為,涉及 submit()getLoginAttempts()、可能還有 isLocked(),但它是一個完整的場景,不應該拆開來測

最要命的問題是:你把一個大 method 拆成兩個,或把兩個合併成一個,測試結構就要跟著重組。但如果測試是按行為組織的,重構根本不影響測試

// ✅ 按行為組織 — 測試結構 = 業務規則結構
describe('密碼驗證', () => {
  it('密碼少於 8 個字元時,驗證應該失敗', () => { ... })
  it('密碼沒有大寫字母時,驗證應該失敗', () => { ... })
  it('密碼沒有數字時,驗證應該失敗', () => { ... })
  it('同時違反多個規則時,應回傳所有錯誤訊息', () => { ... })
  it('密碼符合所有規則時,驗證應該通過', () => { ... })
})

describe('登入流程', () => {
  it('帳號密碼正確時,登入應該成功', () => { ... })
  it('密碼錯誤時,登入應該失敗', () => { ... })
  it('連續三次登入失敗,帳號應被鎖定', () => { ... })
  it('帳號被鎖定後,即使密碼正確也無法登入', () => { ... })
})

按行為組織的測試,即使你把內部實作整個砍掉重寫,只要行為不變,測試一個字都不用改

快速檢查:你的 describeit 讀起來像 API 文件,還是使用情境?前者多半在測 method,後者在測行為

用 TDD 開發,這個問題通常不會出現。先寫預期行為再寫實作,測試天生就按行為組織了。反過來,事後補測試的人最容易掉進這個陷阱——看著現有的程式碼結構,就照著結構寫了


原則四:一個測試只驗一件事

測試失敗時,你要立刻知道是哪個行為壞了。一個測試裡塞五個 assertion 驗五件不同的事,失敗時還要花時間 debug 到底哪個壞了

// ❌ 一個測試驗太多事
it('密碼驗證器應正常運作', () => {
  const validator = new PasswordValidator()

  expect(validator.validate('short').isValid).toBe(false)
  expect(validator.validate('nouppercase123').isValid).toBe(false)
  expect(validator.validate('NOLOWERCASE123').isValid).toBe(false)
  expect(validator.validate('NoDigits').isValid).toBe(false)
  expect(validator.validate('ValidPass1').isValid).toBe(true)
})

// ✅ 每個行為一個測試
it('密碼少於 8 個字元時,驗證應該失敗', () => {
  const validator = new PasswordValidator()
  const result = validator.validate('short')
  expect(result.isValid).toBe(false)
})

it('密碼沒有大寫字母時,驗證應該失敗', () => {
  const validator = new PasswordValidator()
  const result = validator.validate('nouppercase123')
  expect(result.isValid).toBe(false)
})

it('密碼符合所有規則時,驗證應該通過', () => {
  const validator = new PasswordValidator()
  const result = validator.validate('ValidPass1')
  expect(result.isValid).toBe(true)
})

不過「一個測試只驗一件事」不等於「一個測試只有一個 assertion」。多個 assertion 驗的是同一個行為的不同面向,放在一起完全合理

// ✅ 多個 assertion 但驗的是同一個行為的不同面向
it('密碼少於 8 個字元時,應回傳失敗和對應的錯誤訊息', () => {
  const validator = new PasswordValidator()
  const result = validator.validate('short')

  expect(result.isValid).toBe(false)
  expect(result.errors).toContain('密碼長度至少 8 個字元')
})

isValiderrors 是同一件事的兩個面向——驗證失敗時,結果是 false 且要有錯誤訊息。這不算「驗太多事」


原則五:Assertion 要精確

弱的 assertion 等於沒有 assertion。測試一直綠燈但什麼都沒驗到,這種虛假安全感最危險

// ❌ 弱的 assertion
it('應回傳折扣金額', () => {
  const result = calculateDiscount('gold', 1000)
  expect(result).toBeDefined() // 回傳 9999 也會過
  expect(result).toBeTruthy() // 回傳 1 也會過
  expect(typeof result).toBe('number') // 回傳 -500 也會過
})

// ✅ 精確的 assertion
it('金卡會員購買 1000 元商品,折扣金額應為 100 元', () => {
  const result = calculateDiscount('gold', 1000)
  expect(result).toBe(100)
})

每一個 assertion 都值得問自己:「如果實作是錯的,這個 assertion 會抓到嗎?」錯誤的實作也能讓測試通過,那這個 assertion 就沒用


原則六:測試要能獨立執行

測試之間不應該有順序依賴。A 測試修改了共享狀態,B 測試必須在 A 之後跑才過——這種定時炸彈最難抓

// ❌ 測試之間有隱性依賴
let sharedUser

it('應建立使用者', () => {
  sharedUser = createUser('alice') // 後面的測試都依賴這個
  expect(sharedUser).toBeDefined()
})

it('應更新使用者 email', () => {
  sharedUser.email = '[email protected]' // 如果上一個測試沒跑過呢?
  expect(sharedUser.email).toBe('[email protected]')
})

// ✅ 每個測試自己 setup
it('應建立使用者', () => {
  const user = createUser('alice')
  expect(user).toBeDefined()
})

it('應更新使用者 email', () => {
  const user = createUser('alice') // 自己建立需要的狀態
  user.email = '[email protected]'
  expect(user.email).toBe('[email protected]')
})

每個測試都應該自己 setup 需要的狀態,用完就丟。任何順序執行測試,甚至隨機排序,結果都要一樣


原則七:不要過度使用 Test Double

Test Double 是好工具,但過度使用會讓測試跟實作細節綁死。如果測試裡 mock/stub 的 setup 比 assertion 還多,通常代表兩件事之一:你 mock 太多了,或者設計耦合太高

// ❌ Mock 比產品程式碼還複雜
it('應處理訂單', () => {
  const mockDb = { save: vi.fn().mockResolvedValue({ id: 1 }) }
  const mockLogger = { info: vi.fn(), error: vi.fn() }
  const mockEmailService = { send: vi.fn().mockResolvedValue(true) }
  const mockInventory = { check: vi.fn().mockReturnValue(true), reserve: vi.fn() }
  const mockPayment = { charge: vi.fn().mockResolvedValue({ txId: 'abc' }) }

  const service = new OrderService(mockDb, mockLogger, mockEmailService, mockInventory, mockPayment)
  // ... 到這裡都還沒開始測試
})

發現自己在寫一大堆 mock setup 時,先停下來問:「這是測試的問題,還是設計的問題?」通常是後者。一個 class 需要五個依賴才能運作,多半違反了單一職責原則

TDD 的人對這個很敏感——測試難寫是設計在跟你說話。「我需要 mock 五個東西才能跑」這句話翻譯成白話就是「這個 class 做太多事了」


原則八:測試也是程式碼,需要重構

很多人把測試當二等公民,「能跑就好」。但測試程式碼同樣需要可讀性和可維護性。重複的 setup 可以抽成 helper function,常用的測試資料可以建 factory

// ❌ 每個測試都重複一大堆 setup
it('金卡會員購買 1000 元', () => {
  const validator = new PasswordValidator({
    minLength: 8,
    requireUppercase: true,
    requireDigit: true,
  })
  // ...
})

it('銀卡會員購買 500 元', () => {
  const validator = new PasswordValidator({
    minLength: 8,
    requireUppercase: true,
    requireDigit: true,
  })
  // ...
})

// ✅ 抽出共用的 setup
function createDefaultValidator() {
  return new PasswordValidator({
    minLength: 8,
    requireUppercase: true,
    requireDigit: true,
  })
}

it('金卡會員購買 1000 元', () => {
  const validator = createDefaultValidator()
  // ...
})

但過度抽象也有問題。讀者需要跳到三個 helper function 才能理解一個測試在做什麼,那可讀性反而更低了

Arrange(準備)可以抽共用,但 Act(執行)和 Assert(驗證)要留在測試裡。讀者不離開測試本身,就能看到「做了什麼」和「預期什麼」

說到底,標準只有一個:讀者不看實作,能不能直接看懂這段在驗什麼。helper 名稱本身就是行為描述時,Act 和 Assert 包進去也沒差。看到 givenUserExists()givenLoginFailedCount()shouldThrow(AccountLockedException),不看實作就知道這個測試場景

❌ 壞的:測試本體充滿 API 細節,讀起來是程式碼,不是場景

// ❌ 需要理解 mock 設定細節,才知道這段在測什麼
it('登入失敗 5 次後,帳號應被鎖定', async () => {
  accountDao.findByUsername.mockResolvedValue({ username: 'alice', password: 'hashed' })
  crypto.hash.mockReturnValue('wrong-hash')
  accountDao.getFailedCount.mockResolvedValue(5)

  await expect(accountBL.login('alice', 'wrongpass')).rejects.toThrow(AccountLockedException)
})

✅ 好的:helper 名稱就是場景描述,測試本體讀起來像規格

// ✅ 測試本體本身就是場景腳本
it('登入失敗 5 次後,帳號應被鎖定', async () => {
  givenUserExists('alice', 'hashed')
  givenLoginFailedCount('alice', 5)

  await shouldThrow(AccountLockedException, () =>
    accountBL.login('alice', 'wrongpass')
  )
})

// helper 把 mock 細節收起來,名稱說清楚前提和預期
function givenUserExists(username, hashedPassword) { ... }
function givenLoginFailedCount(username, count) { ... }
function shouldThrow(ExceptionClass, fn) { ... }

這樣一來,測試本體就是場景說明:givenUserExistsgivenLoginFailedCountshouldThrow(AccountLockedException),讀起來就是「有一個使用者,已登入失敗 5 次,再嘗試登入應該拋出鎖定例外」。測試即文件,不用另外寫 spec


反模式集錦

壞的測試有固定的模式,你在團隊的 codebase 裡大概都見過其中幾個

The Giant(巨人)

一個測試超過 50 行,setup 佔了 80%。要嘛你在測太大的單位,要嘛設計耦合太高

// ❌ The Giant — setup 比測試本體還長
it('應成功完成結帳流程', async () => {
  // 建立資料庫連線
  const db = new Database({ host: 'localhost', port: 5432, name: 'testdb' })
  await db.connect()

  // 建立使用者
  const user = new User({ id: 1, name: 'Alice', tier: 'gold' })
  await db.save(user)

  // 建立商品
  const product = new Product({ id: 101, name: '耳機', price: 1000, stock: 10 })
  await db.save(product)

  // 建立購物車
  const cart = new Cart({ userId: 1 })
  cart.addItem(product, 1)
  await db.save(cart)

  // 建立折扣規則
  const discountRule = new DiscountRule({ tier: 'gold', percentage: 10 })
  await db.save(discountRule)

  // 建立支付設定
  const paymentConfig = new PaymentConfig({
    provider: 'stripe',
    apiKey: 'test_key',
    currency: 'TWD',
  })

  // 建立所有依賴
  const inventoryService = new InventoryService(db)
  const discountService = new DiscountService(db)
  const paymentService = new PaymentService(paymentConfig)
  const emailService = new EmailService({ smtpHost: 'localhost' })
  const logger = new Logger({ level: 'debug' })
  const orderService = new OrderService(
    db,
    inventoryService,
    discountService,
    paymentService,
    emailService,
    logger
  )

  // 終於開始測試了...
  const result = await orderService.checkout(user.id, cart.id)

  expect(result.success).toBe(true)
  // 等了 60 行,就驗這一行

  await db.disconnect()
})

你在測「完整的 checkout 流程」,但範圍太大了。任何一個依賴出問題,測試就壞,但你不知道是哪個環節。setup 這麼複雜,讀者根本看不出測試的重點是什麼

// ✅ 用 factory function 把 setup 精簡
function createOrderService(overrides = {}) {
  const defaults = {
    db: createInMemoryDb(),
    inventoryService: createFakeInventoryService({ hasStock: true }),
    discountService: createFakeDiscountService({ percentage: 10 }),
    paymentService: createFakePaymentService({ success: true }),
    emailService: createFakeEmailService(),
    logger: createNullLogger(),
  }
  return new OrderService({ ...defaults, ...overrides })
}

it('金卡會員結帳,應套用 10% 折扣', async () => {
  const orderService = createOrderService()
  const result = await orderService.checkout({ userId: 1, cartTotal: 1000, tier: 'gold' })

  expect(result.discountAmount).toBe(100)
  expect(result.finalAmount).toBe(900)
})

it('庫存不足時,結帳應該失敗', async () => {
  const orderService = createOrderService({
    inventoryService: createFakeInventoryService({ hasStock: false }),
  })
  const result = await orderService.checkout({ userId: 1, cartTotal: 1000, tier: 'gold' })

  expect(result.success).toBe(false)
  expect(result.error).toBe('庫存不足')
})

共用的 setup 抽成 factory,每個測試只描述「跟這個測試有關的差異」。重點一眼就清楚了


The Inspector(檢查員)

驗證每一個內部方法是否被呼叫、呼叫幾次、用什麼參數。你測的不是行為,是實作的每一步——重構即死

// ❌ The Inspector — 把實作細節照單全收
describe('OrderService 結帳流程', () => {
  let discountService, inventoryService, paymentService, logger, orderService, order

  beforeEach(() => {
    discountService = new DiscountService()
    inventoryService = new InventoryService()
    paymentService = new PaymentService()
    logger = new Logger()
    orderService = new OrderService(discountService, inventoryService, paymentService, logger)
    order = { userId: 1, cartTotal: 1000, tier: 'gold', items: [{ id: 101, qty: 1 }] }
  })

  it('結帳時應套用折扣', () => {
    const discountSpy = vi.spyOn(discountService, 'calculate')
    const inventorySpy = vi.spyOn(inventoryService, 'reserve')
    const paymentSpy = vi.spyOn(paymentService, 'charge')
    const loggerSpy = vi.spyOn(logger, 'info')

    orderService.checkout(order)

    // 驗每個內部呼叫
    expect(discountSpy).toHaveBeenCalledOnce()
    expect(discountSpy).toHaveBeenCalledWith('gold', 1000)
    expect(inventorySpy).toHaveBeenCalledWith(order.items)
    expect(paymentSpy).toHaveBeenCalledWith(900)
    expect(loggerSpy).toHaveBeenCalledWith('checkout completed', { orderId: order.id })
  })
})

這個測試是你實作流程的複製品。「先算折扣、再扣庫存、再刷卡、最後記 log」的順序被寫死在測試裡了。哪天改成「先扣庫存、再算折扣」(完全合理的重構),這個測試就壞了——行為沒變,但測試壞了

// ✅ 只驗你真正在意的結果
it('金卡會員結帳 1000 元,最終應收 900 元', () => {
  const result = orderService.checkout({
    userId: 1,
    cartTotal: 1000,
    tier: 'gold',
  })

  expect(result.charged).toBe(900)
  expect(result.success).toBe(true)
})

你關心的是「金卡會員結帳 1000 元最後被收 900 元」,不是「這中間呼叫了哪些 method」。關注結果,不是過程


The Flicker(閃爍者)

時過時不過的測試。跟時間、亂數、執行順序或外部依賴有關。這種測試最折磨人——沒有人能解釋它為什麼失敗,團隊久了就開始忽略失敗的測試了

// ❌ The Flicker — 依賴當前時間
it('訂單超過 30 分鐘後應標記為過期', () => {
  const order = new Order({
    id: 1,
    createdAt: new Date(Date.now() - 31 * 60 * 1000), // 31 分鐘前
  })

  // 如果這個測試在午夜跨日時跑,或者機器時間跑慢了,結果可能不同
  expect(order.isExpired()).toBe(true)
})

// ❌ 依賴亂數
it('應打亂商品順序', () => {
  const items = [1, 2, 3, 4, 5]
  const shuffled = shuffle(items)

  // 這個測試可能偶爾通過(剛好沒洗牌),偶爾失敗
  expect(shuffled).not.toEqual(items)
})

CI 跑一百次可能通過九十八次,偶爾神秘失敗,但你找不到 bug。「現在是幾點」或「亂數怎麼跑」決定測試結果,這讓測試變成非決定性的

// ✅ 注入時間依賴,讓測試完全控制時間
class Order {
  constructor({ id, createdAt, clock = Date }) {
    this.id = id
    this.createdAt = createdAt
    this.clock = clock // 把時間來源注入,方便測試替換
  }

  isExpired() {
    const now = this.clock.now()
    const thirtyMinutes = 30 * 60 * 1000
    return now - this.createdAt.getTime() > thirtyMinutes
  }
}

it('訂單建立超過 30 分鐘後,應標記為過期', () => {
  const fixedNow = new Date('2026-02-18T12:00:00Z').getTime()
  const fakeClock = { now: () => fixedNow }

  const order = new Order({
    id: 1,
    createdAt: new Date('2026-02-18T11:29:00Z'), // 31 分鐘前
    clock: fakeClock,
  })

  expect(order.isExpired()).toBe(true)
})

it('訂單建立不到 30 分鐘,不應標記為過期', () => {
  const fixedNow = new Date('2026-02-18T12:00:00Z').getTime()
  const fakeClock = { now: () => fixedNow }

  const order = new Order({
    id: 1,
    createdAt: new Date('2026-02-18T11:35:00Z'), // 25 分鐘前
    clock: fakeClock,
  })

  expect(order.isExpired()).toBe(false)
})

把「現在是幾點」這個外部依賴注入進來,測試完全控制時間,結果就是決定性的了


The Silent(沈默者)

有測試但沒有 assertion,或 assertion 太弱。永遠綠燈,但什麼都沒保護到

// ❌ The Silent — 完全沒有 assertion
it('應處理支付', async () => {
  const payment = new Payment({ amount: 1000, method: 'credit_card' })
  await paymentService.process(payment)
  // 這裡什麼都沒驗,測試永遠過
})

// ❌ assertion 太弱,沒有意義
it('應計算折扣', () => {
  const result = calculateDiscount('gold', 1000)
  expect(result).toBeDefined() // 回傳 undefined 以外的任何值都會過
  expect(result).not.toBeNull() // 沒有排除 0, -500, 9999...
})

// ❌ 只驗型別,不驗值
it('應回傳數字', () => {
  const result = calculateDiscount('gold', 1000)
  expect(typeof result).toBe('number') // 回傳 NaN 也是 number
})

有人刪掉 calculateDiscount 裡的核心邏輯,讓它直接回傳 0,這個測試還是會通過。你以為有保護,其實沒有

// ✅ 精確驗證你真正在意的結果
it('金卡會員購買 1000 元,折扣應為 100 元', () => {
  const result = calculateDiscount('gold', 1000)
  expect(result).toBe(100)
})

it('支付成功後,訂單狀態應更新為 paid', async () => {
  const payment = new Payment({ amount: 1000, method: 'credit_card' })
  const result = await paymentService.process(payment)

  expect(result.status).toBe('paid')
  expect(result.transactionId).toMatch(/^txn_/) // 驗格式
  expect(result.paidAt).toBeDefined()
})

每個 assertion 都值得問:「如果實作是錯的,這行會讓測試失敗嗎?」不會的話,就刪掉它


The Chain(鎖鏈)

測試之間有順序依賴,單獨跑會失敗

// ❌ The Chain — 測試依賴執行順序
let createdUserId // 共享狀態,跨測試傳遞

it('應建立使用者', async () => {
  const user = await userService.create({ name: 'Alice', email: '[email protected]' })
  createdUserId = user.id // 把 id 存起來讓後面的測試用
  expect(user.name).toBe('Alice')
})

it('應更新使用者 email', async () => {
  // 如果上一個測試沒跑,createdUserId 是 undefined,這個測試就爆了
  const updated = await userService.update(createdUserId, { email: '[email protected]' })
  expect(updated.email).toBe('[email protected]')
})

it('應刪除使用者', async () => {
  // 同樣依賴前面兩個測試
  await userService.delete(createdUserId)
  const user = await userService.findById(createdUserId)
  expect(user).toBeNull()
})

隨機打亂順序,這三個測試全部失敗,因為 createdUserId 要在第一個測試跑完後才有值。而且一旦第一個測試壞了,後面全部跟著壞,你以為有三個 bug,其實只有一個

// ✅ 每個測試自己 setup,彼此獨立
it('應建立使用者', async () => {
  const user = await userService.create({ name: 'Alice', email: '[email protected]' })
  expect(user.name).toBe('Alice')
})

it('應更新使用者 email', async () => {
  // 自己建立需要的前置資料
  const user = await userService.create({ name: 'Alice', email: '[email protected]' })
  const updated = await userService.update(user.id, { email: '[email protected]' })

  expect(updated.email).toBe('[email protected]')
})

it('應刪除使用者', async () => {
  // 自己建立需要的前置資料
  const user = await userService.create({ name: 'Alice', email: '[email protected]' })
  await userService.delete(user.id)

  const deleted = await userService.findById(user.id)
  expect(deleted).toBeNull()
})

是的,每個測試都要自己建立 user。看起來重複,但換來的是完全的獨立性。任何一個測試失敗,都不會拖累其他測試


The Mockery(嘲弄者)

mock 的行為跟真實物件不一樣。測試通過,但實際使用會爆。真實物件的行為改了,mock 不會自動更新,所以測試還是綠的,但產品已經壞了

// ❌ The Mockery — mock 跟真實物件行為不符
it('應成功儲存使用者', async () => {
  // Mock 總是回傳成功
  const mockUserRepository = {
    save: vi.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
  }

  const userService = new UserService(mockUserRepository)
  const result = await userService.createUser({ name: 'Alice', email: '[email protected]' })

  expect(result.id).toBe(1)
})

// 但真實的 repository 在 email 重複時會拋出例外:
// class UserRepository {
//   async save(user) {
//     if (await this.db.findByEmail(user.email)) {
//       throw new UniqueConstraintError('email already exists')
//     }
//     // ...
//   }
// }

// 所以你的 userService 沒有處理這個例外
// 但 mock 永遠回傳成功,所以測試不會發現問題
// 等到上 production,第二個用相同 email 的使用者一註冊就爆了

mock 切斷了你跟真實實作的連結。真實的 save() 可能拋出 UniqueConstraintError,但 mock 永遠成功,所以 UserService 沒有處理這個錯誤路徑。測試綠的,production 爆的

修正方向有兩個。

第一,如果需要 mock 外部依賴,考慮用合約測試 (contract test) 確保 mock 的行為跟真實物件一致

// 合約測試:確保你的 mock 跟真實物件行為一致
describe('UserRepository 合約', () => {
  // 對真實 repository 和 mock 都跑同一套測試
  const implementations = [
    { name: 'real', factory: () => new UserRepository(testDb) },
    { name: 'mock', factory: () => createUserRepositoryMock() },
  ]

  implementations.forEach(({ name, factory }) => {
    describe(name, () => {
      it('儲存相同 email 的使用者時,應拋出 UniqueConstraintError', async () => {
        const repo = factory()
        await repo.save({ name: 'Alice', email: '[email protected]' })

        await expect(repo.save({ name: 'Bob', email: '[email protected]' })).rejects.toThrow(
          UniqueConstraintError
        )
      })
    })
  })
})

第二,對於資料庫這類外部依賴,考慮用整合測試取代 mock,直接對真實(或測試用)資料庫跑測試,問題根本就不存在了


怎麼判斷測試好不好

好的測試有三個特徵,很好記:

看名稱就知道在測什麼行為。不用打開測試的程式碼,光看 test runner 的輸出,就能理解每個測試在驗什麼

壞掉時立刻知道是什麼行為壞了。失敗訊息應該告訴你「金卡會員的折扣算錯了」,不是「第 47 行的 expect 失敗」

重構時不需要跟著改。如果每次重構都要同時修一堆測試,那些測試測的是實作,不是行為


FIRST 原則

Robert C. Martin 在《Clean Code》第九章用五個字母總結好測試的特徵,跟前面的八個原則和六個反模式對照,會發現講的是同一件事

Fast(快速),測試跑太慢,開發者就懶得跑。懶得跑就不常跑,久了就不敢改 code。The Giant 反模式裡那種要連資料庫、塞一堆假資料才能跑的測試,就是典型的慢

Independent(獨立),A 測試不能影響 B 測試。不管什麼順序跑、甚至隨機打亂,結果都要一樣。前面的原則六和 The Chain 反模式講的就是這件事

Repeatable(可重複),在你的筆電上跑會過,換到 CI server 或同事的電腦上也要過。The Flicker 反模式就是綁了當前時間或亂數,換個環境結果就變了

Self-Validating(自我驗證),測試失敗時,光看失敗訊息就能知道是什麼行為壞了,不需要再去翻 log、查資料庫或手動比對。這跟前面「怎麼判斷測試好不好」說的一樣——「壞掉時立刻知道是什麼行為壞了」。The Silent 反模式就是反面教材,永遠綠燈但什麼都沒驗到,壞了也不會告訴你

Timely(及時),不是每個人都做 TDD,但至少在寫完產品程式碼的當下就把測試一起寫完,一起進版控。拖到「之後再補」,通常就是不補了,或者補出來的測試照著現有的程式碼結構寫,測的是 method 不是行為,掉進原則三說的那個陷阱


Test Double 的工具TDD 學派的立場單元測試和 TDD 的關係,加上這篇的判斷標準,AI 時代的 Code Review 才有具體的東西可以說——不只是「感覺這個測試不好」,而是能指出它壞在哪個原則


圖片來源:AI 產生