Python 測試入門 - 開始使用 PyTest

Python 測試入門系列之二,開始使用 PyTest、如何寫測試、怎麼偵錯

前一篇文章中聊到幾個 unittest 相對弱勢的地方——雖然那也是在已經舒服地使用 PyTest 一段時間後去體驗到的弱勢;這篇文章就起個頭來聊聊使用 PyTest 是個什麼樣的體驗。

這篇文沒有需要用前一篇文當先備知識,請放心閱讀。

從 unittest 轉換到 PyTest

如果手上沒有剛好有一個 unittest 框架下的專案,那麼可以直接跳過這個段落。如果手上恰好有這麼一個專案要轉移到 PyTest,那很開心地宣布這基本上是無痛的。安裝它、改用它當作進入點、開始享受那些額外的功能。如測試結果的回報——

開始使用後最明顯的感受大概會是報告的資訊變多了,包含環境、預期的工作數量、當前工作進度、測試結果等(貼到這裡來還少掉了顏色的加持)。

pytest
============================= test session starts =============================
platform darwin -- Python 3.10.6, pytest-7.1.2, pluggy-1.0.0
rootdir: REDACTED
collected 3 items

test/test___init__.py ...                                               [100%]

============================== 3 passed in 0.01s ==============================

喔對了,它的 entrypoint 就只剩 pytest 這樣而已(沒有啥 discover 了),後面倒是多了一拖拉庫的可選參數可以設定。

安裝

pip install pytest

安裝這段基本上應該沒啥坑。

程式碼要怎麼放

Ok,當今天我想開始整測試進去一個既存的專案,那我程式碼該怎麼放?在跟專案平行的地方建立起 tests 資料夾1

.
├── Readme.md
├── demo
│   ├── __init__.py
│   └── foo.py
└── tests
    └── test_foo.py

PyTest 的預設搜尋規則是:當前目錄下檔名為 test_ 開頭或 _test 結尾的檔案們。

這規則十分簡明,但如果濫用它將會導致管理困難,我們習慣上會去建立起名為 tests 或類似名稱的資料夾、並在裡面仿造主專案的結構配置測試腳本,即用 test/test_foo.py 去測 demo/foo.py 裡面的函數、用 test/bar/test_qax.py 去測 demo/bar/qax.py 裡面的函數,這樣的配置模式會比較易於找出每段程式碼其對應的測試腳本在哪。

另外,相較於 unittest 框架需要把 test 資料夾模組化,在 PyTest 我們無需這樣做。

寫測試囉

