🧪 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 減小映像檔大小