深入解析 monkeypatch:Python 測試中的強大工具

更新日期: 2025 年 2 月 13 日

在撰寫測試時,我們經常會遇到一些問題,例如:

  • 需要測試的函數會調用外部 API,但我們不希望真正發送請求。
  • 某些函數會讀取環境變數,而我們希望在測試時提供不同的值。
  • 某些系統函數可能會影響測試結果,導致測試不穩定。

這時候,pytest 提供的 monkeypatch 工具就能派上用場。

它允許我們在測試期間動態修改函數、類別、屬性、環境變數等,使我們能夠更方便地控制測試環境,確保測試結果的可預測性與穩定性。

本文將詳細介紹 monkeypatch 的用途、使用方式及最佳實踐,讓你能夠在 Python 測試中得心應手地運用它。


命名由來

monkeypatch 這個名稱的由來與 “Monkey Patching”(猴子補丁) 這個概念有關,它是一種在 執行時動態修改程式行為 的技術。

  1. Monkey(猴子)+ Patch(補丁)
    “Monkey” 在程式設計領域中有時代表 不受控制的修改或即興的變更,而 “Patch” 則表示對程式的修補。因此,monkeypatch 這個詞的意思就是 “像猴子一樣隨意地修改程式碼”
  2. 靈感來自猴子的行為
    猴子(monkey)以頑皮和隨機的行為著稱,而 “Monkey Patching” 這個詞則形象地描述了一種在程式執行時對既有程式碼進行動態修改的方式,就像猴子亂動某些東西一樣,使它產生不同的行為。
  3. 起源於 Ruby 和 Smalltalk
    • 這個術語最早可以追溯到 Smalltalk,後來在 Ruby 社群中廣為流傳,指的是在不修改原始碼的情況下,動態覆蓋或替換現有的方法
    • 例如,在 Ruby 中可以重新定義核心函數,讓它表現出不同的行為。
  4. Python 社群的採用
    • Python 也支援這種動態修改技術,例如透過 monkeypatch臨時替換函數、類別或屬性,在測試時模擬不同的場景。
    • pytestmonkeypatch 工具就是基於這種概念,專門用來 讓測試可以控制某些行為,而不影響程式的永久運行

monkeypatch 是什麼?

monkeypatchpytest 內建的一個 fixture,允許我們在測試時暫時修改函數或屬性,並在測試結束後自動還原原始行為。

它的核心目標是 提供一種安全且可控的方式來修改程式行為,讓測試能夠模擬各種場景,而不影響實際程式的運行。

monkeypatch 主要用於:

  1. 替換函數或方法(Mock 內建函數或第三方函數)
  2. 修改類別或屬性
  3. 變更環境變數
  4. 移除屬性或方法

補充:什麼是 fixture

pytest 中,fixture 是一種提供測試前置(setup)和後置(teardown)步驟的機制

它可以用來準備測試環境,例如建立測試資料庫、模擬 API 回應、設定測試變數等。

fixture 讓測試更加模組化、可重複使用,並提高測試的可讀性和可維護性。

fixture 的基本概念

pytest 中,fixture 是透過 @pytest.fixture 裝飾器來定義的,並且會被自動調用到測試函數中。

基本範例

import pytest

# 定義 fixture
@pytest.fixture
def sample_data():
    return {"name": "Alice", "age": 25}

# 測試函數使用 fixture
def test_sample_data(sample_data):
    assert sample_data["name"] == "Alice"
    assert sample_data["age"] == 25

範例解析

  1. @pytest.fixture 定義了一個 sample_datafixture,它回傳一個測試用的字典。
  2. 測試函數 test_sample_data(sample_data) 直接使用這個 fixturepytest 會自動將 sample_data 的值傳入測試函數。
  3. 測試函數可以透過這個 fixture 取得測試資料,而不需要手動建立。

fixture 的作用

1. 減少重複程式碼

使用 fixture 可以避免在每個測試函數內部重複建立相同的測試資料。

2. 提供測試隔離

fixture 確保每次測試都從相同的初始狀態開始,避免測試之間相互影響,提高測試的可靠性。

3. 提供 setupteardown

fixture 可以在測試開始前進行 初始化,在測試結束後 清理資源(如關閉資料庫連線、刪除測試檔案等)。


monkeypatch 的使用方式

替換函數的行為 (monkeypatch.setattr())

monkeypatch.setattr() 方法簡介

monkeypatch.setattr() 允許我們在測試期間 動態修改某個物件的屬性或方法,使其在測試執行時表現出不同的行為。

monkeypatch.setattr() 方法的參數

monkeypatch.setattr(target, name, value, raising=True)
參數說明
target需要修改的物件(模組、類別或實例)
name物件的屬性或方法名稱(字串)
value替換的新值(函數、類別或變數)
raising預設為 True,若 name 不存在則拋出 AttributeError,設為 False 則忽略錯誤

示例 1:替換函數

假設我們有一個函數 get_current_time(),它會回傳 time.time() 的結果,但我們希望測試時使用固定的值,以確保測試結果穩定。

原始程式
import time

def get_current_time():
    return time.time()
測試時使用 monkeypatch.setattr() 替換 time.time()
def test_get_current_time(monkeypatch):
    def fake_time():
        return 1234567890  # 固定的時間戳

    monkeypatch.setattr(time, "time", fake_time)  # 替換 time.time 為 fake_time

    assert get_current_time() == 1234567890  # 測試是否返回固定值
