Python Note 02

基於Pytest框架的自動化測試 (上)

在寫程式的時候,有時會不太確定自己的程式是不是按照自己想的一樣在執行,往往要針對個別案例進行手動的測試,而且還可能無法確定每一次測試的東西都是相同的;除此之外當遇到修改某功能的時候,有時會莫名地影響到了另一個功能,導致產出的結果不符合預期或是直接發生錯誤,這個時候就需要一個測試框架將測試的流程規範化,將每個測試寫成一個個的用例來驗證各個功能是否正確。

測試框架有很多種,主要的用處是幫助管理測試的用例、執行測試、收集測試結果和生成測試報告等等,將開發的注意力集中在測試的用例上,而不會浪費在原本可以避免的問題上。

核心功能

Pytest是一個用於Python程式語言的測試框架。它允許開發者撰寫簡潔而有效的測試,以確保他們的代碼正確運行。以下是一些pytest的主要特點和功能:

  1. 簡單易用、容易上手使用標準的斷言語句來檢查代碼的行為是否符合預期。
  2. 文件豐富,文件中有很多例項可以參考。
  3. 能夠支援簡單的單元測試和複雜的功能測試。
  4. 能夠自動發現和執行你的測試用例,只需按照特定的命名規則來命名測試文件和函數,pytest就能夠自動識別並運行它們。
  5. 允許參數化測試用例,這意味著您可以定義一個測試,為不同的輸入參數運行該測試。這有助於測試不同情景下的代碼行為。
  6. 支援各種插件,這些插件可以擴展pytest的功能,並添加額外的報告、測試選項和功能。
  7. 提供多種報告格式,包括文本報告、HTML報告和JUnit XML 報告等,方便您查看測試結果。
  8. 支持並行執行測試,這意味著測試套件中的多個測試用例可以同時運行,從而節省時間。
  9. 易於集成到持續集成和持續部署 (CI/CD) 流水線中。開發團隊可以使用Pytest來確保每次代碼更改都經過全面的測試。

Pytest安裝方式跟一般的套件安裝方式一樣,使用pip或是poetry都是可以的:

pip install pytest
poetry add pytest

進入到安裝了pytest的環境,可以用以下指令查看有甚麼參數可以設定:

pytest --help

使用案例

這裡簡單的介紹如何使用pytest的資料夾結構、基礎使用方法、產生報表,只針對簡單的數學計算寫測試案例,後續如果有時間會再開一篇針對後端、資料庫寫測試範例的紀錄。

資料夾結構

首先創建一個test資料夾,用作存放測試的用例方便管理。pytest預設會自動把目錄裡所有名稱為test_*.py的檔案全都測試一遍,所以用作測試的檔案要以test_開頭命名。

專案中常見的資料夾結構大致上分為兩種,一種是放在應用程式程式碼之外的測試,這種方案的好處很明顯,這些測試案例獨立於套件,簡化了測試的組織架構,可以將所有測試都集中在test資料夾內單獨測試,不需要考慮專案內的層次結構:

├─src
│  └─pkg
│     ├─__init__.py
│     ├─module_a.py
│     └─module_b.py
├─tests
│  ├─__init__.py
│  ├─test_module_a.py
│  └─test_module_b.py
└─setup.py

如果測試和你的專案密切相關,可以使將測試案例作為專案程式碼的一部分,這樣可以更好地組織程式碼,並且因為在同一個資料夾內便於維護:

├─src
│  ├─pkg
│  │  ├─__init__.py
│  │  ├─module_a.py
│  │  └─module_b.py
│  └─tests
│     ├─test_module_a.py
│     └─test_module_b.py
└─setup.py

只要團隊內有共識,無論選擇哪一種pytest都可以進行識別和測試,最重要的是確保測試的組織結構清晰且易於維護即可。

可以看到test內有__init__.py,這是為了將test視為一個python的package,使得pytest能夠在其中識別和執行測試文件,有助於更好地組織您的測試代碼,使其更易於管理和維護。

簡單範例

創建一個Python文件 calculator.py,其中包含要測試的函數:

# calculator.py

def add(a, b):
    return a + b

然後創建一個測試文件 test_calculator.py,其中包含測試用例:

# test_calculator.py

import calculator

def test_add_positive_numbers():
    assert calculator.add(2, 3) == 5

def test_add_negative_numbers():
    assert calculator.add(-2, -3) == -5

def test_add_mixed_numbers():
    assert calculator.add(5, -3) == 2

def test_add_zero():
    assert calculator.add(0, 0) == 0

在這個例子中,創建了四個測試用例,分別測試了 add 函數的不同情景:兩個正數相加、兩個負數相加、一個正數和一個負數相加,以及兩個零相加。切換到包含測試資料夾的路徑運行pytest,pytest會自動發現並運行 test_calculator.py 的測試用例,並且報告結果,如果一切正確應該會看到類似以下的輸出:

