練習

已完成

在這個練習中,你會使用 pytest with @pytest.mark.parametrize 來測試函式,包括預期例外的情況。 接著,您會將類別型測試從 xUnit 樣式的 setup_method() 和 teardown_method() 方法重構為 fixture。 使用參數化和夾具能幫助你撰寫涵蓋更多案例、減少重複且設定更清晰的測試。

如需設定協助,請參閱 virtual environments 的 Python 文件、pip install 的 pip 文件,以及 installing pytest 的文件。 欲了解更多關於本練習中 pytest 功能的細節,請參閱 pytest 文件,內容包括 測試函式參數化、 預期例外的斷言、 夾具、 xUnit 風格設定,以及 暫存目錄與檔案。

開始之前

請依模組先修條件使用Python 3.10或更新版本。 如果你已經有一個安裝並啟用 pytest 的虛擬環境,請繼續步驟 1。 否則,請在你要建立 test_advanced.py的資料夾裡建立並啟用虛擬環境。

在 Windows PowerShell 上:

python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install pytest
python -m pytest --version

在 macOS、Linux 或 Windows 子系統 Linux 版 上:

python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install pytest
python -m pytest --version

version 指令應該會印出一個 pytest 版本,類似以下輸出:

pytest x.y.z

安裝指令不會把 pytest 釘選到特定版本。 Pip 使用套件中繼資料來選擇與虛擬環境的 Python 版本相容的穩定 pytest 版本。

在整個練習過程中使用 python -m pytest ,使 pytest 遠離活躍環境。 如果 Windows PowerShell 阻擋了 Activate.ps1,請參考 Python venv 文件中的 PowerShell 執行政策選項,或直接執行虛擬環境的 Python來執行 pip 和 pytest 指令。 例如:

.\.venv\Scripts\python -m pip install --upgrade pip
.\.venv\Scripts\python -m pip install pytest
.\.venv\Scripts\python -m pytest -v test_advanced.py

步驟 1 - 在此練習中新增一個測試檔案

  1. 在執行 pytest 的同一個資料夾裡,建立一個名為 test_advanced.py 的新測試檔。 不要把檔案放進 .venv 資料夾裡。 新增下列程式碼:

    import pytest
    
    
    def str_to_bool(string):
        normalized = string.lower()
        if normalized in ["yes", "y", "1"]:
            return True
        if normalized in ["no", "n", "0"]:
            return False
        raise ValueError(f"Cannot convert {string!r} to a boolean")
    

    函數 str_to_bool() 接受字串作為輸入。 對於已識別的真值,回傳True;對於已識別的假值,回傳False;對其他值則引發ValueError。

  2. 請在相同檔案中附加 str_to_bool() 函式的測試。 先用 @pytest.mark.parametrize 來測試真實數值:

    @pytest.mark.parametrize("string", ["Y", "y", "1", "YES"])
    def test_str_to_bool_true(string):
        assert str_to_bool(string) is True
    
  3. 接下來,附加另一個包含「false」值的測試:

    @pytest.mark.parametrize("string", ["N", "n", "0", "NO"])
    def test_str_to_bool_false(string):
        assert str_to_bool(string) is False
    

    現在有兩個測試函式,涵蓋 True 和 False 傳回值的代表性輸入。

  4. 最後,附加一個參數化測試,找出函數不應該接受的值。 將 pytest.raises() 用作上下文管理器,使測試僅在 str_to_bool() 引發預期例外時通過。

    @pytest.mark.parametrize("string", ["maybe", "2"])
    def test_str_to_bool_invalid(string):
        with pytest.raises(ValueError, match="Cannot convert"):
            str_to_bool(string)
    

    參數 match 會用正則表達式檢查異常訊息。 在這裡,測試只檢查訊息的穩定部分,因此不依賴於精確的無效值。

注意

