經過前四天的準備工作(PRD、User Story、AC、UI/UX),今天我們終於要開始寫 code 了!
但不是直接寫功能,而是用 TDD(Test-Driven Development) 的方式:先寫測試,再寫實作。
很多人(包括以前的我)覺得 TDD 很麻煩:「為什麼要先寫測試?直接寫功能不是更快嗎?」
但經歷過幾次「改A壞B」的痛苦後,我才理解 TDD 的價值:
傳統開發:功能 → 測試 → 發現Bug → 修Bug → 又壞了別的地方
TDD 開發:測試 → 功能 → 通過測試 → 有保護網可以放心重構
今天的任務:
在開始之前,先理解 TDD 的核心流程。
🔴 Red(紅燈):寫一個失敗的測試
    ↓
🟢 Green(綠燈):寫最少的 code 讓測試通過
    ↓
♻️ Refactor(重構):優化 code 但保持測試通過
    ↓
重複循環
1. 紅燈階段:確保測試有用
2. 綠燈階段:快速驗證想法
3. 重構階段:提升代碼品質
在前三個專案的實踐中,我深刻體會到 TDD 的好處:
1. 早期發現問題
2. 有信心重構
3. 活文件
首先,建立一個全新的 Next.js 14 專案。
# 使用 create-next-app 建立專案
npx create-next-app@latest bolthq
# 選項:
# ✓ TypeScript
# ✓ ESLint
# ✓ Tailwind CSS
# ✓ src/ directory
# ✓ App Router
# × Turbopack
cd bolthq
# UI 套件
npm install @radix-ui/react-slot class-variance-authority clsx tailwind-merge lucide-react
# 狀態管理
npm install zustand @tanstack/react-query
# 表單處理
npm install react-hook-form zod @hookform/resolvers
# AI 整合
npm install @anthropic-ai/sdk
# 測試相關(開發相依)
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom @vitejs/plugin-react
根據 Day 4 的設計,建立清晰的資料夾結構:
bolthq/
├── src/
│   ├── app/              # Next.js App Router
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── globals.css
│   │
│   ├── components/      # UI 元件
│   │   ├── ui/          # 基礎元件
│   │   ├── forms/       # 表單元件
│   │   └── layouts/     # 版面元件
│   │
│   ├── lib/             # 工具函式
│   │   ├── utils.ts
│   │   └── design-tokens.ts
│   │
│   ├── services/        # 業務邏輯
│   │   ├── ai/
│   │   └── github/
│   │
│   ├── stores/          # Zustand 狀態管理
│   │
│   └── types/           # TypeScript 型別
│
├── tests/              # 測試檔案
│   ├── components/
│   ├── services/
│   └── setup.ts
│
├── docs/               # 文件
│   ├── PRD.md
│   ├── USER_STORIES.md
│   └── UI_UX_DESIGN.md
│
├── vitest.config.ts    # Vitest 設定
└── package.json
為什麼這樣分類?
Next.js 14 預設沒有測試設定,我們要手動配置 Vitest。
創建 vitest.config.ts:
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./tests/setup.ts'],
    globals: true,
    css: true,
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
})
創建 tests/setup.ts:
import { expect, afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'
import * as matchers from '@testing-library/jest-dom/matchers'
// 擴充 expect
expect.extend(matchers)
// 每個測試後清理
afterEach(() => {
  cleanup()
})
{
  \"scripts\": {
    \"dev\": \"next dev\",
    \"build\": \"next build\",
    \"start\": \"next start\",
    \"test\": \"vitest\",
    \"test:ui\": \"vitest --ui\",
    \"test:coverage\": \"vitest --coverage\"
  }
}
先寫一個簡單的測試驗證環境:
// tests/example.test.ts
import { describe, it, expect } from 'vitest'
describe('Test Environment', () => {
  it('should work', () => {
    expect(1 + 1).toBe(2)
  })
})
npm test
結果:
🟢 Test Files  1 passed (1)
🟢 Tests  1 passed (1)
完美!測試環境就緒了。
根據 Day 4 的設計,先建立 Design System 的基礎。
創建 src/lib/design-tokens.ts:
export const colors = {
  primary: {
    DEFAULT: '#2563EB',
    hover: '#1D4ED8',
    active: '#1E40AF',
    light: '#DBEAFE',
    dark: '#1E3A8A',
  },
  secondary: {
    DEFAULT: '#7C3AED',
    hover: '#6D28D9',
    light: '#EDE9FE',
  },
  success: '#10B981',
  warning: '#F59E0B',
  error: '#EF4444',
  info: '#3B82F6',
  gray: {
    50: '#F9FAFB',
    100: '#F3F4F6',
    200: '#E5E7EB',
    300: '#D1D5DB',
    400: '#9CA3AF',
    500: '#6B7280',
    600: '#4B5563',
    700: '#374151',
    800: '#1F2937',
    900: '#111827',
  },
} as const
export const spacing = {
  0: '0px',
  1: '4px',
  2: '8px',
  3: '12px',
  4: '16px',
  5: '20px',
  6: '24px',
  8: '32px',
  10: '40px',
  12: '48px',
  16: '64px',
} as const
export const borderRadius = {
  sm: '4px',
  md: '8px',
  lg: '12px',
  xl: '16px',
  full: '9999px',
} as const
import type { Config } from 'tailwindcss'
import { colors, spacing, borderRadius } from './src/lib/design-tokens'
const config: Config = {
  content: [
    './src/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {
      colors,
      spacing,
      borderRadius,
      fontFamily: {
        sans: ['Inter', 'Noto Sans TC', 'sans-serif'],
        mono: ['JetBrains Mono', 'monospace'],
      },
    },
  },
}
export default config
根據 Day 4 的設計,建立 Button 元件。
先創建工具函式 src/lib/utils.ts:
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
然後創建 src/components/ui/button.tsx:
import { ButtonHTMLAttributes, forwardRef } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        primary: 'bg-primary text-white hover:bg-primary-hover active:scale-[0.98]',
        secondary: 'border-2 border-primary text-primary hover:bg-primary-light',
        ghost: 'text-gray-600 hover:bg-gray-100',
      },
      size: {
        sm: 'h-9 px-3 text-sm',
        md: 'h-10 px-4',
        lg: 'h-12 px-6 text-lg',
      },
    },
    defaultVariants: {
      variant: 'primary',
      size: 'md',
    },
  }
)
export interface ButtonProps
  extends ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, ...props }, ref) => {
    return (
      <button
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    )
  }
)
Button.displayName = 'Button'
export { Button, buttonVariants }
現在環境都準備好了,開始第一個 TDD 循環!
根據 Day 3 的 User Story #1,我們要實作 Spec Input 頁面的第一個 AC。
Given 我在首頁
When 我點擊「開始新專案」
Then 系統顯示一個大型文字輸入框
And 有提示文字:「用一段話描述你想做的產品」
And 有範例可以參考
創建 tests/components/project-input.test.tsx:
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ProjectInput } from '@/components/forms/project-input'
describe('ProjectInput Component', () => {
  describe('AC 1.1: 基本輸入介面', () => {
    it('應該顯示一個大型文字輸入框', () => {
      render(<ProjectInput />)
      
      const textarea = screen.getByRole('textbox')
      expect(textarea).toBeInTheDocument()
      expect(textarea).toHaveClass('min-h-[200px]')
    })
    
    it('應該顯示提示文字', () => {
      render(<ProjectInput />)
      
      const placeholder = screen.getByPlaceholderText(/用一段話描述/i)
      expect(placeholder).toBeInTheDocument()
    })
    
    it('應該有範例連結', () => {
      render(<ProjectInput />)
      
      const exampleLink = screen.getByText(/查看範例/i)
      expect(exampleLink).toBeInTheDocument()
    })
  })
})
執行測試:
npm test
結果:
🔴 FAIL  tests/components/project-input.test.tsx
  ✗ Error: Cannot find module '@/components/forms/project-input'
