iT邦幫忙

2025 iThome 鐵人賽

DAY 19
0

「API 又掛了,但我的測試還是要繼續跑啊!」這是昨天同事在群組裡的抱怨。當我們開始測試 HTTP API 時,外部依賴成了最大的挑戰。今天我們要學習如何在 Python 中使用 Mock 和 Fake 技術,讓測試不再受外部服務影響。

旅程地圖

單元測試基礎 ✅ → Roman Numeral Kata ✅ → 【框架測試 📍】
                                              ↑ 我們在這裡

在前 18 天,我們已經掌握了:

  • 測試基礎概念(Day 1-10)
  • TDD 紅綠重構循環
  • Roman Numeral Kata 實戰(Day 11-17)
  • HTTP 測試基礎(Day 18)

今天的學習目標

  • 理解 Mock 與 Fake 的差異
  • 學會使用 unittest.mock 模組
  • 掌握 responses 庫的使用
  • 建立可靠的 API 測試

為什麼需要 Mock?

回想昨天的 HTTP 測試:

def test_fetch_user():
    response = requests.get('https://api.example.com/user/1')
    assert response.json()['name'] == 'Alice'

這個測試有什麼問題?

  • 依賴外部 API(可能掛掉)
  • 測試速度慢(網路延遲)
  • 無法測試錯誤情況
  • 可能產生費用(API 計費)

Mock vs Fake vs Stub 概念澄清

在開始之前,讓我們釐清這些術語:

Mock

  • 可以驗證互動行為
  • 記錄被呼叫的次數和參數
  • 適合測試「是否正確呼叫」

Fake

  • 提供簡化的實作
  • 有實際運作邏輯
  • 適合測試「功能是否正常」

Stub

  • 只回傳預設值
  • 最簡單的替身
  • 適合測試「單純的回傳值」

Python Mock 基礎

Python 內建的 unittest.mock 是強大的 Mock 工具:

基本 Mock 使用

# 建立 tests/day19/test_mock_basics.py
from unittest.mock import Mock, patch
import pytest

def test_mock_return_value():
    # 建立 Mock 物件
    mock_func = Mock()
    mock_func.return_value = 'Hello Mock'
    
    result = mock_func()
    assert result == 'Hello Mock'

def test_mock_side_effect():
    # Mock 可以有副作用
    mock_func = Mock()
    mock_func.side_effect = [1, 2, 3]
    
    assert mock_func() == 1
    assert mock_func() == 2
    assert mock_func() == 3

def test_mock_called_with():
    # 驗證呼叫參數
    mock_func = Mock()
    mock_func('test', key='value')
    
    mock_func.assert_called_with('test', key='value')
    mock_func.assert_called_once()

使用 patch 裝飾器

# 建立 src/services/weather.py
import requests

def get_weather(city: str) -> dict:
    response = requests.get(f'https://api.weather.com/{city}')
    return response.json()

# 建立 tests/day19/test_weather_mock.py
from unittest.mock import patch, Mock
from src.services.weather import get_weather

@patch('src.services.weather.requests')
def test_get_weather_with_mock(mock_requests):
    # 設置 Mock 回傳值
    mock_response = Mock()
    mock_response.json.return_value = {
        'city': 'Taipei',
        'temperature': 25,
        'condition': 'Sunny'
    }
    mock_requests.get.return_value = mock_response
    
    # 測試
    result = get_weather('Taipei')
    
    # 驗證
    assert result['city'] == 'Taipei'
    assert result['temperature'] == 25
    mock_requests.get.assert_called_with('https://api.weather.com/Taipei')

使用 responses 庫(更優雅的 HTTP Mock)

responses 是專門用於模擬 HTTP 請求的庫,比 unittest.mock 更直觀:

安裝 responses

pip install responses

基本使用

# 建立 tests/day19/test_responses_basics.py
import responses
import requests
import pytest

@responses.activate
def test_simple_response():
    # 註冊假的 HTTP 回應
    responses.add(
        responses.GET,
        'https://api.example.com/user/1',
        json={'id': 1, 'name': 'Alice'},
        status=200
    )
    
    # 發送請求
    response = requests.get('https://api.example.com/user/1')
    
    # 驗證
    assert response.json()['name'] == 'Alice'
    assert len(responses.calls) == 1

@responses.activate
def test_multiple_endpoints():
    # 可以註冊多個端點
    responses.add(responses.GET, 'https://api.example.com/users',
                  json=[{'id': 1}, {'id': 2}])
    responses.add(responses.POST, 'https://api.example.com/users',
                  json={'id': 3}, status=201)
    
    # GET 請求
    get_response = requests.get('https://api.example.com/users')
    assert len(get_response.json()) == 2
    
    # POST 請求
    post_response = requests.post('https://api.example.com/users',
                                   json={'name': 'Charlie'})
    assert post_response.status_code == 201

實戰:Todo API 客戶端測試

讓我們為 Todo API 客戶端撰寫完整測試:

Todo 客戶端實作

# 建立 src/clients/todo_client.py
import requests
from typing import List, Dict, Optional