為了簡化起見,這個練習會將被測程式碼和測試放在同一個檔案裡。 在實際的 Python 專案中,應用程式程式碼與測試通常會被拆分成不同的檔案和目錄,例如 src/ 套件與 tests/ 目錄。

步驟 2 - 執行測試和探索報告

加入測試後,執行 pytest 並檢查輸出。 使用提高詳細程度的旗標 (-v),讓您可以看到每個輸入值都被視為個別測試。

在 Windows PowerShell 上:

python -m pytest -v test_advanced.py

在 macOS、Linux 或 Windows 子系統 Linux 版 上:

python -m pytest -v test_advanced.py

結果應與以下報告相似:

============================= test session starts ==============================
platform ... -- Python 3.x.y, pytest-x.y.z, pluggy-x.y.z -- ...
rootdir: ...
...
collected 10 items

test_advanced.py::test_str_to_bool_true[Y] PASSED                        [ 10%]
test_advanced.py::test_str_to_bool_true[y] PASSED                        [ 20%]
test_advanced.py::test_str_to_bool_true[1] PASSED                        [ 30%]
test_advanced.py::test_str_to_bool_true[YES] PASSED                      [ 40%]
test_advanced.py::test_str_to_bool_false[N] PASSED                       [ 50%]
test_advanced.py::test_str_to_bool_false[n] PASSED                       [ 60%]
test_advanced.py::test_str_to_bool_false[0] PASSED                       [ 70%]
test_advanced.py::test_str_to_bool_false[NO] PASSED                      [ 80%]
test_advanced.py::test_str_to_bool_invalid[maybe] PASSED                 [ 90%]
test_advanced.py::test_str_to_bool_invalid[2] PASSED                     [100%]

============================== 10 passed in 0.01s ==============================

你的作業系統、Python 版本、pytest 版本、執行檔路徑、根目錄、插件和快取行、集合進度格式、行換行和執行時間可能不同。 雖然你只寫了三個測試函數,但 pytest 收集了 10 個測試,因為每個參數化輸入值都當作獨立的測試案例執行。

步驟 3 - 將 setup 和 teardown 重構為 fixture

  1. 在 test_advanced.py 檔案中附加一個基於類別的測試。 此測試使用 xUnit 風格 setup_method() 及 teardown_method() 方法,在每個測試方法周圍建立與移除檔案:

    from pathlib import Path
    
    
    class TestFile:
    
        def setup_method(self):
            self.path = Path("done")
            self.path.write_text("1", encoding="utf-8")
    
        def teardown_method(self):
            self.path.unlink(missing_ok=True)
    
        def test_done_file(self):
            contents = self.path.read_text(encoding="utf-8")
            assert contents == "1"
    

    此測試可通過,但在目前工作目錄中寫入固定檔名,且需獨立清理程式碼。 若測試執行中斷或同時執行,例如使用 pytest-xdist 或重疊的本地執行,此方法可能會留下過時的檔案、覆蓋已存在的名為 done 的檔案,或導致測試間相互影響。

  2. 新增一個模組層級的夾具,使用內建 tmp_path 夾具來建立暫存檔案。 在下一個步驟中,您會更新類別,以使用此 fixture 取代 setup 和 teardown 方法。 為了提高可讀性,請將此模組層級 fixture 放在已參數化的 str_to_bool 測試之後,並放在 TestFile 類別之前。 無論模組層級 fixture 出現在模組中的哪個位置,Pytest 都會探索到,因此這個位置是為了方便閱讀,而不是為了 pytest:

    @pytest.fixture
    def tmp_file(tmp_path):
        def write():
            path = tmp_path / "done"
            path.write_text("1", encoding="utf-8")
            return path
        return write
    

    該 tmp_file() 夾具使用 pytest 的 tmp_path 夾具,為每個測試函式調用(包括每個參數化案例)提供獨特的 pathlib.Path 暫存目錄。 Pytest 管理的是暫存目錄,因此測試不依賴硬編碼的檔案路徑。

  3. 刪除先前的 TestFile 類別,包括其中的 setup_method() 和 teardown_method() 方法,並以下列使用 fixture 取代 setup 和 teardown 方法的最終版本替換:

    class TestFile:
    
        def test_done_file(self, tmp_file):
            path = tmp_file()
            contents = path.read_text(encoding="utf-8")
            assert contents == "1"
    

    你也可以移除在前一步中新增的 from pathlib import Path 匯入,因為測試不再直接建立 Path 物件。

