Pytest 101 - 給 Python 開發者的測試入門 (2) - Mock 基礎介紹

Posted by MingLun Allen Wu on Sunday, March 12, 2023

前情提要

Pytest 101 - 給 Python 開發者的測試入門

TL;DR

在撰寫測試時,使用 Mock 來維持測試環境的一致性,能夠有效提升測試的品質、降低測試的複雜度。

本篇筆記將會介紹 Mock 的使用情境以及如何使用 Python 原生的 unittest 套件來建立 Mock 物件。

本篇筆記所使用的範例,可以在 Github - pytest_101 中找到。

為什麼需要 Mock ?

在前一篇文章 : Pytest 101 - 給 Python 開發者的測試入門 中,我們了解到要寫出高品質的程式碼,測試案例是不可或缺的。

在撰寫測試時,如何維持「測試環境」的「一致性」是一件非常重要的課題

什麼是「測試環境的一致性」呢?

為了要測試程式碼的邏輯,我們希望每次執行測試時,都是以「相同的環境配置」來進行測試。

換句話說,我們不希望每次執行測試時,得到的行為會不一樣。

讓我們實際舉個例子 ! 我們寫了一個 get_current_month() 來取得現在的月份 :

# src/module_mock.py
from datetime import datetime
def get_current_month() -> int:
    """回傳當下的月份

    Raises:
        Exception: 當月份發生錯誤時,回傳 Exception

    Returns:
        int: 執行當下的月份
    """
    today = datetime.now()

    month = today.month
    if month < 0:
        raise Exception("Wrong month")

    return today.month

接著我們寫一個簡單的測試函式,在這篇文章撰寫的當下 (2023/03/12),我預期 get_current_month() 回傳的月份是 3,所以我在測試程式碼中寫入 assert month == 3

# tests/test_module_mock.py
from src.module_mock import get_current_month

def test_get_current_month():
    month = get_current_month()
    assert month == 3

問題來了!如果一個月後(2023/04/12),我再次進行測試,這時候測試案例會發生什麼事情?

測試並不會通過,因為這時候 get_current_month() 應該會回傳 4

原因是 :

測試當下 datetime.now() 的結果會變動,導致每一次的測試環境都不相同

這種因為外部元件的數值發生變化導致測試難以執行,我們稱為與外部元件有依賴關係 (Dependencies)

讓我們再來看看剛剛的 get_current_month() :

# src/module_mock.py
from datetime import datetime
def get_current_month() -> int:
    today = datetime.now()              # 每次執行都會變動,有依賴關係

    month = today.month                 # 需要測試的邏輯
    if month < 0:                       # 需要測試的邏輯
        raise Exception("Wrong month")  # 需要測試的邏輯

    return today.month

從註解中可以發現,其實真正需要測試的邏輯應該是從第 6 行 (month = today.month) 開始,為了要讓「每次測試的環境都保持一致」,有沒有什麼辦法可以讓 today = datetime.now() 的結果在「測試的當下」被「替換成固定的數值」呢?

為了要消除這種依賴關係,這時候我們就需要 Mock 出場!

什麼是 Mock ?

以白話文來說,Mock 就是 :

在測試的當下,將被測試的「部分程式碼」替換成「另外一種內容」

以上述的 get_current_month() 來說,因為其中包含 today = datetime.now(),導致在不同時間點執行測試時,會得到完全不同的 today,而 today 的不固定,會使得後續的測試很難設定測試成功或失敗的條件。

這時候,我們可以透過 Mock 在執行測試時,將 datetime.now() 的回傳值從「當下的時間點」替換成「固定的數值」。

讓我們透過幾個例子說明:

案例 1 - 透過 Mock 來替換掉特定物件

在剛剛的例子中,我們希望在測試時,可以透過 Mock 來替換掉 datetime.now() 的回傳值,讓每次測試的結果都相同。

我們可以透過 unittest.mock 中的 patch 來達到此目的:

# tests/test_module_mock.py
from unittest.mock import patch
from src.module_mock import get_current_month
from datetime import datetime

def test_get_current_month():
    with patch("src.module_mock.datetime") as mock_datetime: 
        mock_datetime.now.return_value = datetime(2023, 3, 1, 0, 0, 0)
        month = get_current_month()
        assert month == 3

使用 patch 建立一個 Context Manager,目的是要取代掉原始程式碼中的 datetime.now()

特別注意的是 : 在 patch 中要放置的是被替換的物件路徑,儘管 datetime 套件是 Python 的原生套件,但是因為在測試環境中,datetime.now() 是存放在 src/module_mock.py 中,所以在建立 patch 物件時,要使用 with patch("src.module_mock.datetime"),這在初次接觸 Mock 時很容易混淆,建議大家可以實際試試看。

執行測試的當下,src/module_mock 中的 datetime 將會被替換為 mock_datetime 物件,此時我們可以自由設定 mock_datetime 的值。以上述的範例來說,我們將 mock_datetimenow() 的回傳值 (return_value) 設定為 datetime(2023,9,1,0,0,0)

