「API 又掛了,但我的測試還是要繼續跑啊!」這是昨天同事在群組裡的抱怨。當我們開始測試 HTTP API 時,外部依賴成了最大的挑戰。今天我們要學習如何在 Python 中使用 Mock 和 Fake 技術,讓測試不再受外部服務影響。
單元測試基礎 ✅ → Roman Numeral Kata ✅ → 【框架測試 📍】
↑ 我們在這裡
在前 18 天,我們已經掌握了:
回想昨天的 HTTP 測試:
def test_fetch_user():
response = requests.get('https://api.example.com/user/1')
assert response.json()['name'] == 'Alice'
這個測試有什麼問題?
在開始之前,讓我們釐清這些術語:
Python 內建的 unittest.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()
# 建立 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 請求的庫,比 unittest.mock 更直觀:
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 客戶端撰寫完整測試:
# 建立 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
# 建立 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'
# ❌ 錯誤:沒有 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'})
# 請求會被攔截
# ❌ 錯誤: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 測試應該要簡單、可靠、快速。讓外部依賴不再是測試的阻礙!