在 Day 12,我們打造了功能完整的 BaseCard
和 ActionCard
元件。
這次我們一樣要來為這個新的元件來撰寫測試,這邊我們先專注在 BaseCard
元件的測試,ActionCard
元件可以再自行延伸。
想像一下,未來我們為了某個新需求,修改了 BaseCard
元件的程式碼。
相較於 BaseButton
和 BaseSelect
,BaseCard
元件的測試有幾個特別需要注意的地方:
首先建立 src/components/BaseCard.spec.js
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/vue'
import BaseCard from '../BaseCard.vue'
describe('BaseCard', () => {
// Case 1: 基本渲染測試 - 同時有標題和副標題
it('should render title and subtitle correctly', () => {
// 1. 渲染元件,傳入必要的 props
render(BaseCard, {
props: {
title: '訂單管理',
subtitle: '管理所有訂單狀態'
}
})
// 2. 標題應該要被渲染出來
expect(screen.getByText('訂單管理')).toBeTruthy()
// 3. 副標題應該要被渲染出來
expect(screen.getByText('管理所有訂單狀態')).toBeTruthy()
// 4. header 區域應該存在
const header = document.querySelector('.pos-card-header')
expect(header).toBeTruthy()
// 5. 確認標題是 h3 元素
const titleElement = document.querySelector('.pos-card-title')
expect(titleElement.tagName).toBe('H3')
// 6. 確認副標題是 p 元素
const subtitleElement = document.querySelector('.pos-card-subtitle')
expect(subtitleElement.tagName).toBe('P')
})
確保我們的 Card 能正確渲染 title 和 subtitle。
Card 最重要的邏輯之一是條件渲染,需要根據 props 決定是否顯示 header:
// ... 接續上方程式碼
// Case 2: 沒有標題時不應該渲染 header
it('should not render header when no title provided', () => {
// 1. 渲染元件,不傳入 title
render(BaseCard, {
props: {
// 故意不傳 title 和 subtitle
}
})
// 2. header 區域不應該存在
const header = document.querySelector('.pos-card-header')
expect(header).toBeFalsy()
// 3. 但主容器和 content 區域應該要存在
const card = document.querySelector('.pos-card')
const content = document.querySelector('.pos-card-content')
expect(card).toBeTruthy()
expect(content).toBeTruthy()
})
// Case 3: 只有標題沒有副標題的情況
it('should render title without subtitle', () => {
render(BaseCard, {
props: {
title: '只有標題',
// 沒有 subtitle
}
})
// 標題應該存在
expect(screen.getByText('只有標題')).toBeTruthy()
// header 應該存在(因為有標題)
const header = document.querySelector('.pos-card-header')
expect(header).toBeTruthy()
// 但副標題元素不應該存在
const subtitle = document.querySelector('.pos-card-subtitle')
expect(subtitle).toBeFalsy()
})
// Case 4: 空字串標題應該視為沒有標題
it('should treat empty string title as no title', () => {
render(BaseCard, {
props: {
title: '',
subtitle: '有副標題'
}
})
// header 不應該被渲染(因為 title 是空字串)
const header = document.querySelector('.pos-card-header')
expect(header).toBeFalsy()
})
作為容器元件,正確處理 slot 內容至關重要:
// ... 接續上方程式碼
// Case 5: 基本 Slot 內容測試
it('should render slot content correctly', () => {
render(BaseCard, {
props: {
title: '測試卡片'
},
slots: {
default: '<div class="test-content">測試內容</div>'
}
})
// slot 內容應該被渲染
expect(screen.getByText('測試內容')).toBeTruthy()
// 確認內容在正確的容器內
const content = document.querySelector('.pos-card-content')
expect(content).toBeTruthy()
// 確認自定義的 class 也有被保留
const testContent = content.querySelector('.test-content')
expect(testContent).toBeTruthy()
})
// Case 6: 複雜 Slot 內容測試(列表)
it('should handle complex slot content like lists', () => {
render(BaseCard, {
slots: {
default: `
<ul>
<li>項目 1</li>
<li>項目 2</li>
<li>項目 3</li>
</ul>
`
}
})
// 所有列表項目都應該被渲染
expect(screen.getByText('項目 1')).toBeTruthy()
expect(screen.getByText('項目 2')).toBeTruthy()
expect(screen.getByText('項目 3')).toBeTruthy()
// 確認 ul 元素存在且有三個子元素
const list = document.querySelector('.pos-card-content ul')
expect(list).toBeTruthy()
expect(list.children.length).toBe(3)
})
// Case 7: 互動式 Slot 內容測試
it('should preserve slot content functionality', async () => {
let clickCount = 0
render(BaseCard, {
slots: {
default: {
template: '<button @click="handleClick">點擊次數: {{ count }}</button>',
data() {
return { count: 0 }
},
methods: {
handleClick() {
this.count++
clickCount = this.count
}
}
}
}
})
// 按鈕應該被渲染
const button = screen.getByRole('button')
expect(button).toBeTruthy()
expect(button.textContent).toBe('點擊次數: 0')
// slot 內的互動功能應該正常運作
await button.click()
expect(button.textContent).toBe('點擊次數: 1')
expect(clickCount).toBe(1)
})
確保元件產生正確的 HTML 結構:
// ... 接續上方程式碼
// Case 8: CSS 類別結構測試
it('should have correct CSS class structure', () => {
render(BaseCard, {
props: {
title: '測試標題',
subtitle: '測試副標題'
},
slots: {
default: '<p>內容</p>'
}
})
// 檢查主要容器
const card = document.querySelector('.pos-card')
expect(card).toBeTruthy()
// 檢查 header 結構
const header = card.querySelector('.pos-card-header')
expect(header).toBeTruthy()
// 檢查 header 內的元素
const title = header.querySelector('.pos-card-title')
const subtitle = header.querySelector('.pos-card-subtitle')
expect(title).toBeTruthy()
expect(subtitle).toBeTruthy()
// 檢查 content 區域
const content = card.querySelector('.pos-card-content')
expect(content).toBeTruthy()
// 確認階層關係正確
expect(header.parentElement).toBe(card)
expect(content.parentElement).toBe(card)
})
// Case 9: 同時有標題和 Slot 的完整測試
it('should render both header and slot content together', () => {
render(BaseCard, {
props: {
title: '使用者列表',
subtitle: '系統中的所有使用者'
},
slots: {
default: `
<table>
<tr>
<td>使用者1</td>
<td>user1@example.com</td>
</tr>
<tr>
<td>使用者2</td>
<td>user2@example.com</td>
</tr>
</table>
`
}
})
// header 內容應該存在
expect(screen.getByText('使用者列表')).toBeTruthy()
expect(screen.getByText('系統中的所有使用者')).toBeTruthy()
// slot 內容應該存在
expect(screen.getByText('使用者1')).toBeTruthy()
expect(screen.getByText('user1@example.com')).toBeTruthy()
// 確認 table 在 content 區域內
const table = document.querySelector('.pos-card-content table')
expect(table).toBeTruthy()
expect(table.querySelectorAll('tr').length).toBe(2)
})
處理各種特殊輸入:
// ... 接續上方程式碼
// Case 10: 特殊字元處理測試
it('should handle special characters in props', () => {
render(BaseCard, {
props: {
title: '<script>alert("XSS")</script>',
subtitle: '& < > " \' 特殊字元測試'
}
})
// Vue 會自動轉義,防止 XSS
// 文字內容應該是純文字,不是 HTML
const title = screen.getByText('<script>alert("XSS")</script>')
expect(title).toBeTruthy()
expect(title.innerHTML).not.toContain('<script>')
const subtitle = screen.getByText('& < > " \' 特殊字元測試')
expect(subtitle).toBeTruthy()
})
// Case 11: 超長內容測試
it('should handle very long content', () => {
const longTitle = '這是一個非常長的標題'.repeat(10)
const longSubtitle = '這是一個非常長的副標題'.repeat(10)
render(BaseCard, {
props: {
title: longTitle,
subtitle: longSubtitle
}
})
// 即使內容很長,元件應該正常渲染
expect(screen.getByText(longTitle)).toBeTruthy()
expect(screen.getByText(longSubtitle)).toBeTruthy()
})
// Case 12: 沒有任何內容的測試
it('should render empty card without crash', () => {
const { container } = render(BaseCard)
// 即使沒有任何 props 和 slot,元件應該正常渲染
const card = container.querySelector('.pos-card')
expect(card).toBeTruthy()
// 只有 content 區域,沒有 header
const header = container.querySelector('.pos-card-header')
const content = container.querySelector('.pos-card-content')
expect(header).toBeFalsy()
expect(content).toBeTruthy()
})
// Case 13: Props 更新測試
it('should react to props changes', async () => {
const { rerender } = render(BaseCard, {
props: {
title: '初始標題'
}
})
// 初始狀態
expect(screen.getByText('初始標題')).toBeTruthy()
expect(document.querySelector('.pos-card-subtitle')).toBeFalsy()
// 更新 props
await rerender({
title: '更新後的標題',
subtitle: '新增的副標題'
})
// 應該顯示新的內容
expect(screen.getByText('更新後的標題')).toBeTruthy()
expect(screen.getByText('新增的副標題')).toBeTruthy()
// 移除標題
await rerender({
title: '',
subtitle: ''
})
// header 應該消失
expect(document.querySelector('.pos-card-header')).toBeFalsy()
})
})
npm run test
今天,我們為 BaseCard
完成了全面的測試覆蓋。相較於 BaseButton
和 BaseSelect
,BaseCard
元件的測試有其獨特之處:
有了這些測試,每當需要修改或擴充功能時,只要測試依然通過,我們就能確信沒有破壞既有功能。
明天,Day 14:[Componentの呼吸・柒之型] Form組合 - 基礎登入表單實作。心を燃やせ 🔥!