class TodoClient:
    def __init__(self, base_url: str):
        self.base_url = base_url
    
    def get_todos(self) -> List[Dict]:
        response = requests.get(f'{self.base_url}/todos')
        response.raise_for_status()
        return response.json()
    
    def get_todo(self, todo_id: int) -> Dict:
        response = requests.get(f'{self.base_url}/todos/{todo_id}')
        response.raise_for_status()
        return response.json()
    
    def create_todo(self, title: str, completed: bool = False) -> Dict:
        payload = {'title': title, 'completed': completed}
        response = requests.post(f'{self.base_url}/todos', json=payload)
        response.raise_for_status()
        return response.json()
    
    def update_todo(self, todo_id: int, **kwargs) -> Dict:
        response = requests.patch(f'{self.base_url}/todos/{todo_id}', 
                                   json=kwargs)
        response.raise_for_status()
        return response.json()
    
    def delete_todo(self, todo_id: int) -> bool:
        response = requests.delete(f'{self.base_url}/todos/{todo_id}')
        return response.status_code == 204

使用 responses 測試 TodoClient

# 建立 tests/day19/test_todo_client.py
import responses
import pytest
from src.clients.todo_client import TodoClient

class TestTodoClient:
    @pytest.fixture
    def client(self):
        return TodoClient('https://api.example.com')
    
    @responses.activate
    def test_get_todos(self, client):
        # 設置假回應
        responses.add(
            responses.GET,
            'https://api.example.com/todos',
            json=[
                {'id': 1, 'title': 'Task 1', 'completed': False},
                {'id': 2, 'title': 'Task 2', 'completed': True}
            ],
            status=200
        )
        
        # 執行
        todos = client.get_todos()
        
        # 驗證
        assert len(todos) == 2
        assert todos[0]['title'] == 'Task 1'
        assert todos[1]['completed'] is True
    
    @responses.activate
    def test_create_todo(self, client):
        # 設置假回應
        responses.add(
            responses.POST,
            'https://api.example.com/todos',
            json={'id': 3, 'title': 'New Task', 'completed': False},
            status=201
        )
        
        # 執行
        new_todo = client.create_todo('New Task')
        
        # 驗證
        assert new_todo['id'] == 3
        assert new_todo['title'] == 'New Task'
        
        # 驗證請求內容
        assert len(responses.calls) == 1
        request = responses.calls[0].request
        assert request.body == b'{"title": "New Task", "completed": false}'
    
    
    @responses.activate
    def test_handle_error(self, client):
        # 測試錯誤處理
        responses.add(
            responses.GET,
            'https://api.example.com/todos/999',
            json={'error': 'Not found'},
            status=404
        )
        
        # 執行並預期錯誤
        with pytest.raises(requests.exceptions.HTTPError):
            client.get_todo(999)

進階技巧:動態回應

有時我們需要根據請求內容動態回應:

# 建立 tests/day19/test_dynamic_responses.py
import responses
import requests
import json

def request_callback(request):
    # 解析請求內容
    payload = json.loads(request.body)
    
    # 根據請求動態回應
    if payload.get('title') == 'Important':
        return (200, {}, json.dumps({'priority': 'high'}))
    else:
        return (200, {}, json.dumps({'priority': 'normal'}))

@responses.activate
def test_dynamic_response():
    # 註冊動態回應
    responses.add_callback(
        responses.POST,
        'https://api.example.com/todos',
        callback=request_callback
    )
    
    # 測試不同請求
    response1 = requests.post('https://api.example.com/todos',
                              json={'title': 'Important'})
    assert response1.json()['priority'] == 'high'
    
    response2 = requests.post('https://api.example.com/todos',
                              json={'title': 'Regular'})
    assert response2.json()['priority'] == 'normal'

今天的成就

  • ✅ 理解 Mock、Fake、Stub 的概念差異
  • ✅ 掌握 unittest.mock 基本用法
  • ✅ 學會使用 responses 庫模擬 HTTP
  • ✅ 完成 TodoClient 的完整測試
  • ✅ 了解 Mock 測試的最佳實踐

常見陷阱與解決方案

陷阱 1:忘記 activate 裝飾器

# ❌ 錯誤:沒有 activate
def test_without_activate():
    responses.add(responses.GET, 'https://api.example.com/data',
                  json={'result': 'ok'})
    # 這會真的發送請求!
    
# ✅ 正確:使用 activate
@responses.activate
def test_with_activate():
    responses.add(responses.GET, 'https://api.example.com/data',
                  json={'result': 'ok'})
    # 請求會被攔截

陷阱 2:Mock 位置錯誤

# ❌ 錯誤:Mock 原始模組
@patch('requests.get')
def test_wrong_patch(mock_get):
    # 如果 weather.py import requests,這可能不會生效
    pass

# ✅ 正確:Mock 使用處
@patch('src.services.weather.requests.get')
def test_correct_patch(mock_get):
    # Mock weather.py 中的 requests.get
    pass

明天預告

明天我們將繼續深入探索測試的進階技巧,讓我們的測試能力更上一層樓!


記住:好的 Mock 測試應該要簡單、可靠、快速。讓外部依賴不再是測試的阻礙!


上一篇
Day 18 - HTTP 測試基礎 🌐
下一篇
Day 20 - 測試 TodoList 元件 📝
系列文
Python pytest TDD 實戰:從零開始的測試驅動開發20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言