完美!這正是我們要的 紅燈。
現在寫最少的 code 讓測試通過。
創建 src/components/forms/project-input.tsx:
import { Button } from '@/components/ui/button'
export function ProjectInput() {
  return (
    <div className=\"w-full max-w-2xl mx-auto p-6\">
      <div className=\"mb-6\">
        <label htmlFor=\"project-name\" className=\"block text-sm font-medium mb-2\">
          專案名稱
        </label>
        <input
          type=\"text\"
          id=\"project-name\"
          className=\"w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary\"
        />
      </div>
      
      <div className=\"mb-6\">
        <label htmlFor=\"project-description\" className=\"block text-sm font-medium mb-2\">
          描述你想做的產品
        </label>
        <textarea
          id=\"project-description\"
          className=\"w-full min-h-[200px] px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary resize-y\"
          placeholder=\"用一段話描述你想做的產品…\"
        />
        
        <p className=\"mt-2 text-sm text-gray-500\">
          💡 提示:至少 20 字,越詳細越好
        </p>
      </div>
      
      <div className=\"flex items-center justify-between\">
        <button className=\"text-primary hover:underline\">
          查看範例
        </button>
        
        <Button>
          開始分析 →
        </Button>
      </div>
    </div>
  )
}
執行測試:
npm test
結果:
🟢 PASS  tests/components/project-input.test.tsx
  ✓ 應該顯示一個大型文字輸入框
  ✓ 應該顯示提示文字
  ✓ 應該有範例連結
