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 會有極高的開發效率,彷彿是造物主般,隨心所欲地創造自己的小世界。
但隨著專案的規模成長,函式與模組的數量增加,我開始意識到兩個問題:
test.py
的數量越來越肥大當一個函式開發完成後,就是我使用
test.py
的時機,如同上述的例子,我會實際 import 函式,執行確認結果沒問題,然後開始開發下一個函式。下一個函式開發結束後,我會把剛剛的測試程式碼註解起來。
不直接刪掉的原因是「我覺得等等會再用到」,如果刪掉要再重打很麻煩
接著輸入下一段測試程式碼來手動測試新的函式,如此持續迭代。
發現問題了嗎?
在我的流程中,測試程式碼只會不斷地被註解,到後期開始影響到我的開發效率。
為了要測試先前寫過的某個函式,我要先滑過被註解的數百行程式碼,找到正確的那段,重新執行。
毫無規劃的蔓生,是混亂的起源
函式只會測試一次,但偶而會有連鎖反應
由於每次
test.py
在執行時,只會針對一個函式進行測試,只要通過了,我就會繼續開發下一個函式。但有時候函式間是會互相影響的,A 函式通過了手動測試,但在開發 B 函式時,可能不小心影響到了 A 函式的行為,導致 A 函式發生錯誤。
但此時我只會在
test.py
測試 B 函式的行為。直到執行主程式時,發生出錯了!
唉呀! 真奇怪! 我之前執行時明明都好好的呀!
為何需要測試框架
在介紹 pytest
這個測試框架前,我們先來談談「測試框架」
我認為「測試框架」的目的,是讓你能
有效率的管理並執行測試
手動測試雖然快速且方便,但當專案規模達到一定程度後,不容易管理,且也很難做到重複測試。
- 測試框架能同時進行多項測試,並將結果以結構化報表的形式呈現。
- 測試框架能按照測試目的分類,按照需求執行不同類型的測試
以 Python 來說,較主流的測試框架是 unittest
及 pytest
當初選擇從 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
,具體來說:
- 用一個函式來回傳結果
- 並在該函式上加上一個
@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