所以在執行測試的當下,當測試框架進入到 src/module_mock.py 中取用 datetime 物件時,會自動將其替換為 mock_datetime 這個 Mock 物件。並在 get_current_month() 呼叫 datetime.now() 時,將回傳值替換為我們在測試中設定的值 datetime(2023,3,1,0,0,0)

這樣一來,我們就可以確保不管在任何時間點進行測試,datetime.now() 都可以維持相同的值。這意味著,測試本身跟 datetime.now() 不會有任何相依關係,我們可以專注的測試程式邏輯有無錯誤!

# tests/test_module_mock.py
from unittest.mock import patch
from src.module_mock import get_current_month
from datetime import datetime

def test_get_current_month():
    with patch("src.module_mock.datetime") as mock_datetime: 
        # 執行測試時將 datetime.now 的 return value 設定為固定的值
        mock_datetime.now.return_value = datetime(2023, 3, 1, 0, 0, 0)
        month = get_current_month()
        # 因為 datetime.now() 的值被固定下來了,這邊可以設定固定的條件值
        # 如果 Assertion 發生錯誤,就代表程式的邏輯發生問題了! 跟 datetime.now() 無關!
        assert month == 3

案例 2 - 透過 Mock 跳過 / 替換特定函數

讓我們來看看另外一個例子,我們建立了一個函式 sleep_for_a_while(),在這個函式中會使用 time.sleep() 暫停幾秒,然後回傳傳入的參數 :

# src/module_mock.py
import time

def sleep_for_a_while(seconds: int) -> int:
    """模擬一個需要執行有點久的函數

    Args:
        seconds (int): Pending 時間


    Returns:
        int: 回傳 Pending 秒數
    """
    print("Ready for sleeping!")
    time.sleep(seconds)
    return seconds

接著我們同樣建立了一個測試案例 :

# tests/test_module_mock.py
from src.module_mock import sleep_for_a_while

def test_sleep_for_a_while():
    response = sleep_for_a_while(20)
    assert response==20

如果我們試著執行這個測試案例,會發生什麼事情呢?

可以發現執行測試花費了 20 秒的時間,因為在程式碼中有 time.sleep(20),每次測試就會因為這行程式碼被卡 20 秒!

假如今天在程式碼中有許多地方都需要等待,那麼執行一次測試的時間就會重複疊加。

以上述例子 (sleep_for_a_while()) 來說,我們真正想要測試的是函式的 Input 和 Output 是否相同,實際上 time.sleep() 這一行沒有任何意義,所以我們希望在測試過程中暫時讓它消失!

from src.module_mock import sleep_for_a_while

def test_sleep_for_a_while(): # 錯誤用法,這一個測試案例將需要花費 20 秒時間等待
    response = sleep_for_a_while(20)
    assert response==20

def test_sleep_for_a_while_mock(): # 用法 1 
    # 使用 patch 將 time.sleep() 函數替換為 None 物件
    with patch("src.module_mock.time.sleep"): 
        response = sleep_for_a_while(20)
        assert response==20

def test_sleep_for_a_while_replace(): # 用法 2
    # 使用 patch 將 time.sleep() 函數替換為 time.sleep(2)
    with patch("src.module_mock.time.sleep", new_callable=time.sleep(2)):
        response = sleep_for_a_while(20)
        assert response==20

在上述案例中,我們說明了 Mock 的另外兩種用法 :

第一種用法是 test_sleep_for_a_while_mock(),在使用 patch 時,不多做另外的設定,此時在執行測試的當下,unittest 會將 time.sleep() 這個函式替換為 None,所以在執行測試時,會得到「將 time.sleep() 直接跳過」的效果。

而第二種用法 test_sleep_for_a_while_replace(),則是在使用 patch 時,透過 new_callable 參數將被 patch 的物件 (src.module_mock 中的 time.sleep()) 替換為新的 Callable Object (time.sleep(2))。

也就是說,在執行測試時,當執行到 src_module_mock 中的 time.sleep() 時,不管參數是多少,一律都會被替換為新的 Callable Object (time.sleep(2))。

讓我們看看這兩種用法的執行結果 :

可以發現總執行時間為 2.11 秒,其中有 2 秒是 test_sleep_for_a_while_replace()time.sleep(20) 替換為 time.sleep(2),所以等待了 2 秒。另外一個 test_sleep_for_a_while_mock() 則是替換為 None 物件,直接跳過。 兩個測試案例加總起來總共執行 2.11 秒,相當合理。

總結

在今天的文章中,我們透過介紹了使用 Pytest 撰寫測試時,一個很重要的概念 Mock !

透過 Mock 可以將測試時會變動的因素替換成「固定」的值,如此一來就能「測試程式本身的邏輯」是否正確。

具體的操作則是使用 patch 語法來將特定的物件或函數替換為「測試者設定的值」或是 None 值。

在下期的文章中,我們會繼續介紹更多 Mock 的相關語法。

感謝你的收看! 我們下次見~



See Also