Pytest 101 - 給 Python 開發者的測試入門 (3) - 圖解 Mock & 測試框架語法整理

Posted by MingLun Allen Wu on Thursday, August 17, 2023

前情提要

TL;DR

本篇筆記透過圖解來進一步說明 Mock 在測試中所扮演的角色。

此外針對 Python 常見的測試框架 unittestpytest,分別整理了常用的 Mock 語法。

在內容中所使用到的說明程式碼,你可以在 Github - pytest_101 取得。

前言

在前一篇筆記 : Pytest 101 - 給 Python 開發者的測試入門 (2) - Mock 基礎介紹 中,我們討論了 Mock 的基本概念,也針對幾個實際案例分享了 Mock 的使用方法。

然而,在實際撰寫測試的過程中,我發現自己對於 Mock 的認知還是有點模糊,在網路上搜尋資料時,常會看到不同的 Mock 語法,因此想要透過這篇筆記進一步梳理。


範例 - 呼叫 API

在今天的範例中,我們會撰寫一個 Function check_response_greater_than_0_5() :

確認 Response 的數值是否大於 0.5

這個 Function 的目的很單純 :

  1. 呼叫外部的 API : call_external_api()
  2. 嘗試讀取 Response 中的特定數值 : "response_value"
  3. 依據 response_value 的數值進行判斷 :
    • 如果 response_value > 0.5, 回傳 True
    • 如果 response_value <= 0.5, 回傳 False
    • 如果 response_value 不存在, 拋出錯誤

基本架構圖

# src/check_response.py
from src.external import call_external_api
def check_response_greater_than_0_5() -> bool:
    """
    判斷 call_external_api() 的數值是否大於 0.5
    """
    external_result = call_external_api() # 呼叫其他 API
    response_value = external_result.get("response_value", None) # 從 Response 中取得 response_value 的數值
    if response_value is None: 
        raise KeyError("response_value not exists!") # 如果 response_value 不存在,拋出錯誤
    elif external_result > 0.5:
        return True
    else:
        return False

通常在撰寫測試的過程中,常會遇到某些 Function 或是外部元件包含「不確定性」,意思是 :

我們並不確定與外部元件互動時,得到的 Response 是什麼。

為了模擬這個「不確定性」,我們試著在 call_external_api() 中加入一些隨機性 :

# src/external.py
from typing import Dict
import time
import random

def call_external_api() -> Dict:
    """模擬呼叫外部的 API

    Returns:
        Dict: 外部 API 回傳的結果
    """
    time.sleep(0.5)
    # 有 50% 機率回傳沒有任何資料的 Dict
    if random.random() < 0.5:
        return {}
    response_value = random.random()
    return {"response_value": response_value}

上述程式碼會讓 call_external_api() 的結果有下列可能性 :

  • 有 50% 機率會出現空白 Response :
    {}
    
  • 有 50% 機率會回傳隨機的數值 :
    {"response_value": X} # X 為一個隨機數值
    

開始撰寫測試

接下來,讓我們開始嘗試對 check_response_greater_than_0_5() 撰寫測試,那麼我究竟要測試什麼呢 ?

我們再看一次 Function 的邏輯圖 :

基本架構圖

從上圖中看到的三個情境,都必須是我們要納入測試的,但是問題來了 :

每次呼叫 call_external_api() 的結果都不同,該如何撰寫測試呢?


圖解 Mock

透過 Mock,我們可以在執行測試的當下,將 「不穩定的物件」替換成「特定的物件」,來確保程式碼的邏輯可以被測試。

舉例來說:

當我們 call_external_api() 時有 50% 的機率會得到 {},此時我們的 Function 應該要拋出錯誤訊息。

然而,如果我們真的在測試的過程中呼叫 call_external_api(),執行 100 次測試中,理論上只有 50 次的情境會得到 {},其他 50 次會是隨機的 {"response_value": X}

當測試的配置相同時,理論上每次執行測試的結果都應該要完全相同!,如果每次執行測試,都會出現不同結果,這個測試也就失去意義了。

為了讓每次測試的結果都一致,我們可以先檢驗第一個條件 :

當 Response 為 {} 時,check_response_greater_than_0_5()必須要拋出錯誤

為了檢驗這個情境是否正確運行,我們需要先確保一件事情 :

讓 Response 回傳 {}

為了達成這個目的,我們可以透過 Mock 來產生一個 mock_api(),替換掉原先的 call_external_api(),使其在測試執行的當下,一定會回傳 {}

mock 示意圖

如此一來,我們在測試的當下不再需要擔心 call_external_api() 的結果是什麼。而是專注在 :

call_external_api() 的結果為 {} 時,當下函式的行為是否正常。

接下來,讓我們將 Mock 擴充到其他不同情境 :

  1. 當 Response 的 response_value > 0.5 時,需要回傳 True
  2. 當 Response 不存在 response_value 時,拋出錯誤
  3. 當 Response 的 response_value <= 0.5 時,需要回傳 False

在這三個測試情境下,分別針對 call_external_api() 的結果有一些先決條件,同樣的,我們可以用 Mock 來替換 :

