Python 測試入門 - 使用 unittest

Python 測試入門系列之一,簡述內建 unittest 框架的使用方式及限制

當我們要著手開始寫測試,那麼第一個想到的大概就會是 Python 內建的 unittest 框架。

這篇文章就來聊聊內建的這個單元測試框架怎麼使用、大概能提供哪些功能。

配置

首先帶到程式碼放置的問題,在希望可以盡可能減少工作量的條件下(後面會提及)、我們會需要把程式碼照一定的規則放置。

在一般的使用情境下,有兩種配置方式是常見的:

  1. 建立跟專案平行的 test 資料夾

    .
    ├── Readme.md
    ├── demo
    │   └── __init__.py
    └── test
        ├── __init__.py
        └── test_demo.py
  2. 在專案內建立 test 資料夾

    .
    └── demo
        ├── __init__.py
        └── test
            ├── __init__.py
            └── test___init__.py

首先會有我們的待測專案,以這裡而言即是 demo;再來就是測試腳本所放置的位置,其資料夾名稱必須為 test 不要變化。

而程式碼配置的部分兩個方法都算常見,不過確實第一種配置較多專案使用,這邊可以連結到 PyTest 文件 Good Integration Practices 在 Choosing a test layout 章節說道使用第一種配置可以有利於將測試腳本可以跟主程式抽開,並在之後如果在環境中執行過本地安裝( pip install .)後還可以進行測試。

以大型專案來說,採用第一種配置的專案有 django,採用第二種配置的專案則有 numpy,雖然他們都不是使用完全原生的 unittest 框架,但即使是用了 PyTest 配置邏輯依然跟這裡差不多。

另外,如果有發生更多層的檔案,通常我們會建議照著主專案的配置往下開更多的子資料夾:以第一種配置而言可能則是 test/submod/,第二種則會變成 demo/submod/test/

test 模組

在上面的結構裡面 test 底下是有出現 __init__.py 的,請注意將 test 模組化這件事情在使用 unittest 的時候是必要的。

檔案名稱

腳本只要置放在 test 模組內、名稱為 test*.py 基本上就能偵測到。

但一般而言我們會建議讓測試腳本的配置完全仿照主專案內的檔案名稱,即用 test/test_foo.py 去測試 demo/foo.py 裡面的函數、用 test/bar/test_qax.py 去測 demo/bar/qax.py 裡面的函數,這樣的配置模式會比較易於找出每段程式碼其對應的測試腳本在哪。

程式碼及測試腳本

接下來要開始寫程式了!

這裡以一個最簡單的函數為例:

# demo/__init__.py
def add(a: int, b: int):
return a + b

那麼我們可以生一個單元測試去確保 1 + 2 永遠為 3:

# test/test___init__.py
from unittest import TestCase
import demo
class TestAdd(TestCase):
def test_1(self):
self.assertEqual(demo.add(1, 2), 3)
def test_2(self):
self.assertEqual(demo.add(3, 5), 8)

基本的規則是:我們必須在 test*.py 內撰寫測試函數,其測試腳本必須要是一個繼承了 TestCase 的物件、然後成員名稱必須名為 test*

物件的名稱並不重要,只要你繼承了 TestCase 原則上就有被註冊到,再來則是成員名稱跟檔案名稱一樣必須有 test 這個前綴,實作上個人覺得這個條件挺方便的:

理想中我們對每個函數寫好它對應的輸入、輸出來做測試,務實上則是因為可能會有共用的參數,不想複製貼上那麼多次、想偷懶,那麼我就可以額外寫一個 do_something 然後在測試中呼叫,此時由於 do_something 本身並不合乎測試腳本的命名規範,所以不用額外去做處理讓他排除在測試以外;不過這類的行為在更多時候可以藉由 setup 達成,下面 Test fixture 的段落說明。

補充一下,TestCase 自帶了很多 assertXXX 的成員可以使用,如果在使用 unittest 的狀況下十分推薦多多利用!後面說明細節。

跑起來!

到這裡已經有個測試的雛形了,那麼先來跑個一輪試試看——

