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

Posted by MingLun Allen Wu on Friday, March 4, 2022

TL;DR

寫 Python 大概也有 4 ~ 5 年的時間了,用 Python 開發過平台、寫 API、訓練深度模型,但似乎一直都沒有寫過測試。

對測試的印象就是 : 很常聽到!感覺很重要! 欸! 但我不會!

實際開始接觸後,開始發現有趣的地方,也漸漸體會為什麼測試很重要。

今天這則筆記希望能跟大家分享 Python 開發者該怎麼透過 pytest 進行測試。

誰適合讀這篇?

  • 會寫 Python,但不知道怎麼撰寫測試
  • 聽過測試,但不知道為什麼需要測試?
  • 隨著專案規模越來越大,開始覺得程式碼一團亂,改A壞B

為何需要測試?

其實在開發過程中,為了確保程式/函式運作正常,在開發完成後,通常都會實際執行一次,確定沒有什麼異常。

舉例來說:

# utils.py
def sum_two_str(str1: str, str2: str) -> str:
    """
    將兩個字串合併在一起:
    Args:
        str1 (str): 字串 1
        str2 (str): 字串 2

    Returns:
        str: 字串1 + 字串2
    """
    return str1 + str2

當我們寫了上述的函式,通常會怎麼測試呢?

我會另外執行一個 test.py,實際 import 這個函式,確定行為沒有什麼異常。

# test.py
from utils import sum_two_str

str1 = "冷萃"
str2 = "咖啡"

print(sum_two_str(str1= str1, str2= str2)) # 結果應該要是"冷萃咖啡"

其實這樣子的行為,就是一種測試。

這種行為,通常我們稱為 “Manual Testing” (手動測試),這種做法有什麼問題嗎?

以我的經驗來說,在專案開發初期,使用 Manual Testing 會有極高的開發效率,彷彿是造物主般,隨心所欲地創造自己的小世界。

但隨著專案的規模成長,函式與模組的數量增加,我開始意識到兩個問題:

  1. test.py 的數量越來越肥大

    當一個函式開發完成後,就是我使用 test.py 的時機,如同上述的例子,我會實際 import 函式,執行確認結果沒問題,然後開始開發下一個函式。

    下一個函式開發結束後,我會把剛剛的測試程式碼註解起來。

    不直接刪掉的原因是「我覺得等等會再用到」,如果刪掉要再重打很麻煩

    接著輸入下一段測試程式碼來手動測試新的函式,如此持續迭代。

    發現問題了嗎?

    在我的流程中,測試程式碼只會不斷地被註解,到後期開始影響到我的開發效率。

    為了要測試先前寫過的某個函式,我要先滑過被註解的數百行程式碼,找到正確的那段,重新執行。

    毫無規劃的蔓生,是混亂的起源

  2. 函式只會測試一次,但偶而會有連鎖反應

    由於每次 test.py 在執行時,只會針對一個函式進行測試,只要通過了,我就會繼續開發下一個函式。

    但有時候函式間是會互相影響的,A 函式通過了手動測試,但在開發 B 函式時,可能不小心影響到了 A 函式的行為,導致 A 函式發生錯誤。

    但此時我只會在 test.py 測試 B 函式的行為。

    直到執行主程式時,發生出錯了!

    唉呀! 真奇怪! 我之前執行時明明都好好的呀!

為何需要測試框架

在介紹 pytest 這個測試框架前,我們先來談談「測試框架」

我認為「測試框架」的目的,是讓你能

有效率的管理並執行測試

手動測試雖然快速且方便,但當專案規模達到一定程度後,不容易管理,且也很難做到重複測試。

  • 測試框架能同時進行多項測試,並將結果以結構化報表的形式呈現。
  • 測試框架能按照測試目的分類,按照需求執行不同類型的測試

以 Python 來說,較主流的測試框架是 unittestpytest

當初選擇從 pytest 入手,是看中它簡單的語法 (只需要依靠 assert 就能實現測試),希望能在開發之餘慢慢摸索,試著習慣測試的存在。

安裝

pytest 身為主流的測試框架,可以透過 pip 快速安裝:

如果有 Clone 範例 Repo 的讀者,可以直接透過 requirements.txt 進行安裝:

conda create -n pytest python=3.7 # Optional
conda activate pytest # Optional
pip install -r requirements.txt

如果想要手動安裝的讀者,可以直接安裝下列套件:

pytest
pytest-mock
pytest-cov
requests-mock

安裝完成後,試著在 Terminal 輸入:

pytest --version

