# 【學習筆記】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/