為什麼要這樣做?
  1. time.time() 會隨著時間變動,但測試需要可重現的結果,因此我們用 monkeypatch.setattr() 讓它回傳固定值
  2. 確保測試穩定性,避免測試因系統時間不同而產生不同結果。
  3. 不影響其他測試或系統函數monkeypatch 會在測試結束後自動還原 time.time() 的原始行為。

示例 2:替換類別

有時候,我們想要模擬某個類別的行為,例如應用程式需要與 OpenAI API 互動,但我們不希望在測試時真正發送 API 請求。

原始程式
import openai

class OpenAIClient:
    def __init__(self, api_key):
        self.client = openai.Client(api_key=api_key)

    def get_response(self, prompt):
        response = self.client.chat.completions.create(
            model="gpt-4",
            messages=[{"role": "user", "content": prompt}]
        )
        return response

測試時使用 monkeypatch.setattr() 替換 openai.OpenAI

def test_openai_client(monkeypatch):
    class FakeChatCompletions:
        def create(self, *args, **kwargs):
            return {"choices": [{"message": {"content": "Mocked Response"}}]}

    class FakeChat:
        def __init__(self):
            self.completions = FakeChatCompletions()  

    class FakeOpenAIClient:
        def __init__(self, api_key):
            self.api_key = api_key
            self.chat = FakeChat()  # ✅ 更新為新版 API 的結構

    monkeypatch.setattr(openai, "Client", FakeOpenAIClient)  

    client = OpenAIClient(api_key="fake-key")
    response = client.get_response("Hello!")

    assert response["choices"][0]["message"]["content"] == "Mocked Response"
為什麼要這樣做?
  1. 避免真實 API 請求,防止影響 API 配額或測試速度。
  2. 模擬 API 回應,確保測試可以驗證 API 呼叫行為,而不依賴 OpenAI 伺服器的可用性。
  3. 提高測試的可控性,可以測試 API 回應的不同情境。

修改環境變數 (monkeypatch.setenv())

monkeypatch.setenv() 方法簡介

monkeypatch.setenv() 允許我們修改環境變數,以便在測試時模擬不同的設定。

monkeypatch.setenv() 方法的參數

monkeypatch.setenv(name, value, prepend=False)
參數說明
name環境變數名稱(字串)
value環境變數的新值
prepend若為 True,則會將值附加到現有變數的前方

示例:修改 API 金鑰

假設我們的應用程式透過環境變數 API_KEY 來存取 API。

原始程式
import os

def get_api_key():
    return os.getenv("API_KEY", "default-key")
測試時使用 monkeypatch.setenv() 設定環境變數
def test_get_api_key(monkeypatch):
    monkeypatch.setenv("API_KEY", "test-key")  # 設定環境變數

    assert get_api_key() == "test-key"
為什麼要這樣做?
  1. 模擬不同環境,確保程式可以根據不同的 API 金鑰運行。
  2. 避免修改系統環境變數,確保測試結束後環境變數不會受到影響。

移除屬性或方法 (monkeypatch.delattr())

monkeypatch.delattr() 方法簡介

monkeypatch.delattr() 允許我們在測試期間刪除某個屬性或方法,用來模擬該屬性不存在的情況。

monkeypatch.delattr() 方法的參數

monkeypatch.delattr(target, name, raising=True)
參數說明
target需要刪除屬性的物件
name屬性名稱(字串)
raising預設為 True,若 name 不存在則拋出 AttributeError,設為 False 則忽略錯誤

示例:移除 os.remove() 方法

假設我們的程式需要刪除檔案,但我們希望測試當 os.remove() 不存在時的行為

原始程式
import os

def delete_file(filename):
    os.remove(filename)
測試時使用 monkeypatch.delattr()
def test_delete_file(monkeypatch):
    monkeypatch.delattr(os, "remove")  # 移除 os.remove 方法

    try:
        delete_file("test.txt")
    except AttributeError:
        assert True  # 測試成功,因為 os.remove() 已被刪除

為什麼要這樣做?

  1. 測試系統函數缺失時的行為,確保程式能夠正確處理異常。
  2. 模擬不同環境,例如在受限系統中 os.remove() 可能不可用。

最佳實踐與注意事項

確保 monkeypatch 只在測試範圍內生效

monkeypatch 會在測試結束後自動還原變更,不過還是建議不要在測試範圍外使用它,以免影響其他測試案例。

使用 monkeypatch 時應確保變更合理

過度使用 monkeypatch 可能會讓測試變得過於依賴 mock,而無法驗證程式的真實行為。

因此,應該只在必要時使用 monkeypatch,並搭配其他測試方法(如整合測試)。

搭配 pytest 使用 monkeypatch

monkeypatchpytest 提供的 fixture,因此應搭配 pytest 來運行測試,以確保 monkeypatch 能夠正確運作。


結論

monkeypatch 是 Python 測試中非常有用的工具,它允許我們輕鬆地修改函數、類別、環境變數等,使測試更可控、更穩定。

在測試過程中,透過適當地運用 monkeypatch,我們可以有效避免不必要的 API 請求、模擬不同的測試場景,並確保程式能夠在各種情境下正常運作。

希望這篇文章能幫助你更深入了解 monkeypatch,並在實際測試中靈活運用它! 🚀

Similar Posts