iT邦幫忙

2025 iThome 鐵人賽

DAY 13
0
Vue.js

打造銷售系統30天修練 - 全集中・Vue之呼吸系列 第 13

Day 13:[Componentの呼吸・陸之型] 測試Card - 驗證卡片元件功能

  • 分享至 

  • xImage
  •  

在 Day 12,我們打造了功能完整的 BaseCardActionCard 元件。
這次我們一樣要來為這個新的元件來撰寫測試,這邊我們先專注在 BaseCard 元件的測試,ActionCard 元件可以再自行延伸。

想像一下,未來我們為了某個新需求,修改了 BaseCard 元件的程式碼。

  • 它會不會不小心讓條件渲染失效,總是顯示 header?
  • 它會不會改壞了 slot 的內容傳遞?
  • 它會不會讓 ActionCard 的點擊事件無法正確觸發?
  • 它會不會破壞了 variant 樣式的應用邏輯?

Card 元件的測試重點

相較於 BaseButtonBaseSelectBaseCard 元件的測試有幾個特別需要注意的地方:

  1. Slot 內容渲染 - 作為容器元件,slot 是核心功能
  2. 條件渲染邏輯 - header 區域的顯示條件
  3. Props 組合 - 標題與副標題的各種組合
  4. DOM 結構 - 確保正確的 HTML 結構和 CSS 類別

撰寫 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 內容

作為容器元件,正確處理 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)
})

測試 DOM 結構和 CSS 類別

確保元件產生正確的 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 完成了全面的測試覆蓋。相較於 BaseButtonBaseSelectBaseCard 元件的測試有其獨特之處:

  1. Slot 是核心功能:作為容器元件,必須確保各種內容都能正確傳遞。
  2. 條件渲染邏輯重要:header 的顯示與否直接影響元件結構。
  3. DOM 結構驗證:需要確保正確的 HTML 階層和 CSS 類別。
  4. 邊界情況更多樣:空內容、特殊字元、超長文字都需要處理。

有了這些測試,每當需要修改或擴充功能時,只要測試依然通過,我們就能確信沒有破壞既有功能。

明天,Day 14:[Componentの呼吸・柒之型] Form組合 - 基礎登入表單實作。心を燃やせ 🔥!


上一篇
Day 12:[Componentの呼吸・伍之型] Card - 卡片元件設計與實作
下一篇
Day 14:[Componentの呼吸・柒之型] Form組合 - 基礎登入表單實作
系列文
打造銷售系統30天修練 - 全集中・Vue之呼吸15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言