TDD 最佳實踐(Vitest + RTL)
Red-Green-Refactor
- Red:先寫一個失敗的測試(描述想要的行為)
- Green:用最小修改讓測試過(允許醜陋)
- Refactor:保持綠燈重構
鐵律:
- 每次只讓一個測試紅 → 綠
- 不寫沒對應測試的生產代碼
- 不重構沒綠燈的代碼
- 小步前進:每 2-5 分鐘一次紅綠循環
安裝
pnpm add -D vitest @vitest/ui jsdom \
@testing-library/react @testing-library/user-event \
@testing-library/jest-dom
vite.config.ts
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
coverage: { reporter: ['text', 'html'] },
},
})
src/test/setup.ts:
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => cleanup())
檔案命名
foo.ts→foo.test.ts同資料夾- 或
__tests__/foo.test.ts - 選一種專案內一致
測試結構(AAA)
import { describe, it, expect, beforeEach, vi } from 'vitest'
describe('Cube', () => {
let cube: Cube
beforeEach(() => { cube = new Cube() })
it('rotates U face clockwise', () => {
// Arrange
cube.apply('U')
// Act
const top = cube.getFace('U')
// Assert
expect(top).toEqual(Array(9).fill('U'))
})
})
React Testing Library
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
it('increments on click', async () => {
const user = userEvent.setup()
render(<Counter />)
await user.click(screen.getByRole('button', { name: /add/i }))
expect(screen.getByText('1')).toBeInTheDocument()
})
Query 優先順序:getByRole > getByLabelText > getByPlaceholderText > getByText > getByTestId(最後手段)。
測試金字塔
E2E (Playwright) ← 少量、關鍵流程
Integration (RTL) ← 元件 + store
Unit (Vitest pure functions) ← 大量、邏輯
本專案分層建議:
- 純邏輯(cube state、solver wrapper、color classify)→ 大量 unit test
- React 元件(Input 表單、教學面板)→ RTL integration test
- 3D 元件(R3F scene)→ 不測渲染,測背後的 state/邏輯
該測什麼
✅ 測行為:給定輸入 → 期待輸出/副作用 ✅ 邊界條件、錯誤處理、public API ❌ 不測實作細節:私有函式、內部 state 欄位 ❌ 不測第三方套件本身 ❌ 不測 trivial getter/setter
Mock 策略
見 test-doubles skill。原則:能不 mock 就不 mock,優先 Fake > Stub > Mock。
魔術方塊範例(TDD walkthrough)
// 1. Red
it('U move rotates top face clockwise', () => {
const cube = new Cube()
cube.apply('U')
expect(cube.facelets.slice(0, 9)).toBe('UUUUUUUUU')
expect(cube.facelets.slice(9, 12)).toBe('FFF') // R top row = F
})
// 2. Green (最小實作)
class Cube {
facelets = 'UUUUUUUUU RRR... '
apply(move: string) {
if (move === 'U') { /* 實作 U */ }
}
}
// 3. Refactor:抽 rotateFace 工具、查表驅動 6 面
檢查清單
- 每次改代碼前先看看有沒有測試覆蓋
- 一個測試只驗一件事
- 測試名稱講「做什麼」不是「怎麼做」
-
pnpm test全綠才 commit - coverage 看趨勢不看絕對值