如果有看到版本號就是安裝成功了!

基本架構

使用 Pytest 測試框架進行測試時,需要按照特定格式擺放檔案。

並沒有唯一正確的格式,身為一個廣泛使用的測試框架,Pytest 可依照使用者的需求自行指定。

但是為了方便介紹,讓我們先以下面的架構進行說明:

(此架構同步更新在 Github Repo)

|
|_ Your Repo
|
|_ src/                  # 主要程式碼資料夾
    |_ module_a.py       # 範例模組 A
    |_ module_b.py       # 範例模組 B
|
|_ tests/                # 測試程式碼資料夾
    |_ test_module_a.py  # 模組 A 的測試程式碼
    |_ test_module_b.py  # 模組 B 的測試程式碼
|
|_ pytest.ini            # pytest 相關設定

從上述的架構我們可以掌握幾個原則 :

  • 主要的程式碼會統一放在 src/ (也有人稱為 app, lib 或直接用模組的用途命名)
  • 測試用的程式碼則統一放在 tests 資料夾
  • 通常測試用的程式碼會以 test_xxx.py 命名 (我自己的習慣是直接對應到 src/ 中的模組)

撰寫第一個測試

舉例來說,如果在 src/module_a.py 中撰寫了:

# src/module_a.py
def square(num: int) -> int:
    """範例函式,回傳平方值

    Args:
        num (int): 數值

    Returns:
        int: 平方後的數值
    """
    return num**2

如果是一般的手動測試,我們可能會另外開一個 test.py

# test.py
from src.module_a import square

print(square(8)) # 得到 64

好! 經過「肉眼」的判斷,這個函式寫對了!

該發生的,一定要發生!

使用 pytest 的做法,則是另外撰寫一小段程式碼來確認,這樣的測試函式必須以test 開頭,通常稱為一個 “Test Case” :

# tests/test_module_a.py
from src.module_a import square

def test_square():
    assert square(8) == 64

測試的方法,是透過 Python 的 assert 語法,來做基本的條件判斷:

# assert <條件為真>, "錯誤訊息"
assert square(8) == 64 # 如果 square(8) 不等於 64,則會發生錯誤

透過 assert 語法來指定 「函式應該要達成的條件」,這個條件會隨著函式的目的而有所不同,以上述例子來說,square() 的目的是計算平方值,因此我們需要在 test case test_square() 中試著驗證「計算平方值」這件事是否有被達成。

執行第一個測試

寫好 Test Case 後,接著在 Terminal 執行:

pytest -vv

pytest 會在執行測試時,自動至測試的資料夾(預設是 tests)尋找檔名為 test_ 開頭的檔案,並且執行開頭為 test_ 的函數。

執行測試後,會得到下列畫面:

這意味著我們撰寫的第一個 Test Case - test_square 成功通過了!

計算 Coverage

接著我們嘗試輸入 :

pytest -vv --cov src/

--cov src/ 意味著我們要在執行測試時,計算 src/ 有多少比例的程式碼是「有被測試過」!

執行後則會得到下列結果:

從上圖中可以看到除了原先的測試結果以外,在下方還多了一欄 coverage 的表格,顯示了 src/ 資料夾中的每一個檔案有多少行程式碼(Stmts),以及有多少行程式碼是沒有被測試到的 (Miss)。

將常用參數寫入至 pytest.ini 中

雖然 flag 很好用,但每次執行 pytest 時都需要加上 -vv--cov src/ 其實並不方便。

這時候我們可以在專案的根目錄加上 pytest.ini 檔案,這是 pytest 的主要設定檔,執行 pytest 時,會自動尋找當前位置是否有 pytest.ini,若有,則讀取相關設定後執行:

[pytest]
addopts= -vv --cov src/

addopts意味著「添加參數」,我們設定 -vv--cov src後,接下來只需要執行:

pytest

就會等價於

pytest -vv --cov src/

如果有更多參數,是在每次執行時都需要附加的話,可以考慮統一放置於 pytest.ini,會更省事一些!

測試「正確的錯誤」

該錯誤的地方,也應該要拋出錯誤

接下來讓我們看看 src/module_a.py 的下一個函式

# src/module_a.py
def concat(str_1: str, str_2: str) -> str:
    """將兩個字串串接在一起

    Args:
        str_1 (str): 字串 1
        str_2 (str): 字串 2

    Raises:
        TypeError: 當任一參數不為 str 時,拋出 TypeError

    Returns:
        str: str_1+str_2
    """
    if not (isinstance(str_1, str) and isinstance(str_2, str)):
        raise TypeError("錯誤型態")
    else:
        return str_1 + str_2