python -m unittest test/test___init__.py
..
----------------------------------------------------------------------
Ran 2 test in 0.000s

OK

最基本的呼叫就是直接叫出 unittest 的 main,然後給他要測試的腳本的路徑。

但多個檔案總不好意思排參數排到天荒地老、或是又開始寫 Makefile。這邊就會需要上面提及的標準配置——test 模組。

unittest 下有個 Test Discovery 功能,它會依照給定的參數去尋找到測資們、然後開跑,而最懶人的模式就是放在 test 模組裡面的 test*.py 們,這樣可以不用給參數也能跑:

python -m unittest discover

discover 是這裡的 magic word 用於觸發 Test discovery,跑這段指令預期要跟前面一樣可以找到 test/test___init__.py 並且跑過 TestAdd 下的兩個測資。

assertEqual 是做什麼用的?

我們可以在 TestAdd 中加入一個肯定會壞掉的條件來看看會發生什麼事:

def test_3(self):
self.assertEqual(demo.add(3, 5), 7)
..F
======================================================================
FAIL: test_3 (test.test___init__.TestAdd)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/tim_shih/Repos/pytest-workshop/test/test___init__.py", line 14, in test_3
    self.assertEqual(demo.add(3, 5), 7)
AssertionError: 8 != 7

----------------------------------------------------------------------
Ran 3 tests in 0.000s

FAILED (failures=1)

在有使用 assertXXX 系列成員的時候會看到 error message 中嘗試提供了一部分的資訊可用於偵錯,相較於直接使用 assert 則會看到:

Traceback (most recent call last):
  File "/Users/tim_shih/Repos/pytest-workshop/test/test___init__.py", line 14, in test_3
    assert demo.add(3, 5) == 7
AssertionError

雖然 Traceback 還是讀得到,但基本上 error message 就沒了,在多數的狀況下偵錯的複雜性會高一些。

然後在估狗 "python test xxx stackoverflow" 的時候大概率還是會看到一堆 assert,這原因是因為太多人都在用 PyTest 了,而上面的這個問題在 PyTest 則會被解決。

Test fixture

Fixture 是指在做測試時會用到的各種資料或環境設置等,在 unittest 框架下我們除了可以用一些非 test 開頭的成員去做環境設置,我們也可以利用一些更原生的手段達到一樣的效果:setUptearDown

class TestEnviron(TestCase):
def setUp(self):
os.environ["SAMPLE"] = "foo"
def tearDown(self) -> None:
try:
del os.environ["SAMPLE"]
except KeyError:
...
def test_getenv(self):
self.assertEqual(os.getenv("SAMPLE"), "foo")
self.assertIsNone(os.getenv("NO_THIS_ENVVAR"))

setUp 會在每個測資被執行前執行過一次,試想像你有多個測資可能都會使用到某個物件、甚至對該物件做 in-place 的改動,那麼善用 setUp 就可以保證每次跑測試的時候都有乾淨的輸入可以使用。

tearDown 則是在每個測資結束的時候都會被執行,無論該測資執行的成敗,類似於 try... except 語句中的 finally,可用於清理環境,尤其好用於恢復會影響到全域的設定。

為啥不用 unittest

對我個人來說,unittest 算是個輕巧、基礎功能完整的測試框架。但也只有到基礎功能完整,使用過程中會有幾個稍微沒有那麼順暢的地方,如:

  • 測試結果的回報——當你有數十乃至數百個測資,那麼在畫面上依然是一堆小點,缺乏一些回饋讓使用者去知道進度到哪裡了、是不是卡住。
  • 參數化的測試——有些函數我們會想要用多組不同的參數輸入來進行測試,但 unittest 本身對這塊缺乏配套。
  • 相對嚴格的結構——我們需要一定的檔名規則、完整的模組化、適當的繼承、正確的成員名稱以讓一個測資被執行。

相對地,PyTest 在不少細節上提供的容錯及功能解決了上述、甚至更多的不方便。簡單地說——

PyTest 太香了,所以我個人比較喜歡 PyTest。 就這樣。


續讀:開始使用 PyTest