iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0
Modern Web

React TDD 實戰:用 Vitest 打造可靠的前端應用系列 第 7

Day 07 - 測試替身基礎 🎭

  • 分享至 

  • xImage
  •  

今天要做什麼?

昨天我們學會了參數化測試,用優雅的方式處理大量測試資料。今天要解決一個新挑戰:「如何測試依賴外部服務的函數?」

想像一個場景:你的應用需要呼叫 API、寄送 email 或讀取資料庫。在測試時,你不希望真的去呼叫這些外部服務。今天我們要學習「測試替身」,了解如何用假物件替換真實依賴。

學習目標

今天結束後,你將學會:

  • 理解測試替身的概念與種類
  • 掌握 Vitest 的 vi.fn()vi.spyOn() 用法
  • 學會 Mock 和 Stub 的使用場景
  • 掌握測試替身的最佳實踐

TDD 學習地圖

第一階段:打好基礎(Day 1-10)
├── Day 01 - 環境設置與第一個測試
├── Day 02 - 認識斷言(Assertions)
├── Day 03 - TDD 紅綠重構循環
├── Day 04 - 測試結構與組織
├── Day 05 - 測試生命週期
├── Day 06 - 參數化測試
├── Day 07 - 測試替身基礎 ★ 今天在這裡
├── ...
└── (更多精彩內容待續)

什麼是測試替身? 🎪

測試替身(Test Double)是在測試中用來替代真實依賴的假物件。主要有兩種:

  1. Stub:回傳預設定好回應的物件
  2. Mock:有預期行為的物件,會驗證是否被正確呼叫

為什麼需要測試替身?

// 問題:直接依賴外部服務
class UserService {
  async getUserProfile(userId) {
    const response = await fetch(`/api/users/${userId}`); // 真實 API 呼叫
    const user = await response.json();
    
    return {
      id: user.id,
      name: user.name,
      displayName: user.name.toUpperCase()
    };
  }
}

測試時會遇到的問題:

  • 需要真實的 API server
  • 測試速度慢
  • 難以測試錯誤情況

基本語法與用法 🔧

Mock 的基本用法

Vitest 提供了強大的 Mock 功能:

// 建立 Mock
const mockFetch = vi.fn()
mockFetch.mockReturnValue({ id: 1, name: 'John' })

實戰演練:Mock 函數

建立 src/services/user_service.js

export class UserService {
  constructor(httpClient) {
    this.httpClient = httpClient;
  }
  
  async getUserProfile(userId) {
    const response = await this.httpClient.get(`/api/users/${userId}`);
    const user = response.data;
    
    return {
      id: user.id,
      name: user.name,
      displayName: user.name.toUpperCase()
    };
  }
  
  async createUser(userData) {
    const response = await this.httpClient.post('/api/users', userData);
    return response.data;
  }
}

建立 tests/day07/test_user_service.js

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { UserService } from '../../src/services/user_service.js'

describe('UserService', () => {
  let userService;
  let mockHttpClient;

  beforeEach(() => {
    mockHttpClient = {
      get: vi.fn(),
      post: vi.fn()
    };
    userService = new UserService(mockHttpClient);
  })

  it('gets user profile', async () => {
    mockHttpClient.get.mockResolvedValue({
      data: {
        id: 1,
        name: 'John Doe',
        email: 'john@example.com'
      }
    });
    
    const result = await userService.getUserProfile(1);
    
    expect(result).toEqual({
      id: 1,
      name: 'John Doe',
      displayName: 'JOHN DOE'
    });
    
    expect(mockHttpClient.get).toHaveBeenCalledWith('/api/users/1');
  })

  it('creates user', async () => {
    mockHttpClient.post.mockResolvedValue({
      data: { id: 2, name: 'Jane' }
    });
    
    const userData = { name: 'Jane', email: 'jane@example.com' };
    const result = await userService.createUser(userData);
    
    expect(result).toEqual({ id: 2, name: 'Jane' });
    
    expect(mockHttpClient.post).toHaveBeenCalledWith('/api/users', userData);
  })
})

進階使用 🚀

模擬不同回應狀況

it('handles api error', async () => {
  mockHttpClient.get.mockRejectedValue(new Error('API Error'));
  
  await expect(userService.getUserProfile(999)).rejects.toThrow('API Error');
})

