☕ NEW! 完成新手任務即可參加抽獎!LINE 星巴克禮券等你拿,名額有限!        🎉 推廣活動:邀請好友註冊 DevLearn,累積推薦抽 LINE 星巴克禮券! 活動詳情 →        🔥 活動期間 2026/4/1 - 5/31 |已有 0 人參加       
vue 進階

🧪 Vue 測試與部署:從開發到上線

📌 為什麼要寫測試?

沒有測試的程式碼 = 每次改功能都在走鋼索 🎪
有測試的程式碼 = 有安全網保護你 🛡️

測試金字塔:
  🔺 E2E 測試(少量)     → 模擬真實使用者操作
  🔹 元件測試(適量)     → 測試元件行為
  🟩 單元測試(大量)     → 測試個別函式/邏輯

📌 Vitest 單元測試

Vitest 是 Vite 生態系的測試框架,速度快、設定簡單。

安裝

npm install -D vitest
// vite.config.js 加入測試設定
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    globals: true,
    environment: 'jsdom' // 模擬瀏覽器環境
  }
})

測試純函式

// utils/calculator.js
export function add(a, b) { return a + b }
export function multiply(a, b) { return a * b }
export function discount(price, rate) {
  if (rate < 0 || rate > 1) throw new Error('折扣率需在 0-1 之間')
  return Math.round(price * (1 - rate))
}

// utils/calculator.test.js
import { describe, it, expect } from 'vitest'
import { add, multiply, discount } from './calculator'

describe('Calculator', () => {
  it('加法正確', () => {
    expect(add(1, 2)).toBe(3)
    expect(add(-1, 1)).toBe(0)
  })

  it('乘法正確', () => {
    expect(multiply(3, 4)).toBe(12)
  })

  it('折扣計算', () => {
    expect(discount(1000, 0.2)).toBe(800)  // 打八折
    expect(discount(1000, 0.15)).toBe(850) // 85折
  })

  it('折扣率超出範圍應拋錯', () => {
    expect(() => discount(1000, -0.1)).toThrow('折扣率需在 0-1 之間')
    expect(() => discount(1000, 1.5)).toThrow()
  })
})

測試 Composable

// composables/useCounter.test.js
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('初始值為 0', () => {
    const { count } = useCounter()
    expect(count.value).toBe(0)
  })

  it('可以自訂初始值', () => {
    const { count } = useCounter(10)
    expect(count.value).toBe(10)
  })

  it('increment 增加 1', () => {
    const { count, increment } = useCounter()
    increment()
    expect(count.value).toBe(1)
  })

  it('decrement 減少 1', () => {
    const { count, decrement } = useCounter(5)
    decrement()
    expect(count.value).toBe(4)
  })
})

📌 Vue Test Utils 元件測試

npm install -D @vue/test-utils

測試元件

// components/TodoList.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import TodoList from './TodoList.vue'

describe('TodoList', () => {
  it('渲染正確', () => {
    const wrapper = mount(TodoList)
    expect(wrapper.find('h2').text()).toContain('待辦清單')
  })

  it('可以新增待辦事項', async () => {
    const wrapper = mount(TodoList)

    // 找到 input 並輸入文字
    const input = wrapper.find('input')
    await input.setValue('學習 Vue 測試')

    // 找到按鈕並點擊
    const button = wrapper.find('button[type="submit"]')
    await button.trigger('click')

    // 檢查列表是否新增了項目
    const items = wrapper.findAll('li')
    expect(items.length).toBe(1)
    expect(items[0].text()).toContain('學習 Vue 測試')
  })

  it('可以刪除待辦事項', async () => {
    const wrapper = mount(TodoList)

    // 先新增一個項目
    await wrapper.find('input').setValue('要刪除的項目')
    await wrapper.find('button[type="submit"]').trigger('click')

    // 確認有一個項目
    expect(wrapper.findAll('li').length).toBe(1)

    // 點擊刪除按鈕
    await wrapper.find('.delete-btn').trigger('click')

    // 確認已刪除
    expect(wrapper.findAll('li').length).toBe(0)
  })

  it('空白輸入不應新增', async () => {
    const wrapper = mount(TodoList)
    await wrapper.find('input').setValue('   ')
    await wrapper.find('button[type="submit"]').trigger('click')
    expect(wrapper.findAll('li').length).toBe(0)
  })
})