我覺得這是 PyTest 最舒服的地方,它支援了多種不同格式的測試腳本,讓寫測試這件事情變得比較沒有負擔。其支援的格式包括:

  1. test_ 開頭的函數
  2. Test 開頭的類別(及 xunit 風格2
  3. unittest.TestCase 格式

關於上面三點的細項,等會兒再說明。先用一個過於簡單的結論來說:基本上存在於在各個 test_*.py / *_test.py 內,且名稱為 test_* 的函數就會入列。

我們一樣以一個很基本的函數來舉例:

# demo/foo.py
def add(a: int, b: int):
return a + b
# demo/test_foo.py
def test_add():
assert foo.add(1, 2) == 3

塔搭——寫完測試了(蛤?)

test_ 開頭的函數

查了下官方好像還真的沒有為這種風格取名 😂

承接前面所述,基本上 test_ 開頭為名的函數就能被 PyTest 抓到,然後執行。以上面的的案例來說,我們新增了一個 test_add 然後在裡面跑過 add、並且直接使用 assert 確認結果,這個測資的建立就完工了。

在這套框架下,我們所需要做的即是這個測試可以順暢地跑完、不要炸出任何 exception(或照設計炸出指定的 exception)、並確保有輸出我們預期想看到的結果。

相較於 unittest 下會需要透過 TestCaseassertXXX 等各成員協助列印偵錯訊息,PyTest 則使用了黑魔法來完成這項工作,稍後再來聊這塊。

Test 開頭的類別

很多時候,我們會有某些需求要將一堆測試放在一起,在 PyTest 框架下有個方案即是使用 Test 開頭的類別去做群組(但可惜不像 go test 那樣可以無限加開)。

以上面的函數為例,我們可以寫多個測試放在同個類別底下:

class TestAdd:
def test_1(self):
assert foo.add(1, 2) == 3
def test_2(self):
assert foo.add(3, 5) == 8

注意雖然已將放到 TestAdd 底下了,成員名稱依然必須以 test_ 做開頭。同樣地,我們可以用非 test_ 開頭的成員名稱來做一些額外的事情。

在使用這個方案的時候類別無需進行繼承,PyTest 會主動找到它並把它建立起來,如果你需要對這個類別做一些初始化之類的動作,請使用 xunit 的 setup_method 來處理。

xunit 風格2

PyTest 中提供了一個稱為 xunit-style set-up 的測試前後環境設置及復原功能。對應可作用於模組/類別/函數等級別使用前後做一些額外的動作。

而其中一個很常用到一組就是 setup_method / teardown_method,它跟 TestCase 中的 setUp / tearDown 幾乎可視為等價來使用:它們在每個 test_ 成員執行前後都被執行,所以可以代替 __init__ 當作類別的 constructor 來使用並把某些常用的資料等先設為屬性方便調用:

class TestAdd:
def setup_method(self):
self.a = 1
def test_1(self):
assert foo.add(self.a, 2) == 3

TestCase 多做的一步是它們有一個可選參數 method 可以用來檢測現在是要執行哪個測試,理論上可以用於對每個不同的測試做客製化,但實際上請不要(程式碼不好讀),此類的需求通常能用 fixture 達成。

unittest.TestCase 格式

PyTest 依然相容 Python 內建的 TestCase 物件,所以符合規範的 TestCase 測試們可以無痛的被 PyTest 找到並執行,也會部分的被 PyTest 的功能去增益到。

個人體驗上它們的相容做得很好(或 PyTest 的容錯做得很好),過去自己搞不清楚這兩套框架差異的狀況下我曾經混用了不少的寫法,但鮮少發生問題。

偵錯

測試就是寫來炸的,所以炸了之後怎麼偵錯就是很重要的一環。

前面說道,PyTest 則使用了黑魔法來協助列印偵錯訊息,我們可以來把上面的測試改爆試試看:

class TestAdd:
def setup_method(self):
self.a = 1
def test_1(self):
assert foo.add(self.a, 2) == 7 # 1+2=7

然後 pytest 敲下去給它炸:

============================= test session starts =============================
platform darwin -- Python 3.10.6, pytest-7.1.2, pluggy-1.0.0
rootdir: REDACTED
collected 4 items

test/test_foo.py F...                                                   [100%]

================================== FAILURES ===================================
_______________________________ TestAdd.test_1 ________________________________

self = <test.test_foo.TestAdd object at 0x104622860>

    def test_1(self):
>       assert foo.add(self.a, 2) == 7
E       assert 3 == 7
E        +  where 3 = <function add at 0x104635bd0>(1, 2)
E        +    where <function add at 0x104635bd0> = foo.add
E        +    and   1 = <test.test_foo.TestAdd object at 0x104622860>.a

test/test_foo.py:47: AssertionError
=========================== short test summary info ===========================
FAILED test/test_foo.py::TestAdd::test_1 - assert 3 == 7
========================= 1 failed, 3 passed in 0.02s =========================

然後我們就可以看到這個黑魔法中它(儘量)嘗試去印出這這段程式是怎麼炸掉的,也會嘗試去解釋這裡的每個參數是怎麼來的。

別忘了我們只用了 assert,那個本來只有一個空白 AssertionError 語法。

在有多個測試錯誤的情況下,最下面那段 short test summary info 會一一列出每一個測是最後炸掉的時候的 error message 可以快速撇過,也是很方便。

另外,當這段程式碼本身有輸出 stdout、stderr 或 logging 的時候,框架也會在錯誤的時候輸出捕捉到的內容3,而如果測試成功預設則自動隱藏。

參數化的測試

有些時候,我們會想對一個函數輸入不同的參數來進行測試,而這時候重複的建立 test_n 看起來就有夠笨。這類的需求在 PyTest 中則有 parametrize 來解決。

使用方式為使用 decorator pytest.mark.parametrize 並提供兩個參數:變數名稱及測資表。測資表內每個 row 的物件數量必須跟變數量相同,框架本身則會依照名稱填入;所以在 parametrize 中的變數順序無須跟測試本身的參數順序相同,但測資表內的順序就需要自己注意了。

class TestAdd:
@pytest.mark.parametrize(
("a", "b", "c"),
[
(1, 2, 3),
(3, 5, 8),
(5, 8, 13),
(8, 13, 21),
(13, 21, 34),
(21, 34, 55),
(34, 55, 89),
(55, 89, 144),
(1, 1, 3),
],
)
def test(self, a, b, c):
assert foo.add(a, b) == c

一個使用 parametrize 的範例如上,裏面故意留了一個錯誤的 $1+1=3$,然後以下炸給大家看:

============================= test session starts =============================
platform darwin -- Python 3.10.6, pytest-7.1.2, pluggy-1.0.0
rootdir: REDACTED
collected 13 items

test/test_foo.py ..........F..                                          [100%]

================================== FAILURES ===================================
_____________________________ TestAdd.test[1-1-3] _____________________________

self = <test.test_foo.TestAdd object at 0x103955cf0>, a = 1, b = 1, c = 3

    @pytest.mark.parametrize(
        ("a", "b", "c"),
        [
            (1, 2, 3),
            (3, 5, 8),
            (5, 8, 13),
            (8, 13, 21),
            (13, 21, 34),
            (21, 34, 55),
            (34, 55, 89),
            (55, 89, 144),
            (1, 1, 3),
        ],
    )
    def test(self, a, b, c):
>       assert foo.add(a, b) == c
E       assert 2 == 3
E        +  where 2 = <function add at 0x1039bdcf0>(1, 1)
E        +    where <function add at 0x1039bdcf0> = foo.add

test/test_foo.py:68: AssertionError
=========================== short test summary info ===========================
FAILED test/test_foo.py::TestAdd::test[1-1-3] - assert 2 == 3
======================== 1 failed, 12 passed in 0.02s =========================

在使用 parametrize 的時候框架會主動將測試展開成多個,所以可以看到 test/test_foo.py 後面跟著的 success case 變多了。另外再有測試壞掉的時候,框架也會說明是那一組輸入出錯。

不過這邊就可以發現一個 PyTest 的小缺陷,當我們的測資表變得愈來愈長、或是如果一個測資裡面就要包很多個參數的時候,程式碼可讀性問題會浮現,包括「第幾個參數是哪個輸入」或是「這行是在寫哪個參數」這種問題就會開始出現。

對於上面的問題,官方認為由於 parametrize 已經過度複雜,故不接受追加對 dict 或其他型別的支援,有這類需求的時候可斟酌使用 namedtuple 或是透過外掛如 parameterized 來解決。

小結

到這邊先大致帶過 PyTest 用起來大概長什麼樣子,文章有點長了,所以先收在這。

測試中還有一個一些很重要的元件,如 mock 及 fixture 到目前都還沒被提及,將在後續文章介紹。


續讀:

  1. 使用 unittest 一文有提及實際上主流的程式碼配置有兩種,但有鑒於 PyTest 官方在Good Integration Practices一文中建議了在專案外放置測試的方法,這邊就跟隨其建議了。

  2. 官方文件說明來看 xunit 風格這個名詞並不是用來指這種 Test 開頭的測試,但由於其中最常用到的莫過於 setup_method / teardown_method 這組跟 Test* 群組們共生的函數,在 Stackoverflow 上的部分提問也有名詞混用的狀況。

  3. log 的部分會依照 logging 設置的等級來輸出,都沒有設定的時候就照慣例是 WARNING 級,如果需要帶出 INFO/DEBUG 級資料可以使用 --log-level