昨天我們學會了參數化測試,用優雅的方式處理大量測試資料。今天要解決一個新挑戰:「如何測試依賴外部服務的函數?」
想像一個場景:你的應用需要呼叫 API、寄送 email 或讀取資料庫。在測試時,你不希望真的去呼叫這些外部服務。今天我們要學習「測試替身」,了解如何用假物件替換真實依賴。
今天結束後,你將學會:
vi.fn()
和 vi.spyOn()
用法第一階段:打好基礎(Day 1-10)
├── Day 01 - 環境設置與第一個測試
├── Day 02 - 認識斷言(Assertions)
├── Day 03 - TDD 紅綠重構循環
├── Day 04 - 測試結構與組織
├── Day 05 - 測試生命週期
├── Day 06 - 參數化測試
├── Day 07 - 測試替身基礎 ★ 今天在這裡
├── ...
└── (更多精彩內容待續)
測試替身(Test Double)是在測試中用來替代真實依賴的假物件。主要有兩種:
// 問題:直接依賴外部服務
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()
};
}
}
測試時會遇到的問題:
Vitest 提供了強大的 Mock 功能:
// 建立 Mock
const mockFetch = vi.fn()
mockFetch.mockReturnValue({ id: 1, name: 'John' })
建立 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');
})
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)
})
// ✅ 好的做法:清楚表達測試意圖
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');
})
只 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');
})
})
今天我們深入學習了測試替身的重要概念:
測試替身是 TDD 中的重要工具,讓我們能夠:
記住:好的測試替身讓測試更快、更穩定、更專注。
明天我們將學習「例外處理測試」,了解如何驗證程式在異常情況下的行為。