iT邦幫忙

2025 iThome 鐵人賽

DAY 29
0
佛心分享-SideProject30

AI-Driven Development 實戰篇:30 天 Side Project 開發全紀錄系列 第 29

Day29 - BoltHQ TDD - Day 5:環境設定與第一個測試,終於要開工了!

  • 分享至 

  • xImage
  •  

經過前四天的準備工作(PRD、User Story、AC、UI/UX),今天我們終於要開始寫 code 了!
但不是直接寫功能,而是用 TDD(Test-Driven Development) 的方式:先寫測試,再寫實作。

很多人(包括以前的我)覺得 TDD 很麻煩:「為什麼要先寫測試?直接寫功能不是更快嗎?」

但經歷過幾次「改A壞B」的痛苦後,我才理解 TDD 的價值:

傳統開發:功能 → 測試 → 發現Bug → 修Bug → 又壞了別的地方
TDD 開發:測試 → 功能 → 通過測試 → 有保護網可以放心重構

今天的任務:

  1. 建立 Next.js 14 專案
  2. 設定測試環境(Vitest + Testing Library)
  3. 建立 Design System 基礎
  4. 寫第一個測試(Story #1 的 AC 1.1)
  5. 讓測試通過
  6. 體驗完整的 TDD 紅綠燈循環

TDD 的核心:Red-Green-Refactor

在開始之前,先理解 TDD 的核心流程。

Red-Green-Refactor 循環

🔴 Red(紅燈):寫一個失敗的測試
    ↓
🟢 Green(綠燈):寫最少的 code 讓測試通過
    ↓
♻️ Refactor(重構):優化 code 但保持測試通過
    ↓
重複循環

為什麼要這樣做?

1. 紅燈階段:確保測試有用

  • 如果測試一開始就是綠燈,代表測試沒有測到東西
  • 紅燈證明我們正在測試「還不存在的功能」

2. 綠燈階段:快速驗證想法

  • 不要想太多,先讓測試通過
  • 「最少的 code」不代表「爛的 code」,而是「剛好足夠」
  • 目標是快速看到綠燈

3. 重構階段:提升代碼品質

  • 現在有測試保護,可以安心重構
  • 每次重構後都要跑測試,確保沒壞
  • 重構不改變功能,只改善結構

TDD 的三大好處

在前三個專案的實踐中,我深刻體會到 TDD 的好處:
1. 早期發現問題

  • 寫測試時就會發現 API 設計不合理
  • 比等功能寫完才發現要大改好很多

2. 有信心重構

  • 每次重構後跑測試,立刻知道有沒有壞
  • 不會有「不敢動的紀念碑 code」

3. 活文件

  • 測試就是最好的文件
  • 想知道怎麼用?看測試
  • 想知道功能是什麼?看測試

步驟 1:建立 Next.js 14 專案

首先,建立一個全新的 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

為什麼這樣分類?

  • components/:UI 元件,按功能分類
  • services/:業務邏輯,與 UI 分離
  • stores/:狀態管理,集中管理
  • tests/:測試與原始檔獨立,但結構對應

步驟 2:設定測試環境

Next.js 14 預設沒有測試設定,我們要手動配置 Vitest。

為什麼選 Vitest?

  • :比 Jest 快很多
  • 原生 ESM:不需要轉換
  • 與 Vite 整合:Next.js 14 可以用 Turbopack,但 Vitest 也很快
  • Jest 相容:API 幾乎一樣,學習成本低

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()
})

更新 package.json

{
  \"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)

完美!測試環境就緒了。

步驟 3:建立 Design System 基礎

根據 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

更新 Tailwind 設定

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

基礎元件:Button

根據 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 }

步驟 4:第一個 TDD 循環

現在環境都準備好了,開始第一個 TDD 循環!
根據 Day 3 的 User Story #1,我們要實作 Spec Input 頁面的第一個 AC。

回顧 AC 1.1:基本輸入介面

Given 我在首頁
When 我點擊「開始新專案」
Then 系統顯示一個大型文字輸入框
And 有提示文字:「用一段話描述你想做的產品」
And 有範例可以參考

🔴 Red:寫失敗的測試

創建 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'

完美!這正是我們要的 紅燈

🟢 Green:寫最少的 Code

現在寫最少的 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)

太好了!綠燈

Refactor:優化 Code

現在測試通過了,可以優化 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

現在我們已經完成了 AC 1.1,繼續 AC 1.2。

AC 1.2:輸入驗證

Given 我在輸入框中
When 我輸入少於 20 字的描述
Then 系統顯示警告:「請至少描述 20 字以上」
And 提交按鈕保持禁用狀態

🔴 Red:增加測試

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 紅綠燈循環

更重要的是,我們現在有:

  • ✅ 一個可運行的專案
  • ✅ 一個可用的測試環境
  • ✅ 一個完整的 Design System
  • ✅ 一個有測試保護的元件
  • ✅ 一個清晰的開發流程

全部都是 SDD AI Sprint 前面已經有規格的情況下產出程式碼,效率非常非常高~


上一篇
Day 28 - BoltHQ Day4 - UI/UX Design:讓 AI 設計師畫出產品的靈魂
下一篇
Day 30 - 系列完結:四個 Side Project,一個全新的開發世界
系列文
AI-Driven Development 實戰篇:30 天 Side Project 開發全紀錄30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言