iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0

今天要做什麼?

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

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

學習目標

今天結束後,你將學會:

  • 理解測試替身的概念與種類
  • 掌握 pytest 的 mockpatch 用法
  • 學會 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:有預期行為的物件,會驗證是否被正確呼叫

為什麼需要測試替身?

# 問題:直接依賴外部服務
import requests

class UserService:
    def get_user_profile(self, user_id: int) -> dict:
        response = requests.get(f"/api/users/{user_id}")  # 真實 API 呼叫
        user = response.json()
        
        return {
            'id': user['id'],
            'name': user['name'],
            'display_name': user['name'].upper()
        }

測試時會遇到的問題:

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

基本語法與用法 🔧

Mock 的基本用法

Python 提供了強大的 unittest.mock 模組:

from unittest.mock import patch, Mock

# 建立 Mock
@patch('requests.get')
def test_with_mock(mock_get):
    mock_get.return_value.json.return_value = {
        'id': 1, 'name': 'John Doe', 'email': 'john@example.com'
    }

實戰演練:Mock HTTP 請求

建立 src/services/user_service.py

import requests
from typing import Dict, Any

class UserService:
    def get_user_profile(self, user_id: int) -> Dict[str, Any]:
        response = requests.get(f"/api/users/{user_id}")
        user = response.json()
        
        return {
            'id': user['id'],
            'name': user['name'],
            'display_name': user['name'].upper()
        }
    
    def create_user(self, user_data: Dict[str, Any]) -> Dict[str, Any]:
        response = requests.post('/api/users', json=user_data)
        return response.json()

建立 tests/day07/test_user_service.py

import pytest
from unittest.mock import patch, Mock
from src.services.user_service import UserService

class TestUserService:
    def setup_method(self):
        self.user_service = UserService()

    @patch('src.services.user_service.requests.get')
    def test_get_user_profile(self, mock_get):
        # 設置 mock 回應
        mock_response = Mock()
        mock_response.json.return_value = {
            'id': 1,
            'name': 'John Doe',
            'email': 'john@example.com'
        }
        mock_get.return_value = mock_response
        
        result = self.user_service.get_user_profile(1)
        
        assert result == {
            'id': 1,
            'name': 'John Doe',
            'display_name': 'JOHN DOE'
        }
        
        # 驗證 API 被正確呼叫
        mock_get.assert_called_once_with('/api/users/1')

    @patch('src.services.user_service.requests.post')
    def test_create_user(self, mock_post):
        mock_response = Mock()
        mock_response.json.return_value = {'id': 2, 'name': 'Jane'}
        mock_post.return_value = mock_response
        
        user_data = {'name': 'Jane', 'email': 'jane@example.com'}
        result = self.user_service.create_user(user_data)
        
        assert result == {'id': 2, 'name': 'Jane'}
        
        mock_post.assert_called_once_with('/api/users', json=user_data)

進階使用 🚀

模擬不同回應狀況

@patch('src.services.user_service.requests.get')
def test_handles_api_error(self, mock_get):
    mock_get.side_effect = requests.exceptions.RequestException("API Error")
    
    with pytest.raises(requests.exceptions.RequestException):
        self.user_service.get_user_profile(999)

使用 pytest fixtures

@pytest.fixture
def mock_requests():
    with patch('src.services.user_service.requests') as mock:
        yield mock

def test_with_fixture(mock_requests):
    mock_requests.get.return_value.json.return_value = {'id': 1, 'name': 'John'}
    
    service = UserService()
    result = service.get_user_profile(1)
    
    assert result['display_name'] == 'JOHN'

最佳實踐 💡

1. 明確的測試意圖 🎯

@patch('src.services.user_service.requests.get')
def test_formats_user_name_to_uppercase(self, mock_get):
    mock_get.return_value.json.return_value = {'id': 1, 'name': 'john'}
    
    result = self.user_service.get_user_profile(1)
    
    assert result['display_name'] == 'JOHN'

2. 最小化 Mock 範圍 🔍

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

# ❌ 不好的做法:Mock 太多東西
@patch('src.services.user_service.UserService')
def test_over_mocking(self, mock_service):
    # 連被測試的類別都 Mock 了
    pass

# ✅ 好的做法:只 Mock 外部依賴
@patch('src.services.user_service.requests.get')
def test_minimal_mocking(self, mock_get):
    # 只 Mock 外部 HTTP 請求
    service = UserService()  # 真實的服務物件
    # ...

完整實作 📦

更新 tests/day07/test_user_service.py

import pytest
from unittest.mock import patch, Mock
import requests
from src.services.user_service import UserService

class TestUserServiceWithDoubles:
    def setup_method(self):
        self.user_service = UserService()

    @patch('src.services.user_service.requests.get')
    def test_returns_formatted_user_data(self, mock_get):
        mock_response = Mock()
        mock_response.json.return_value = {
            'id': 1,
            'name': 'John Doe',
            'email': 'john@example.com'
        }
        mock_get.return_value = mock_response
        
        result = self.user_service.get_user_profile(1)
        
        assert 'id' in result
        assert 'name' in result
        assert 'display_name' in result
        assert result['display_name'] == 'JOHN DOE'
        
        mock_get.assert_called_once_with('/api/users/1')

    @patch('src.services.user_service.requests.get')
    def test_handles_different_user_names(self, mock_get):
        mock_response = Mock()
        mock_response.json.return_value = {
            'id': 2, 'name': 'alice smith', 'email': 'alice@example.com'
        }
        mock_get.return_value = mock_response
        
        result = self.user_service.get_user_profile(2)
        
        assert result['display_name'] == 'ALICE SMITH'

    @patch('src.services.user_service.requests.post')
    def test_sends_correct_data_to_api(self, mock_post):
        mock_response = Mock()
        mock_response.json.return_value = {'id': 3, 'success': True}
        mock_post.return_value = mock_response
        
        user_data = {'name': 'New User', 'email': 'new@example.com'}
        result = self.user_service.create_user(user_data)
        
        assert 'success' in result
        assert result['success'] is True
        
        mock_post.assert_called_once_with('/api/users', json=user_data)

    @patch('src.services.user_service.requests.get')
    def test_handles_api_errors(self, mock_get):
        mock_get.side_effect = requests.exceptions.RequestException("Network error")
        
        with pytest.raises(requests.exceptions.RequestException):
            self.user_service.get_user_profile(999)

今天學到什麼?

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

核心概念

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

實用技術

  • @patch:Python 的裝飾器用於 Mock
  • Mock 物件:建立假的回應和行為
  • assert_called_with:驗證函數被正確呼叫

最佳實踐

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

總結 🎊

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

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

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

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


上一篇
Day 06 - 參數化測試 🔢
下一篇
Day 08 - 例外處理測試 ⚠️
系列文
Python pytest TDD 實戰:從零開始的測試驅動開發8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言