在這個函式中:

  • 判斷參數的型態是否為str,如果是,就將兩個字串相加
  • 若不是str,則 Raise Error。

為了妥善的測試兩個不同情境,我們可以分開建立兩個測試案例:

第一個案例,我們測試的是當兩個參數都是字串的時候 ,函數行為是否正常。

這與上一小節的測試基本上是一樣的。

# tests/test_module_a.py
def test_concat():
    str_1 = "Hello! "
    str_2 = "MingLun!"
    assert concat(str_1=str_1, str_2=str_2) == "Hello! MingLun!"

第二個案例,我們要測試的是當參數不為字串時,是否有拋出正確的錯誤:

def test_concat_failed():
    str_1 = 555 # Error Type
    str_2 = 666 # Error Type
    concat(str_1=str_1, str_2=str_2)

我們期待這段程式應該會因為參數型態異常,而拋出錯誤。 來看看結果:

這邊出現了一個有意思的狀況: 在測試中到底要是成功還是失敗呢?

當我們丟 int 格式給 concat() 時,我們期待它要拋出錯誤,現在它真的拋出錯誤了,所以在結果中顯示為failed

但其實拋出錯誤才是「正確的」,我們本來就希望拋出錯誤,所以理論上應該要是 passed 才對!

為了要確保函式有「正確的」拋出「錯誤」,我們可以把剛剛的測試函式改成:

def test_concat_failed():
    str_1 = 555 # Error Type
    str_2 = 666 # Error Type
    with pytest.raises(TypeError): # 以下範圍內的程式碼應該要拋出 Type Error
        concat(str_1=str_1, str_2=str_2)

使用 pytest 內建的 Context Switcher 可以指定「範圍內的程式碼應該要拋出何種錯誤」。

with pytest.raises(<錯誤型態>):
    # 底下的程式碼如果沒有拋出<錯誤型態>,pytest會認為該次測試失敗

現在我們再執行一次測試:

搞定!現在不管是「該正確的測試」還是「該失敗的測試」,我們都可以透過測試來判斷函式行為是否正常了!

使用 mark 來劃分類別

隨著開發的規模上升,可預見測試的數量也會隨之提升,有時不見得會想要執行全部的測試,這時候可以為測試劃分不同的類別,根據需求執行特定類別的測試。

設定的方式是透過 pytest.mark 裝飾子,在測試函式上加上@pytest.mark.<類別>裝飾子,即可將該測試函式設定為特定類別。

舉例來說,我們可以將剛剛的 square() 設定為 math 類別、將 concat() 設定為 string 類別。

設定後的測試如下:

# tests/test_module_a.py
import pytest
from module_a import square, concat
@pytest.mark.math # 設定為 math 類別
def test_square():
    assert square(8) == 64

@pytest.mark.string # 設定為 string 類別
def test_concat():
    str_1 = "Hello! "
    str_2 = "MingLun!"
    assert concat(str_1=str_1, str_2=str_2) == "Hello! MingLun!"

@pytest.mark.string
def test_concat_failed():
    str_1 = 555 # Error Type
    str_2 = 666 # Error Type
    with pytest.raises(TypeError, match="錯誤型態"):
        concat(str_1=str_1, str_2=str_2)

在執行測試時,可使用 -m 標籤來執行特定類別的測試:

pytest -m string # 執行 string 類別的測試
pytest -m "not string" # 執行非 string 類別的測試

不符合類別的測試會如同上圖一樣,被歸類到 deselected,並不會進行測試。

Tips: 我自己很喜歡在函式開發完,準備撰寫 Test Case 時,在「開發中」的 Test Case 上加上 @pytest.mark.test ,也就是賦予這個半成品 test 類別,這是我自己保留給「開發中」的 Test Case。

這樣的好處是在開發 Test Case 時,可以透過:

pytest -m test

快速的檢查當前的 Test Case 是否正確,可以更頻繁的進行迭代,不需要執行既有的其他測試案例。

使用 Fixture 來提高重用率

把常用的物件封裝起來,不需要重複宣告

接著我們看看下一個範例 src/module_b.py,其中宣告了兩個函式:

  • update_value_by_key : 其實就是更新 Dictionary 的值
  • check_key_exists : 其實就是確認 Key 有沒有在 Dictionary 裡面
# src/module_b.py
from typing import Dict, Union