======================= test session starts =======================
platform win32 -- Python 3.10.10, pytest-7.4.2, pluggy-1.3.0        
rootdir: /path/to/your/tests
collected 4 items                                                   

tests\test_calculator.py ....                                [100%] 

======================== 4 passed in 0.02s ========================

這說明所有四個測試用例都成功通過,如果其中任何一個測試失敗,Pytest會報告相關的詳細信息,幫助您快速定位問題。這種測試方式有助於確保 add 函數的正確性。

Class範例

除了前面介紹的方式來使用pytest以外,還可以使用class的形式來組織測試,這稱為 "類級別的測試",這對於在多個測試之間共享設置或資源特別有用。以下是一個示例,展示了如何使用類進行測試以及相應的被測試代碼。

創建一個 test_cls_calculator.py 測試文件,使用類的形式進行測試:

# test_cls_calculator.py

import pytest
import calculator

class TestCalculator:
    def test_add_positive_numbers(self):
        result = calculator.add(2, 3)
        assert result == 5

    def test_add_negative_numbers(self):
        result = calculator.add(-2, -3)
        assert result == -5

    def test_add_mixed_numbers(self):
        result = calculator.add(5, -3)
        assert result == 2

    def test_add_zero(self):
        result = calculator.add(0, 0)
        assert result == 0

這種方法特別適用於複雜的測試場景,為不同測試方法之間共享設置和資源。

setup & teardown

setupteardown是常見用於測試用例的預設定和清理設定的概念,在執行測試用例之前進行一些環境設定、初始化等等,以及在測試執行後進行清理的工作。在pytest中提供了許多方法來實現這些設定和清理的操作,包括setupteardown和後續要介紹的Fixture

setupteardown不能直接使用,要在後面加上測試進行時的作用域,分別是以下這四種:

  1. function: 函數級別,在每次函數開始和結束時運行。
  2. method: 方法級別,class中使用,在類別內method被調用的開始和結束時運行。
  3. class: 類別級別,一樣在class中使用,在類別開始和結束時運行。
  4. module: 模組級別,在整個.py模組開始和結束時運行。

使用方式大致如下:

# test_calculator.py

from src.package import calculator


def setup_module():
    print()
    print("setup_module")


def teardown_module():
    print()
    print("teardown_module")


def setup_function():
    print()
    print("setup_function")


def teardown_function():
    print()
    print("teardown_function")


def test_add_positive_numbers():
    assert calculator.add(2, 3) == 5


def test_add_negative_numbers():
    assert calculator.add(-2, -3) == -5


def test_add_mixed_numbers():
    assert calculator.add(5, -3) == 2


def test_add_zero():
    assert calculator.add(0, 0) == 0

如果是class的話則是:

# test_cls_calculator.py
import pytest

from src.package import calculator


def setup_module():
    print()
    print("setup_module")


def teardown_module():
    print()
    print("teardown_module")


class TestCalculator:
    def setup_class(self):
        print()
        print("setup_class")

    def teardown_class(self):
        print()
        print("teardown_class")

    def setup_method(self):
        print()
        print("setup_method")

    def teardown_method(self):
        print()
        print("teardown_method")

    def test_add_positive_numbers(self):
        result = calculator.add(2, 3)
        assert result == 5

    def test_add_negative_numbers(self):
        result = calculator.add(-2, -3)
        assert result == -5

    def test_add_mixed_numbers(self):
        result = calculator.add(5, -3)
        assert result == 2

    def test_add_zero(self):
        result = calculator.add(0, 0)
        assert result == 0


if __name__ == "__main__":
    pytest.main()

結果如下,每一個點代表的是一個通過的測試:

======================= test session starts =======================
platform win32 -- Python 3.10.10, pytest-7.4.2, pluggy-1.3.0        
rootdir: /path/to/your/tests
collected 8 items

tests\test_calculator.py
setup_module

setup_function
.
teardown_function

setup_function
.
teardown_function

setup_function
.
teardown_function

setup_function
.
teardown_function

teardown_module

tests\test_cls_calculator.py
setup_module

setup_class

setup_method
.
teardown_method

setup_method
.
teardown_method

setup_method
.
teardown_method

setup_method
.
teardown_method

teardown_class

teardown_module


======================== 8 passed in 0.06s ======================== 

如果要在命令列看到定義的setupteardown中所print的內容,在測試的時候記得在pytest後面加上-s才能看到setupteardown print出來的資訊。

參數化

當需要對同一組函數測試多組測試案例時,可以透過pytest的@pytest.mark.parametrize這個裝飾器來設定,可以設定多組的輸入參數和預期輸出,pytest會自動運行測試用例的多個運行實例,每個實例使用不同的輸入參數。