Test Case 示意圖

從上圖可以發現,所謂的 Mock,其實就是在測試過程中,替換函式中的特定物件,目的是驗證特定情況下,函式的行為是否正常

而這樣的行為,我們讓測試的過程中不再受限於 call_external_api() 的變化,而是透過 Mock 來作出「環境隔離」,藉著隔離這些具有「變異性」的物件,將測試的焦點著重在我們的邏輯判斷 (例如 : response_value > 0.5 時要 return True)。


Mock 語法整理

初接觸 Mock 時,網路上關於 Mock 的範例會根據測試框架而有所不同,有時候會有點混淆。

了解 Mock 的概念後,接下來我們來整理不同框架間對於 Mock 的語法,今天想要針對兩種不同框架的 Mock 寫法進行分享 :

  1. unittest
  2. pytest_mock

unittest

我認為 unittest 套件的寫法是最好理解 (但可能不是最簡潔) 的。

Test Case 示意圖

以上圖為例,假設我們要建立一個 mock_api_1() 來取代 call_external_api(),我們該怎麼做呢 ?

使用 unittest 前,需要掌握幾個重點 :

  1. unittest 是 python 原生套件,不需要額外安裝

  2. unittest 可以透過 Context Switch mock.patch() 來建立 Mock 物件,語法如下 :

    from unittest import mock
    with mock.patch(<被替換的物件>) as <別名> :
        # 在 Context Switcher 範圍內只要出現 <被替換的物件>,就會被替換成 Mock 物件
        ...
    

讓我們試著實作剛剛的範例 :

from unittest import mock # unittest 為 python 原生套件

def test_check_response_greater_than_0_5():
    # 將 src.check_response 中的 call_external_api 替換成 mock_api_1 物件
    with mock.patch("src.check_response.call_external_api") as mock_api_1:

        # 指定 mock_api_1 物件的回傳值
        mock_api_1.return_value = {"response_value": 0.95} 
        
        # 因為在 mock 的 context switcher 中,所以該 Function 執行過程中 \
        # 會把 call_external_api() 替換為 mock_api_1
        check_response_greater_than_0_5()

pytest-mock

pytest-mock 則是另一個我喜歡使用的工具,好處是寫法簡潔!

然而,對於初接觸 Mock 的使用者來說,可能會覺得概念有點跳躍。

但在理解 unittest 的寫法後,你可以將 pytest-mock 視為 unittest 的簡潔版!

使用 pytest-mock 前需要先安裝套件 :

pip install pytest-mock

在使用 pytest-mock 時需要掌握 1 個重點 :

使用前須先將 mocker Fixture 加入 Test Case 中

你可以將 pytest-mock 想像成是幫你寫好一個名叫 mocker 的 Fixture,當你在 Test Case 中加入 mocker Fixture 後,就可以直接呼叫 mocker.patch() 來進行替換,相較於 unittest 框架的 Context Switcher 來說,使用上會更簡潔。

from pytest_mock import MockFixture
from src.check_response import check_response_greater_than_0_5

def test_check_response_greater_than_0_5(mocker: MockFixture):
    mock_api_2 = mocker.patch("src.check_response.call_external_api", return_value={"response_value": 0.25})

    result = check_response_greater_than_0_5()
    assert result is False

總結

讓我們來做個總結,在原先的架構中 :

基本架構圖

由於我們無法確定 call_external_api() 的 Response 為何,當 Response 無法確定時,當然無法驗證後續的條件是否正確。

因此,我們透過 Mock 來替換 call_external_api(),使得在測試過程中,call_external_api() 可以輸出「特定」的 Response。

有了固定的前提,我們才可以驗證後續 Function 的行為是否正確 :

Test Case 示意圖

最終的測試案例如下 :

from src.check_response import check_response_greater_than_0_5
from pytest_mock import MockFixture
from unittest import mock
import pytest

# 使用 unittest 寫法
def test_check_response_greater_than_0_5_true():
    with mock.patch("src.check_response.call_external_api") as mock_api_1:
        mock_api_1.return_value = {"response_value": 0.95}
        result = check_response_greater_than_0_5()
        assert result is True

# 使用 pytest-mock 寫法
def test_check_response_greater_than_0_5_false(mocker: MockFixture):
    return_value = {"response_value": 0.25}
    mock_api_2 = mocker.patch("src.check_response.call_external_api", return_value=return_value)
    result = check_response_greater_than_0_5()
    assert result is False

# 使用 pytest-mock 寫法
def test_check_response_greater_than_0_5_error(mocker: MockFixture):
    return_value = {}
    mock_api_3 = mocker.patch("src.check_response.call_external_api", return_value=return_value)
    with pytest.raises(KeyError):
        check_response_greater_than_0_5() # 預期要拋出 KeyError

在今天的筆記中,我們用圖解的方式說明 Mock 在測試中扮演的角色,並且整理了 Python 開發者常會使用到的 Mock 語法。

希望以上內容能幫助你更了解 Mock 是什麼,並且讓你可以實際在測試中使用 Mock!

有問題歡迎在下方留言!我們下次見!



See Also