測試 Props 和 Emit

// components/UserCard.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import UserCard from './UserCard.vue'

describe('UserCard', () => {
  it('正確顯示 props 資料', () => {
    const wrapper = mount(UserCard, {
      props: {
        name: '小明',
        age: 25,
        level: 'intermediate'
      }
    })
    expect(wrapper.text()).toContain('小明')
    expect(wrapper.text()).toContain('25')
  })

  it('點擊會觸發 emit 事件', async () => {
    const wrapper = mount(UserCard, {
      props: { name: '小明', age: 25 }
    })
    await wrapper.find('.edit-btn').trigger('click')

    // 檢查是否 emit 了 'edit' 事件
    expect(wrapper.emitted()).toHaveProperty('edit')
    expect(wrapper.emitted('edit')[0]).toEqual([{ name: '小明', age: 25 }])
  })
})

📌 E2E 測試 (Cypress / Playwright)

Playwright 範例

npm install -D @playwright/test
// e2e/todo.spec.js
import { test, expect } from '@playwright/test'

test.describe('待辦清單 App', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('http://localhost:5173')
  })

  test('可以新增和完成待辦事項', async ({ page }) => {
    // 輸入待辦事項
    await page.fill('input[placeholder="輸入待辦事項..."]', '買牛奶')
    await page.click('button:text("新增")')

    // 確認項目已新增
    await expect(page.locator('li')).toContainText('買牛奶')

    // 勾選完成
    await page.click('input[type="checkbox"]')
    await expect(page.locator('li')).toHaveClass(/done/)
  })

  test('空白不能新增', async ({ page }) => {
    await page.fill('input', '   ')
    await page.click('button:text("新增")')
    await expect(page.locator('li')).toHaveCount(0)
  })
})

📌 Vite 打包與環境變數

環境變數

# .env(所有環境)
VITE_APP_TITLE=我的 Vue App

# .env.development(開發環境)
VITE_API_BASE_URL=http://localhost:5000/api

# .env.production(正式環境)
VITE_API_BASE_URL=https://api.example.com
// 在程式碼中使用(要加 VITE_ 前綴才能在前端存取)
console.log(import.meta.env.VITE_APP_TITLE)
console.log(import.meta.env.VITE_API_BASE_URL)
console.log(import.meta.env.MODE) // 'development' 或 'production'

打包指令

# 開發伺服器
npm run dev

# 正式打包
npm run build

# 預覽打包結果
npm run preview

📌 部署方案

Vercel 部署

# 安裝 Vercel CLI
npm install -g vercel

# 部署
vercel

# 或設定 vercel.json
{
  "buildCommand": "npm run build",
  "outputDirectory": "dist",
  "rewrites": [
    { "source": "/(.*)", "destination": "/index.html" }
  ]
}

Docker 部署

# Dockerfile
# 建置階段
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# 正式階段
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# nginx.conf — SPA 路由設定
server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;

    # SPA:所有路徑都導到 index.html
    location / {
        try_files $uri $uri/ /index.html;
    }

    # 靜態資源快取
    location ~* \.(js|css|png|jpg|svg|ico)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

📌 CI/CD 流程

# .github/workflows/deploy.yml
name: Deploy Vue App

on:
  push:
    branches: [main]

jobs:
  test-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Run unit tests
        run: npm run test:unit

      - name: Run E2E tests
        run: npx playwright install && npm run test:e2e

      - name: Build
        run: npm run build

      - name: Deploy to Vercel
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          vercel-args: '--prod'

💡 測試與部署小提醒

  • 測試覆蓋率不必 100%,但關鍵邏輯一定要測
  • E2E 測試不要太多(慢),單元測試多寫(快)
  • 環境變數前綴 VITE_ 才能在前端用
  • SPA 部署要設定「所有路徑導到 index.html」
  • Docker 部署用 multi-stage build 減小映像檔大小

💡 大家的想法 · 0

載入中...
💬 即時聊天室 🟢 0 人在線
😀 😎 🤓 💻 🎮 🎸 🔥
➕ 新問題
📋 我的工單
💬 LINE 社群
🔒
需要註冊才能使用此功能
註冊帳號即可解鎖測驗、遊戲、簽到、筆記下載等所有功能,完全免費!
免費註冊