🟢 Tests  3 passed (3)
太好了!綠燈!
現在測試通過了,可以優化 code:
import { useState } from 'react'
import { Button } from '@/components/ui/button'
interface ProjectInputProps {
  onSubmit?: (data: { name: string; description: string }) => void
  onShowExample?: () => void
}
export function ProjectInput({ onSubmit, onShowExample }: ProjectInputProps) {
  const [name, setName] = useState('')
  const [description, setDescription] = useState('')
  
  const charCount = description.length
  const minChars = 20
  const isValid = charCount >= minChars
  
  const handleSubmit = () => {
    if (isValid && onSubmit) {
      onSubmit({ name, description })
    }
  }
  
  return (
    <div className=\"w-full max-w-2xl mx-auto p-6\">
      <div className=\"mb-6\">
        <label 
          htmlFor=\"project-name\" 
          className=\"block text-sm font-medium text-gray-700 mb-2\"
        >
          專案名稱
        </label>
        <input
          type=\"text\"
          id=\"project-name\"
          value={name}
          onChange={(e) => setName(e.target.value)}
          className=\"w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary transition-all\"
          placeholder=\"給你的專案取個名字…\"
        />
      </div>
      
      <div className=\"mb-6\">
        <label 
          htmlFor=\"project-description\" 
          className=\"block text-sm font-medium text-gray-700 mb-2\"
        >
          描述你想做的產品
        </label>
        <textarea
          id=\"project-description\"
          value={description}
          onChange={(e) => setDescription(e.target.value)}
          className=\"w-full min-h-[200px] px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary resize-y transition-all\"
          placeholder=\"用一段話描述你想做的產品…\"
        />
        
        <div className=\"mt-2 flex items-center justify-between\">
          <p className=\"text-sm text-gray-500\">
            💡 提示:至少 {minChars} 字,越詳細越好
          </p>
          <p className={`text-sm ${
            charCount < minChars ? 'text-gray-400' : 'text-green-600'
          }`}>
            {charCount} / {minChars}
          </p>
        </div>
      </div>
      
      <div className=\"flex items-center justify-between\">
        <button 
          onClick={onShowExample}
          className=\"text-primary hover:underline transition-all\"
        >
          查看範例
        </button>
        
        <Button
          onClick={handleSubmit}
          disabled={!isValid}
        >
          開始分析 →
        </Button>
      </div>
    </div>
  )
}
再次執行測試:
npm test
結果:
🟢 PASS  tests/components/project-input.test.tsx
  ✓ 應該顯示一個大型文字輸入框
  ✓ 應該顯示提示文字
  ✓ 應該有範例連結
完美!重構後測試仍然通過。
現在我們已經完成了 AC 1.1,繼續 AC 1.2。
Given 我在輸入框中
When 我輸入少於 20 字的描述
Then 系統顯示警告:「請至少描述 20 字以上」
And 提交按鈕保持禁用狀態
import { userEvent } from '@testing-library/user-event'
describe('AC 1.2: 輸入驗證', () => {
  it('輸入少於 20 字應該禁用按鈕', async () => {
    const user = userEvent.setup()
    render(<ProjectInput />)
    
    const textarea = screen.getByRole('textbox', { name: /描述/i })
    await user.type(textarea, 'short text')
    
    const button = screen.getByRole('button', { name: /開始分析/i })
    expect(button).toBeDisabled()
  })
  
  it('輸入超過 20 字應該啟用按鈕', async () => {
    const user = userEvent.setup()
    render(<ProjectInput />)
    
    const textarea = screen.getByRole('textbox', { name: /描述/i })
    await user.type(textarea, '這是一段超過 20 字的描述內容,用來測試按鈕是否啟用')
    
    const button = screen.getByRole('button', { name: /開始分析/i })
    expect(button).toBeEnabled()
  })
  
  it('應該顯示字數統計', async () => {
    const user = userEvent.setup()
    render(<ProjectInput />)
    
    const textarea = screen.getByRole('textbox', { name: /描述/i })
    await user.type(textarea, '測試')
    
    expect(screen.getByText(/2 \\/ 20/)).toBeInTheDocument()
  })
})
執行測試:
npm test
結果:
🟢 PASS  AC 1.1 (3 tests)
🟢 PASS  AC 1.2 (3 tests)
🟢 Tests  6 passed (6)
太好了!我們剛才重構時已經實作了這些功能,所以測試直接通過。
這就是 TDD 的魅力!
✅ 建立 Next.js 14 專案
✅ 設定 Vitest 測試環境
✅ 建立 Design System 基礎
✅ 實作第一個元件:ProjectInput
✅ 完成 6 個測試案例(AC 1.1 & 1.2)
✅ 體驗完整的 TDD 紅綠燈循環
更重要的是,我們現在有:
全部都是 SDD AI Sprint 前面已經有規格的情況下產出程式碼,效率非常非常高~