檢查您的工作

到現在你應該有一個名為 test_advanced.py 的 Python 檔案,裡面包含:

  • 接受字串並針對已辨識的 true 和 false 值傳回布林值的 str_to_bool() 函式。
  • 對str_to_bool()函數進行三個參數化測試:一個測試True值,一個測試False值,一個檢查無效值是否引發ValueError。
  • 自訂 pytest fixture,使用內建的 tmp_path 建立包含一些內容的暫存 done 檔案。
  • 一個 TestFile 類別,只有一個方法,使用自訂 tmp_file 夾具來建立和讀取檔案。
  • 最終版本沒有 from pathlib import Path 匯入,也沒有 setup_method()/teardown_method() 方法。

最終檔案應該看起來像以下這樣:

import pytest


def str_to_bool(string):
    normalized = string.lower()
    if normalized in ["yes", "y", "1"]:
        return True
    if normalized in ["no", "n", "0"]:
        return False
    raise ValueError(f"Cannot convert {string!r} to a boolean")


@pytest.mark.parametrize("string", ["Y", "y", "1", "YES"])
def test_str_to_bool_true(string):
    assert str_to_bool(string) is True


@pytest.mark.parametrize("string", ["N", "n", "0", "NO"])
def test_str_to_bool_false(string):
    assert str_to_bool(string) is False


@pytest.mark.parametrize("string", ["maybe", "2"])
def test_str_to_bool_invalid(string):
    with pytest.raises(ValueError, match="Cannot convert"):
        str_to_bool(string)


@pytest.fixture
def tmp_file(tmp_path):
    def write():
        path = tmp_path / "done"
        path.write_text("1", encoding="utf-8")
        return path
    return write


class TestFile:

    def test_done_file(self, tmp_file):
        path = tmp_file()
        contents = path.read_text(encoding="utf-8")
        assert contents == "1"

再跑一次測試檔。

在 Windows PowerShell 上:

python -m pytest -v test_advanced.py

在 macOS、Linux 或 Windows 子系統 Linux 版 上:

python -m pytest -v test_advanced.py

最終報告應顯示收集的11項檢測:10例參數化檢測及1項類別檢測。

============================= test session starts ==============================
platform ... -- Python 3.x.y, pytest-x.y.z, pluggy-x.y.z -- ...
rootdir: ...
...
collected 11 items

test_advanced.py::test_str_to_bool_true[Y] PASSED                        [  9%]
test_advanced.py::test_str_to_bool_true[y] PASSED                        [ 18%]
test_advanced.py::test_str_to_bool_true[1] PASSED                        [ 27%]
test_advanced.py::test_str_to_bool_true[YES] PASSED                      [ 36%]
test_advanced.py::test_str_to_bool_false[N] PASSED                       [ 45%]
test_advanced.py::test_str_to_bool_false[n] PASSED                       [ 54%]
test_advanced.py::test_str_to_bool_false[0] PASSED                       [ 63%]
test_advanced.py::test_str_to_bool_false[NO] PASSED                      [ 72%]
test_advanced.py::test_str_to_bool_invalid[maybe] PASSED                 [ 81%]
test_advanced.py::test_str_to_bool_invalid[2] PASSED                     [ 90%]
test_advanced.py::TestFile::test_done_file PASSED                        [100%]

============================== 11 passed in 0.01s ==============================

所有測試都應該無錯誤地通過。