def update_value_by_key(origin_dict:Dict, key: str, value: Union[str, int, float]) -> Dict:
    """更新特定 Key 的 Value

    Args:
        origin_dict (Dict): Python 的 Dictionary
        key (str): 要被更新值的 Key
        value (Union[str, int, float]): 新的 Value

    Raises:
        KeyError: 當 Key 不存在時,拋出 KeyError

    Returns:
        Dict: 更新後的 Dictionary
    """
    if key not in origin_dict:
        raise KeyError
    
    new_dict = origin_dict
    new_dict['key'] = value
    return new_dict

def check_key_exists(dictionary: Dict, key: str) -> bool:
    """確認特定的 Key 是否存在於 Dictionary 中

    Args:
        dictionary (Dict): 被檢查的 Dictionary
        key (str): 要確認的 Key

    Returns:
        bool: key 是否存在於 dictionary 中
    """
    if key in dictionary:
        return True
    else:
        return False

因為這兩個函式都是對 Dictionary 進行操作,在撰寫 Test Case 時,我們也需要建立一個「測試用的Dictionary」,我們來看看 tests/test_module_b.py 的內容:

import pytest
from src.module_b import update_value_by_key, check_key_exists

def test_update_value_by_key():
    test_dict = {"a": 1, "b": 2} # 測試用 Dictionary

    new_dict = update_value_by_key(
        origin_dict=test_dict,
        key="b",
        value=999
    )

    assert new_dict["b"] == 999

def test_update_value_by_key_error():
    test_dict = {"a": 1, "b": 2} # 測試用 Dictionary

    with pytest.raises(KeyError):
        new_dict = update_value_by_key(
            origin_dict=test_dict,
            key="error_key",
            value=999
        )

def test_check_key_exists():
    test_dict = {"a": 1, "b": 2} # 測試用 Dictionary

    assert check_key_exists(dictionary=test_dict, key="a")
    assert not check_key_exists(dictionary=test_dict, key="not existed")

發現了嗎?

因為三個函式都需要對 Dictionary 進行操作,所以三個 Test Case 的一開始都需要宣告一個 test_dict,但其實他們是一樣的東西。

同樣的東西要在每個 Test Case 中宣告一次,實在很沒有效率。

這時候我們可以善用 Fixture 來增加效率:

# tests/fixture.py
import pytest

@pytest.fixture()
def test_dict():
    return {"a": 1, "b": 2}

fixture.py 中,我們將會被重複使用的物件封裝為fixture,具體來說:

  1. 用一個函式來回傳結果
  2. 並在該函式上加上一個 @pytest.fixture 裝飾子

封裝完成後,我們可以在 Test Case 中,直接將該 fixture 當成參數傳入!

import pytest
from src.module_b import update_value_by_key, check_key_exists
from .fixture import test_dict # 從 Fixture 中引入 test_dict

def test_update_value_by_key(test_dict): # 直接將 test_dict 當成參數傳入
    new_dict = update_value_by_key( 
        origin_dict=test_dict,
        key="b",
        value=999
    )

    assert new_dict["b"] == 999

def test_update_value_by_key_error(test_dict): # 直接將 test_dict 當成參數傳入
    with pytest.raises(KeyError):
        new_dict = update_value_by_key(
            origin_dict=test_dict,
            key="error_key",
            value=999
        )

def test_check_key_exists(test_dict): # 直接將 test_dict 當成參數傳入
    assert check_key_exists(dictionary=test_dict, key="a")
    assert not check_key_exists(dictionary=test_dict, key="not existed")

使用 Fixture 的好處除了不用在每個 Test Case 都宣告一樣的物件外,如果需要調整物件 (例如 test_dict )的內容,也不需要更改每一個 Test Case 的物件,只需要在 fixture.py 修改一次就可以了!

結語

在本篇筆記中,介紹了 Pytest 的基本操作:

  • Pytest 的基本架構
  • 使用 mark 來分類管理測試
  • 使用 fixture 來提高程式碼重用率

希望看完這篇筆記後,大家都能掌握基本的 pytest 語法,試著以自己現行開發的程式碼作為標的,慢慢加入一些測試。

如果有任何問題,歡迎聯絡我,一起交流!

我個人的經驗:

雖然撰寫測試是相當麻煩的一件事情,對於開發的進度會有影響。相對地,有了測試後,開發會變得較為踏實,就算專案的規模越來越大,也不會擔心自己改了這個函式,是不是會造成什麼潛在的影響。

在下篇筆記,我們會繼續討論測試中的大魔王 - mock!

我們下次見~


前往下集 : Pytest 101 - 給 Python 開發者的測試入門 (2) - Mock



See Also