使用 Spy 監控方法

const mathService = {
  add(a, b) { return a + b },
  calculate(operation, a, b) {
    if (operation === 'add') return this.add(a, b)
    throw new Error('Unsupported')
  }
}

it('spies on method calls', () => {
  const addSpy = vi.spyOn(mathService, 'add')
  const result = mathService.calculate('add', 2, 3)
  
  expect(result).toBe(5)
  expect(addSpy).toHaveBeenCalledWith(2, 3)
})

最佳實踐 💡

1. 明確的測試意圖

// ✅ 好的做法:清楚表達測試意圖
it('formats user name to uppercase', async () => {
  mockHttpClient.get.mockResolvedValue({
    data: { id: 1, name: 'john', email: 'john@example.com' }
  });
  
  const result = await userService.getUserProfile(1);
  
  expect(result.displayName).toBe('JOHN');
})

2. 最小化 Mock 範圍

只 Mock 必要的部分,不要過度 Mock。

完整實作 📝

更新 tests/day07/test_user_service.js

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { UserService } from '../../src/services/user_service.js'

describe('UserService with Test Doubles', () => {
  let userService;
  let mockHttpClient;

  beforeEach(() => {
    mockHttpClient = {
      get: vi.fn(),
      post: vi.fn()
    };
    userService = new UserService(mockHttpClient);
  })

  describe('getUserProfile', () => {
    it('returns formatted user data', async () => {
      mockHttpClient.get.mockResolvedValue({
        data: {
          id: 1,
          name: 'John Doe',
          email: 'john@example.com'
        }
      });
      
      const result = await userService.getUserProfile(1);
      
      expect(result).toHaveProperty('id');
      expect(result).toHaveProperty('name');
      expect(result).toHaveProperty('displayName');
      expect(result.displayName).toBe('JOHN DOE');
      
      expect(mockHttpClient.get).toHaveBeenCalledWith('/api/users/1');
    })

    it('handles different user names correctly', async () => {
      mockHttpClient.get.mockResolvedValue({
        data: { id: 2, name: 'alice smith', email: 'alice@example.com' }
      });
      
      const result = await userService.getUserProfile(2);
      
      expect(result.displayName).toBe('ALICE SMITH');
    })
  })

  describe('createUser', () => {
    it('sends correct data to api', async () => {
      mockHttpClient.post.mockResolvedValue({
        data: { id: 3, success: true }
      });
      
      const userData = { name: 'New User', email: 'new@example.com' };
      const result = await userService.createUser(userData);
      
      expect(result).toHaveProperty('success', true);
      expect(mockHttpClient.post).toHaveBeenCalledWith('/api/users', userData);
    })
  })

  it('handles api errors', async () => {
    mockHttpClient.get.mockRejectedValue(new Error('Network error'));
    
    await expect(userService.getUserProfile(999)).rejects.toThrow('Network error');
  })
})

今天學到什麼? 📚

今天我們深入學習了測試替身的重要概念:

核心概念

  • 測試替身:用假物件替代真實依賴
  • Mock vs Stub:Mock 驗證行為,Stub 提供回應
  • 隔離測試:讓測試專注於被測試的程式碼

實用技術

  • vi.fn():建立 Mock 函數
  • mockResolvedValue():模擬非同步回應
  • toHaveBeenCalledWith():驗證函數呼叫

最佳實踐

  • 最小化 Mock:只 Mock 必要的依賴
  • 驗證重要互動:確保外部服務被正確呼叫
  • 清晰的測試意圖:讓測試表達明確的業務邏輯

總結 🎊

測試替身是 TDD 中的重要工具,讓我們能夠:

  • 隔離測試對象:專注測試目標程式碼
  • 控制外部依賴:模擬各種情況和回應
  • 提高測試速度:避免真實的外部服務呼叫
  • 增強測試可靠性:不受外部服務影響

記住:好的測試替身讓測試更快、更穩定、更專注。

明天我們將學習「例外處理測試」,了解如何驗證程式在異常情況下的行為。


上一篇
Day 06 - 參數化測試 🔢
下一篇
Day 08 - 例外處理測試 ⚠️
系列文
React TDD 實戰:用 Vitest 打造可靠的前端應用9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言