# 【學習筆記】Pytest 入門 ## Pytest 特點 - 自動偵測 test: 只要命名好測試用的檔案就可以自動幫我們測試 - 有豐富的 assertion introspection: 當一個測試失敗時,我們可以得到失敗的詳細原因 - 支持參數化測試 - 支持 fixture-based 測試 ## 測試的方式 我們將測試加入時,可以將檔案架構設定如下: ``` project/ ├── app/ │ ├── __init__.py │ └── main.py └── test/ ├── __init__.py └── test_main.py ``` 在 `project/` 下執行 ``` $ pytest ``` Pytest 會自動偵測名為 `tests` 的資料夾,並且執行開頭為 `test_` 的檔案,在這些檔案內執行以 `test_` 開頭的函數。 假設我們想要測試的函數如下: ```python= def add(num1, num2): return num1 + num2 def divide(num1, num2): return num1 / num2 ``` 測試的函數則可以寫成: ```python= def test_add(): assert add(1, 2) == 3 def test_divide(): assert divide(10, 5) == 2 ``` 針對 `divide()` 函數,可能有特殊情況是除數等於零,因此也可以測試函數是否有 raise 異常: ```python= def test_divide_by_zero(): with pytest.raises(ZeroDivisionError): divide(10, 0) ``` ## Class-based Test Pytest 也可以測試類別 (Class)。定義一個 `Circle` 類別如下: ```python= class Circle: def __init__(self, radius): self.radius = radius def area(self): return math.pi * self.radius ** 2 def perimeter(self): return 2 * math.pi * self.radius ``` 我們可以也將測試寫成一個類別,只要命名開頭為 `Test` ,pytest 就會自動偵測並執行。 將測試寫成類別,有兩個非常好用的函數,分別為: - `setup_method()`:在測試之前執行 - `teardown_method()`:在測試之後執行 如果我們在測試類別中定義了這兩個方法,pytest 會自動偵測到,並在每個測試方法執行前運行 `setup_method()`,此測試執行完後就會運行 `teardown_method()`,幫助我們建立測試資料和移除測試資料,可以針對**類別內**的測試方法進行生命週期管理。 測試類別可以寫成: ```python= class TestCircle: def setup_method(self, method): # method 為測試函數 self.circle = Circle(10) def teardown_method(self, method): del self.circle def test_area(self): assert self.circle.area() == math.pi * self.circle.radius ** 2 def test_perimeter(self): result = self.circle.perimeter() expectation = 2 * math.pi * self.circle.radius assert result == expectation ``` 執行 `pytest` 即可進行測試。 ## Fixtures Fixture 功能提供了靈活的環境管理方法,幫助我們準備測試環境和測試後進行資源的清理,也避免在每個測試函數中使用重複的程式碼。 假設要測試的程式碼如下: ```python= class Rectangle(): def __init__(self, length, width): self.length = length self.width = width def area(self): return self.length * self.width def perimeter(self): return (self.length * 2) + (self.width * 2) ``` 如果我們想測試我們所寫的兩個方法,我們可以使用下列方式進行測試: ```python= def test_area(): rectangle = Rectangle(10, 20) assert rectangle.area() == 10 * 20 def test_perimeter(): rectangle = Rectangle(10, 20) assert rectangle.perimeter() == (10 * 2) + (20 * 2) ``` 可以發現在兩個測試函數中,我們都需要創造一個 rectangle 出來測試,等於我們重複做了相同的事情。 為了避免這樣的狀況,我們可以使用 pytest fixture: ```python= @pytest.fixture def rec(): return Rectangle(length=10, width=20) ``` 進行測試: ```python= def test_area(): assert rec.area() == 10 * 20 def test_perimeter(): assert rec.perimeter() == (10 * 2) + (20 * 2) ``` 在這裡,rec() 函數是一個 fixture,提供測試所需要的資源。 ### 共享 fixture 屬性 如果我們想讓多個 test files 共享 fixture,我們可以在這些 test files 共同所在的目錄位置下建立 `conftest.py`,並將 fixture 寫在這個檔案裡。 `conftest.py` 是一個固定的命名,當 pytest 執行測試時,它會自動尋找並處理在測試檔案目錄結構中發現的所有 `conftest.py` 檔案。 ## Parameterize Parametrize 允許我們以參數的形式提供測試函數多組輸入,不需要寫很多次 assert 或者寫迴圈,就可以使用多組數據對同一個測試函數進行測試。 定義我們要測試的程式碼: ```python= class Square(): def __init__(self, side_length): self.side_length = side_length def area(self): return side_length ** 2 ``` 可以利用 parametrize 創造多組測試數據: ```python= @pytest.mark.parameterize("side_length, expected_area", [(5, 25), (4, 16)]) def test_square_area(side_length, expected_area): assert Square(side_length).area() == expected_area ``` 若執行這個測試,會看到測試一共執行了 2 次,這是因為我們給了 2 組不同的測試數據。 ## Mocks 有時候欲測試的程式碼可能會**依賴**一些其他 API 或外部程式碼,但我們並不想在測試的時候將這些因素納入考慮,只想測試當前的程式碼和功能,因此我們可以建立替代的模擬物件。 假設我們想測試一個返回至前端的功能如下(位於 `main.py`): ```python= import requests def get_weather(city): response = requests.get(f"https://api.weather.com/v1/{city}") if response.status_code == 200: return response.json() else: raise ValueError("Could not fetch weather data") ``` 如果 weather api 連結故障時,會導致程式出現錯誤,然而這個錯誤並不是我們的程式碼所造成的,在測試程式碼時,我們不想要類似的問題影響程式碼執行,上面這個例子中,我們只想單純確認第 5-8 行是否正確執行。 測試可以寫成: ```python= from main import get_weather def test_get_weather(mocker): mock_get = mocker.patch("main.request.get") # mock requests.get # set return values mock_get.return_value.status_code = 200 mock_get.return_value.json.return_value = {"temp": 20, "condition": "Sunny"} result = get_weather("London") assert result == {"temp": 20, "condition": "Sunny"} mock_get.assert_called_once_with("https://api.weather.com/v1/London") ``` 在測試程式碼中,`mocker.patch` 暫時替換掉 `requests` 模組中的 `get` 函數,當 `get_weather ` 內部呼叫 `requests.get` 時,實際上呼叫的是這個模擬物件,而不是真正的請求。 `mock_get.assert_called_once_with(...)` 用來驗證模擬物件的行為,檢查 `requests.get` 是否在測試過程被呼叫過一次,且被呼叫時傳入的參數是否為正確,此時只會檢查參數正確性,而不會檢查 URL 是否正常。 ## Reference [1] https://www.youtube.com/watch?v=cHYq1MRoyI0 [2] https://www.youtube.com/watch?v=EgpLj86ZHFQ [3] https://bayareanotes.com/pytest-tutorial/