在下面的程式碼可以看到透過讀取json檔案獲取輸入參數和預期輸出,設定argnamesargvalues來指定變數名稱跟輸入變數,然後可以指定每組的測試名稱ids來區分不同的測試實例,最後定義出測試函數:

# test_calculator.py

import json

import pytest

from src.package import calculator

with open("tests/test_calculator/test_calculator_args.json", "r", encoding="utf8") as file:
    calculator_test_case = json.loads(file.read())["calculator_test_case"]


ids = [f"case: {idx}" for idx in range(1, len(calculator_test_case) + 1)]


@pytest.mark.parametrize(
    argnames="input_a, input_b, expected_output", argvalues=calculator_test_case, ids=ids
)
def test_add_positive_numbers(input_a: int, input_b: int, expected_output: int):
    assert calculator.add(input_a, input_b) == expected_output

下方是測試資料test_calculator_args.json的內容:

{
    "calculator_test_case": [
        [2, 3, 5],
        [-2, -3, -5],
        [5, -3, 2],
        [0, 0, 0]
    ]
}

最後pytest的輸出結果,可以看到每筆測試資料都對應一個測試實例,名稱ids也顯示在結果上:

===================================== test session starts =====================================
platform win32 -- Python 3.10.10, pytest-7.4.2, pluggy-1.3.0        
rootdir: /path/to/your/tests
collected 4 items                                                                               

tests/test_calculator/test_param_calculator.py::test_add_positive_numbers[case: 1] PASSED [ 25%]
tests/test_calculator/test_param_calculator.py::test_add_positive_numbers[case: 2] PASSED [ 50%]
tests/test_calculator/test_param_calculator.py::test_add_positive_numbers[case: 3] PASSED [ 75%]
tests/test_calculator/test_param_calculator.py::test_add_positive_numbers[case: 4] PASSED [100%]
====================================== 4 passed in 0.03s ======================================

驗證錯誤

在專案中有時會需要設計一些預期內的錯誤,像是無效的輸入、邊界條件或是不合理的操作等等,這些就是合理、預期內的錯誤,pytest有驗證錯誤的功能來確保應用程式在遇到錯誤時不會崩潰或產生不符預期的結果。

建立一個測試用例,用pytest.raises捕獲ValueError確保引發了預期的錯誤:

# test_error.py

import pytest


def test_value_error():
    with pytest.raises(ValueError):
        raise ValueError("This is a test error")

除了驗證錯誤以外,pytest還可以檢查錯誤訊息是否符合預期:

def test_value_error_msg():
    with pytest.raises(ValueError) as exc_info:
        raise ValueError("This is a test error")

    assert str(exc_info.value) == "This is a test error"

    assert exc_info.typename == ValueError.__name__

跳過測試

在軟體開發中,有時候可能需要跳過測試,如尚未實現的功能、臨時性的問題、特定平台或是環境、待解決的問題等等,碰到這些情況pytest提供了跳過的功能skipskipif,如下:

# test_skip.py

import sys

import pytest


@pytest.mark.skip(reason="This test is not implemented yet")
def test_unimplemented_feature():
    # 尚未實作該測試,先跳過
    pass


@pytest.mark.skipif(condition=sys.platform == "win32", reason="Conditionally skipped test")
def test_conditionally_skipped_feature():
    # 當系統為win32時跳過
    pass

要注意雖然跳過測試在某些情況下是合理的,但不應該成為標準作法,應該致力於解決跳過測試的原因,並確保測試套件是完整的,可以正確的驗證應用程序的功能,否則長期跳過測試可能會導致問題累積並降低程式碼的可靠性。

常用指令

這裡介紹幾個在測試時常用到的指令:

  1. pytest -v: 顯示詳細的輸出,包含每個測試的名稱和結果。
  2. pytest -s: 在運行測試時顯示標準輸出 (stdout),通常僅在調試或檢查測試輸出時使用,不然可能會降低測試報告的可讀性。
  3. pytest -r: 用於指定測試報告的顯示級別,-r f顯示失敗的測試 (簡單報告);-r a顯示所有測試 (常規報告);-r A顯示每個測試 (詳細報告)。
  4. pytest -k: 選擇性地運行包含特定關鍵字的函數或方法,-k test_func_name根據測試名稱;-k my_keyword根據關鍵字;-k keyword1 or keyword2關鍵字表達式;-k "not slow"排除包含slow關鍵字的測試。

結論

前面介紹pytest的使用方法基本上已經能完整的測試一整個專案了,下一篇會再介紹pytest其他強大的功能,諸如fixture、conftest、coverage、產生報表等等。

推薦參考資料

pytest: helps you write better programs — pytest documentation
Crafting High Quality Unit Tests: Tips and Best Practices
Unlock the secrets of high-quality unit testing. Discover how to write effective tests and improve your code quality.