# pytest [![RogelioKG/pytest](https://img.shields.io/badge/Sync%20with%20HackMD-grey?logo=markdown)](https://hackmd.io/@RogelioKG/pytest) ## References + 🔗 [**Documentation - Pytest**](https://docs.pytest.org/en/7.4.x/) + 🔗 [**拾遺 - Python 測試入門 - 開始使用 PyTest**](https://blog.tzing.tw/posts/python-testing-pytest-08de903a) + 🔗 [**CSDN - pytest.ini**](https://blog.csdn.net/Moonlight_16/article/details/122706934) + 🔗 [**IT 邦 - Python 與自動化測試的敲門磚**](https://ithelp.ithome.com.tw/users/20144024/ironman/5372) + 🔗 [**知乎 - Pytest fixture 及 conftest 详解**](https://zhuanlan.zhihu.com/p/564168267) + 🔗 [**Pytest With Eric - Ultimate Guide To Pytest Markers And Good Test Management**](https://pytest-with-eric.com/pytest-best-practices/pytest-markers/) + 🔗 [**Pytest With Eric - What Is pytest.ini And How To Save Time Using Pytest Config**](https://pytest-with-eric.com/pytest-best-practices/pytest-ini/) + 🎞️ [**ArjanCodes - How To Write Unit Tests For Existing Python Code // Part 1 of 2**](https://youtu.be/ULxMQ57engo?si=lhBSt6jt0NFBXTAS) + 🎞️ [**ArjanCodes - How To Write Unit Tests For Existing Python Code // Part 2 of 2**](https://youtu.be/NI5IGAim8XU?si=MxcKvp_wooG7H3oF) ## Note |📘 <span class="note">NOTE</span>| |:---| |1. 放置在 `test` / `tests` 目錄內| |2. 模組名稱為 `test_*.py`| |☢️ <span class="warning">WARNING</span>| |:---| |1. 測試目錄與被測試目錄皆要有 `__init__.py`| |2. 測試目錄路徑不可有<mark>**不合法名稱**</mark> (比如有<mark>**中括號**</mark> [] 的名稱,會被誤當成 .ini 的 section)。<br>否則會產生錯誤 `OSError: Starting path not found`。| |3. 測試目錄路徑若有任一<mark>**目錄改過名稱**</mark>,請務必<mark>**清除測試目錄底下的`__pycache__`**</mark>。<br>否則會產生錯誤 `OSError: Starting path not found`。| |4. TestClass 的第一個引數都要是 `self`,就如同在寫一般 class 一樣。| ## CLI + **選項** ```bash pytest ``` + `--markers` : 查看所有已註冊的 markers + `--fixtures` : 查看所有已註冊的 fixtures + `-h` : 參數說明 (help) + `-v` : 詳細結果 (verbose) + `-q` : 簡潔結果 (quiet) + `-s` : 允許 stdout / stderr 印出 (會打亂 pytest 測試結果輸出格式) + **測試範圍** + 全部測試 ```bash pytest ``` + 針對某個子目錄進行測試 ```bash pytest tests/end_to_end ``` + 針對某個模組進行測試 ```bash pytest tests/test_module.py ``` + 針對某個模組中某個函式進行測試 ```bash pytest tests/test_module.py::test_func ``` + 針對某個模組中某個類別進行測試 ```bash pytest tests/test_module.py::TestClass ``` + 針對某個模組中某個類別的某個方法進行測試 ```bash pytest tests/test_module.py::TestClass::test_method ``` + **CLI 測試結果** + <code><span style="color: #719926;">.</span></code> / <code><span style="color: #719926;">PASSED</span></code> : 代表一個通過的 test case + <code><span style="color: #aead2a;">s</span></code> / <code><span style="color: #aead2a;">SKIPPED</span></code> : 代表一個跳過的 test case + <code><span style="color: #aead2a;">x</span></code> / <code><span style="color: #aead2a;">XFAILED</span></code> : 代表一個已預期失敗 (等待修復) 的 test case + <code><span style="color: #d3618b;">F</span></code> / <code><span style="color: #d3618b;">FAILED</span></code> : 代表一個失敗的 test case + `[80%]` : 代表的是總完成進度 (百分比) ## Config ### `.vscode/settings.json` ```json { "python.testing.pytestEnabled": true, // 開啟 Pytest "python.testing.cwd": "${workspaceFolder}/tests" // test case 目錄 } ``` ![vscode-testing](https://code.visualstudio.com/assets/docs/python/testing/test-explorer.png) ### `pytest.ini` |📗 <span class="tip">TIP</span>| |:---| |如果你使用 `pyproject.toml`,只要將以下設定都置於 `[tool.pytest.ini_options]` 即可| ```ini [pytest] # 指定測試時的最小 pytest 版本要求 minversion = 6.0 # 設定 pytest 預設的測試參數 # -q: 安靜模式,減少輸出資訊 # --disable-warnings: 禁用警告顯示 addopts = -q --disable-warnings # 指定測試的搜尋路徑,預設 pytest 會搜尋 tests/ 目錄 # 這樣可以避免 pytest 搜索到不相關的測試文件 norecursedirs = .* build dist CVS _darcs testpaths = tests # 指定 pytest 應該辨識的測試檔案名稱模式 # 預設是 test_*.py 和 *_test.py python_files = test_*.py *_test.py # 指定 pytest 應該辨識的測試類別名稱模式 # 這樣只會執行以 "Test" 開頭的類別 python_classes = Test* # 指定 pytest 應該辨識的測試函數名稱模式 # 這樣只會執行以 "test_" 開頭的函數 python_functions = test_* # 設定測試輸出的格式 (例如 junit, html, xml 等) # 這裡設定為 JUnit 格式輸出到 reports/pytest_report.xml junit_family = xunit1 junit_logging = all junit_suite_name = pytest_suite junitxml = reports/pytest_report.xml # 設定 logging 相關選項 log_cli = true log_cli_level = INFO log_file = logs/pytest.log log_file_level = DEBUG log_format = %(asctime)s [%(levelname)s] %(message)s log_date_format = %Y-%m-%d %H:%M:%S # 自訂 marker,這些 marker 可用於區分測試 markers = slow: 執行較慢的測試 ; 說明文字 regression: 回歸測試 integration: 整合測試 ``` ## Global ### `conftest.py` + **說明** 1. 可創建 fixture,有效範圍:同目錄內所有 test case 可見,<mark>**無須 import**</mark>。 2. 自訂命令列選項 + **example** + `tests/conftest.py` ```py # 1. 同目錄內所有 test case 可見的 fixture @pytest.fixture(scope="function", name="test_data_lol") def test_data() -> dict[str, int]: data = {"a": 1, "b": 2, "c": 3} return data # 2. 自訂命令列選項 (一定要放在 conftest.py) def pytest_addoption(parser: pytest.Parser): """"自訂命令列選項""" parser.addoption( "--env", default="test", choices=["dev", "test", "pre"], help="enviroment parameter" ) ``` + `tests/test_something.py` ```py def update_a(data: dict[str, int], n: int) -> None: data["a"] += n # 1. 使用 conftest.py 的 fixture def test_update_a_plus_1(test_data_lol: dict[str, int]): update_a(test_data_lol, 1) assert test_data_lol["a"] == 2 # 2. 檢查自訂命令列選項 def test_option(pytestconfig: pytest.Config): print("the current environment is:", pytestconfig.getoption("env")) ``` ## Usage ### 斷言驗證 ```py def test_demo(): # 1 個 test case assert 1 + 1 == 2 ``` ### 錯誤驗證 `raises` |🚨 <span class="caution">CAUTION</span>| |:---| |每個 with 述句只能驗證一個錯誤| ```py def raise_error(): raise IndexError("list 的位置錯誤") def test_error(): # 將接收到的錯誤丟給一個名為 exc 的變數,該變數可於外部使用 with pytest.raises(IndexError) as exc: raise_error() # 錯誤 exc.value: IndexError print(exc.value) # list 的位置錯誤 # 錯誤類別名稱 exc.typename: str print(exc.typename) # IndexError ``` ### 資源初始化與釋放 `setup` / `teardown` + <mark>context management</mark>\ 缺點 : 無法接受引數 ```py def setup_module(): print("setup_module") def teardown_module(): print("teardown_module") def setup_function(): print("setup_function") def teardown_function(): print("teardown_function") def test_demo(): assert 1 + 1 == 2 def test_demo_2(): assert 2 + 2 == 4 # setup_module # setup_function # .teardown_function # setup_function # .teardown_function # teardown_module ``` ### 夾具 `fixture` + **decorator kwargs** + [<mark>`scope`</mark>](https://www.cnblogs.com/yoyoketang/p/9762197.html) : 變數生命週期的作用域,預設為 "function"\ (在每個 test case 裡都會重新 create 一遍) + `name` : 變數調用名稱,預設就是函式的名稱 + `autouse` : 是否在每個 test case 中自動使用,預設為 False\ (根據 scope 而定,如果 scope="function",那不需傳遞引數就會自動調用) + `params` : 參數化測試,應給定 list[dict[str, Any]] + **必要性** > Q : 為何使用 fixture,而不使用 global variable 就好?\ > global variable 寫起來不是更簡單嗎? > A : 請看下例,如果 fixture `test_data` 變成一般的 global variable,\ > 就會造成 `test_data` 通過第一個 test case 後,反而無法通過之後的 test case。\ > 很明顯,`test_data` 需要在每個 test case 執行前重新 create 一遍。\ > `fixture(scope="function")` 因而派上用場。 ```py # 功能 def update_a(data: dict[str, int], n: int) -> None: data["a"] += n # 測試 @pytest.fixture(scope="function", name="test_data_lol") def test_data() -> dict[str, int]: data = {"a": 1, "b": 2, "c": 3} return data def test_update_a_plus_1(test_data_lol: dict[str, int]): update_a(test_data_lol, 1) assert test_data_lol["a"] == 2 def test_update_a_times_5(test_data_lol: dict[str, int]): update_a(test_data_lol, 5) assert test_data_lol["a"] == 6 ``` + 參數 `autouse` scope="function" 的 autouse 會讓模組內的 test case 自動使用 clear_tables。\ 如果有非常多的 test cases,就不須每個都套上 decorator。 ```py # 每次 test case 後自動清除資料表 @pytest.fixture(scope="function", autouse=True) def clear_tables(test_session: Session) -> None: yield for _, table in Base.metadata.tables.items(): test_session.query(table).delete() test_session.commit() # 這邊就不需再使用 fixture 了 def test_create_user(test_user: User, test_session: Session): result = user_services.create_user(test_user, test_session) assert result.id is not None assert result.username == test_user.username assert result.birthday == test_user.birthday ``` ### 內建夾具 : 猴子補丁 `monkeypatch` + 輸入 mock ```py import pytest class CreditCard: def __init__(self, number: str, expiry_month: int, expiry_year: int): self.number = number self.expiry_month = expiry_month self.expiry_year = expiry_year @classmethod def read_card_info(cls) -> "CreditCard": number = input("Card Number: ") expiry_month = input("Expiry Month: ") expiry_year = input("Expiry Year: ") return cls(number, int(expiry_month), int(expiry_year)) def test_credit_card(monkeypatch: pytest.MonkeyPatch) -> None: # 信用卡號 / 到期月份 / 到期年分 inputs = ["1249190007575069", "12", "2024"] # 暫時魔改 builtins.input 函數 monkeypatch.setattr("builtins.input", lambda _: inputs.pop(0)) # read_card_info 會使用到 builtins.input 函數 (只要呼叫,我們就把假輸入餵給它) card = CreditCard.read_card_info() assert card.number == "1249190007575069" assert card.expiry_month == 12 assert card.expiry_year == 2024 ``` + 覆蓋環境變數 ```py import os import pytest def get_secret(): return os.getenv("SECRET_KEY", "default") def test_get_secret(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("SECRET_KEY", "super-secret") assert get_secret() == "super-secret" ``` ### 內建夾具 : 擷取輸出流 `capsys` + 輸出流 ```py import pytest def greet(): print("Hello, pytest!") def test_greet(capsys: pytest.CaptureFixture): greet() captured = capsys.readouterr() assert captured.out == "Hello, pytest!\n" greet() greet() captured = capsys.readouterr() assert captured.out == "Hello, pytest!\nHello, pytest!\n" ``` + 錯誤流 ```py import pytest import sys def error(): print("Error, pytest!", file=sys.stderr) def test_error(capsys: pytest.CaptureFixture): error() captured = capsys.readouterr() assert captured.err == "Error, pytest!\n" ``` ### 內建夾具 : 擷取日誌 `caplog` ```py import logging import pytest def log_warning(): logging.warning("This is a warning!") def test_log_warning(caplog: pytest.LogCaptureFixture): with caplog.at_level(logging.WARNING): log_warning() assert "This is a warning!" in caplog.text caplog.clear() with caplog.at_level(logging.WARNING): log_warning() assert ('root', 30, 'This is a warning!') in caplog.record_tuples ``` ### 內建夾具 : 暫時路徑 `tmp_path` |📘 <span class="note">NOTE</span>| |:---| |在 Windows 中,這些暫時目錄或檔案會放在 `%APPDATA%/Local/Temp/pytest-of-user` 中| ```py from pathlib import Path import pytest class FileHandler: def read_file(self, filename): with open(filename, 'r') as file: return file.read() def write_file(self, filename, content): with open(filename, 'w') as file: file.write(content) def test_file_handling(tmp_path: Path): # sub_dir = tmp_path / "tmp_dir" sub_dir.mkdir() file1 = sub_dir / "file1.txt" file2 = sub_dir / "file2.txt" handler = FileHandler() handler.write_file(file1, "Content of file 1") handler.write_file(file2, "Content of file 2") assert file1.exists() assert file2.exists() assert file1.read_text() == "Content of file 1" assert file2.read_text() == "Content of file 2" ``` ### 內建夾具 : 快取 `cache` ```py import time import pytest def expensive_calculation(): time.sleep(2) # 模擬一個耗時的計算 return 42 def test_expensive_calculation(cache: pytest.Cache): # 嘗試讀取快取資料 result = cache.get("expensive_result") # 如果快取資料不存在,進行計算並快取結果 if result is None: result = expensive_calculation() cache.set("expensive_result", result) assert result == 42 ``` ### 標記 `mark.?` |🚨 <span class="caution">CAUTION</span>| |:---| |要先在 `pytest.ini` 註冊,方可使用自訂標記| + **標記自訂 marker** ```py # in ./tests/test_something.py @pytest.mark.database def test_database_connection(): # 被標記 pass def test_case_a(): pass @pytest.mark.database class TestDatabaseCrud: def test_create(self): # 被標記 pass def test_read(self): # 被標記 pass def test_update(self): # 被標記 pass def test_delete(self): # 被標記 pass ``` + **只選擇有 mark.database 的 test case 進行測試** ```bash pytest -m database ``` ### 內建標記 : 條件跳過案例 `mark.skip` / `mark.skipif` + **decorator kwargs** + `condition` : 條件為真時跳過該 test case + `reason` : 跳過測試的原因 (在 verbose 模式會印出來) + **example** ```py # 測試 @pytest.mark.skip(reason="沒有理由但我就想跳過") def test_skip_test_case(): assert 1 + 1 == 3 @pytest.mark.skipif(condition=sys.platform == "win32", reason="因為是萬惡 Windows") def test_skip_test_case_by_condition(): assert 1 + 1 == 4 ``` ### 內建標記 : 已預期失敗案例 `mark.xfail` + **decorator kwargs** + `reason` : 跳過測試的原因 (在 verbose 模式會印出來) + **example** ```py @pytest.mark.xfail(reason="該測試案例目前會失敗,等待 BUG 修復") def test_example_xfail(): assert 2 * 3 == 7 ``` ### 內建標記 : 參數化測試 `mark.parametrize` |🚨 <span class="caution">CAUTION</span>| |:---| |可搭配 fixture 使用,但它一定要裝飾在最靠近函式的地方| + **decorator kwargs** + `argnames` : 參數名稱 + `argvalues` : 參數值 (可以有多組值) + `ids` : 每個 test case 的名稱 + **example** ```py # 算兩個 test case @pytest.mark.parametrize( argnames="num1, num2, result", argvalues=[(1, 1, 2), (2, 2, 4)] ) def test_add(num1: int, num2: int, result: int): assert num1 + num2 == result # tests/test_something.py::test_add[1-1-2] PASSED # tests/test_something.py::test_add[2-2-4] PASSED ``` + **ids** ```py test_args = [(1, 1, 2), (2, 2, 4)] ids = [f"case: {test_arg[0]} + {test_arg[1]} = {test_arg[2]}" for test_arg in test_args] # 算兩個 test case @pytest.mark.parametrize( argnames="num1, num2, result", argvalues=test_args, ids=ids ) def test_add_with_ids(num1: int, num2: int, result: int): assert num1 + num2 == result # tests/test_something.py::test_add_with_ids[case: 1 + 1 = 2] PASSED # tests/test_something.py::test_add_with_ids[case: 2 + 2 = 4] PASSED ``` ### 內建標記 : 使用夾具 `mark.usefixtures` 基本上功能與 fixture 傳遞引數寫法無異。\ 但通常用在欲使用 fixture 達成 <mark>context management</mark> 的場合,\ 而非使用它的回傳值做用途的場合。\ 上下文順序 : 由左至右、由下至上 + **context management : fixture + yield** ```py @pytest.fixture def func_1(): print("setup 1") yield print("teardown 1") @pytest.fixture def func_2(): print("setup 2") yield print("teardown 2") @pytest.fixture def func_3(): print("setup 3") yield print("teardown 3") # 注意上下文順序 @pytest.mark.usefixtures("func_3") @pytest.mark.usefixtures("func_1", "func_2") def test_func(): print("Hello!") # setup 1 # setup 2 # setup 3 # Hello! # .teardown 3 # teardown 2 # teardown 1 ``` + **為什麼不用 `setup` / `teardown` 就好?** 不像 setup / teardown 函數無法傳遞引數,\ 使用 fixture 做 context management 的好處是<mark>可以傳遞引數</mark>。\ 如下,傳遞的引數為 `test_session` ```py @pytest.fixture(scope="session") def test_session() -> Session: # 建立引擎 engine_url = "sqlite://" engine = create_engine(engine_url) # 建立資料表 Base.metadata.create_all(engine) # yield 會話 with sessionmaker(engine)() as session: yield session # 刪除資料表 Base.metadata.drop_all(engine) # 我希望每個 test case 結束後都清空所有資料表的資料 @pytest.fixture(scope="function") def clear_tables(test_session: Session) -> None: yield for _, table in Base.metadata.tables.items(): test_session.query(table).delete() test_session.commit() @pytest.mark.usefixtures("clear_tables") def test_create_user(test_user: User, test_session: Session): result = user_services.create_user(test_user, test_session) assert result.id is not None assert result.username == test_user.username assert result.birthday == test_user.birthday ``` # pytest-cov 插件:覆蓋率測試 ## References + 📑 [**Documentation - pytest-cov**](https://pytest-cov.readthedocs.io/en/latest/index.html) ## Note |🚨 <span class="caution">CAUTION</span>| |:---| |預設的覆蓋率計算方法是<mark>[行數覆蓋 (line coverage)](https://hackmd.io/@RogelioKG/testing#%E7%99%BD%E7%AE%B1%E6%B8%AC%E8%A9%A6-white-box-testing)</mark>| ## CLI + **選項** ```bash pytest --cov=src --cov-report=html:tests/report ``` + `--cov=<path>` : 只根據指定路徑,進行覆蓋率測試 (會產生一個 `.coverage` 檔) + `--cov-report=<type>:<path>` : 產生覆蓋率測試報告 (`<type>` 指定格式,如 `html` / `xml`;`<path>` 指定報告輸出目錄) + `--cov-config=<path>` : 覆蓋率測試的 config (預設是 `.coveragerc`) + `--cov-branch` : 將覆蓋率計算方法設為<mark>分支覆蓋 (branch coverage)</mark> + 快速打開覆蓋率測試 HTML 報告 (Windows) ```bat @echo off set "REPORT_PATH=%CD%\tests\report\index.html" start chrome %REPORT_PATH% ``` ## Config ### `.coveragerc` ```ini [run] # .coverage 路徑 (含檔案名稱) data_file = tests/.coverage # 覆蓋率測試要包含哪些檔案 include = ... # 覆蓋率測試要排除哪些檔案 omit = ... ``` ## Usage ### 內建夾具 : 不計入覆蓋率 `no_cover` 無效化某個 test case 所造成的覆蓋 ```py def test_deactivate_user(no_cover): user = User("testuser", "test@example.com") user.deactivate() assert not user.is_active() ``` # pytest-mock 插件:提供 [Stub、Mock、Spy](https://hackmd.io/@RogelioKG/testing#stub--mock--spy) 等功能,擴充自標準庫的 `unittest.mock` ## References + 📑 [**Documentation - pytest-mock**](https://pytest-mock.readthedocs.io/en/latest/index.html) ## Usage ### 內建夾具 : Mocker `mocker` + `mocekr.Mock` : 建立一個 mock 物件 + `return_value` : 被呼叫時的回傳值。 + `spec` + 給定 class,此 mock 物件只能使用給定 class 存在的屬性或方法,否則引發 `AttributeError`。 + 給定 list,此 mock 物件只能使用給定 list 中的屬性或方法,否則引發 `AttributeError`。 + `side_effect` + 給定 function,此 mock 物件被呼叫時,由此函數代為執行。 + 給定 exception,將引發錯誤 + 給定 iterable,將其中每個值作為每次呼叫 mock 物件的不同回傳值。 + `mocekr.MagicMock` : 建立一個 mock 物件 (已事先設定好 magic method),此類別繼承自 `Mock` + `mocker.patch` : 全域補丁一個函式或變數 ```py import os from pytest_mock import MockFixture class UnixFS: @staticmethod def rm(filename: str): os.remove(filename) def test_unix_fs(mocker: MockFixture): mock = mocker.patch("os.remove") UnixFS.rm("file") mock.assert_called_once_with("file") ``` + `mocker.patch.object` : 針對某物件補丁一個方法或屬性 ```py from pytest_mock import MockFixture class MyClass: def method(self): return "real method" def test_patch_object(mocker: MockFixture): instance1 = MyClass() # 這裡補丁的是實例的 __dict__ 內的函數 mock = mocker.patch.object(instance1, "method", return_value="mocked method") assert instance1.method() == "mocked method" instance2 = MyClass() assert instance2.method() == "real method" ``` + `mocker.patch.multiple` : 針對某物件補丁多個方法或屬性 ```py from pytest_mock import MockFixture class MyClass: def method1(self): return "real method1" def method2(self): return "real method2" def test_patch_multiple(mocker: MockFixture): instance = MyClass() # 類別自身也是一個物件,這裡補丁的是類別的 __dict__ 內的函數 mock = mocker.patch.multiple( MyClass, method1=lambda self: "mocked method1", method2=lambda self: "mocked method2", ) assert instance.method1() == "mocked method1" assert instance.method2() == "mocked method2" ``` + `mocker.patch.dict` : 特別針對字典補丁 (採用 update,常用於環境變數) ```py import os from pytest_mock import MockFixture os.environ["MY_ENV_VAR"] = "value" def test_patch_dict(mocker: MockFixture): mock = mocker.patch.dict("os.environ", {"MY_MOCK_ENV_VAR": "mocked_value"}) # 原本的環境變數並沒有不見 (若使用 mocker.patch 會讓整個字典直接被換掉) assert os.environ["MY_ENV_VAR"] == "value" assert os.environ["MY_MOCK_ENV_VAR"] == "mocked_value" ``` + `mocker.stop` : 停止一個 mock 物件的功能 ```py from pytest_mock import MockFixture class MyClass: def method(self): return "real method" def test_stop(mocker: MockFixture): instance = MyClass() # Mock method mock = mocker.patch.object(instance, "method", return_value="mocked method") assert instance.method() == "mocked method" # 🚩 被 mock 了 # 停止 mock,恢復原始行為 mocker.stop(mock) assert instance.method() == "real method" # ✅ 回到原本的方法 ``` + `mocker.stopall` : 停止所有 mock 物件的功能 ```py import os import pytest from pytest_mock import MockFixture os.environ["MY_ENV_VAR"] = "value" class MyClass: def method(self): return "real method" def test_stop_all(mocker: MockFixture): mock1 = mocker.patch.dict("os.environ", {"MY_MOCK_ENV_VAR": "mocked_value"}) mock2 = mocker.patch.object(MyClass, "method", return_value="mock method") instance = MyClass() assert os.environ["MY_ENV_VAR"] == "value" assert os.environ["MY_MOCK_ENV_VAR"] == "mocked_value" assert instance.method() == "mock method" mocker.stopall() with pytest.raises(KeyError) as err: os.environ["MY_MOCK_ENV_VAR"] assert err.typename == "KeyError" assert instance.method() == "real method" ``` + `mocker.spy` : spy 功能 (僅做監控不做替代,MUT 的原功能仍會執行) ```py from pytest_mock import MockFixture class Foo: def bar(self, v): return v * 2 def test_spy_method(mocker: MockFixture): foo = Foo() spy = mocker.spy(foo, 'bar') assert foo.bar(21) == 42 spy.assert_called_once_with(21) assert spy.spy_return == 42 ``` + `mocker.stub` : stub 功能 (常用於頂替 callback) ```py from typing import Callable from pytest_mock import MockerFixture class EventHandler: def __init__(self): self.callbacks: list[Callable[[str], None]] = [] # 存放所有 callback def register_callback(self, callback: Callable[[str], None]): """ 註冊 callback 到列表 """ self.callbacks.append(callback) def trigger_event(self, data: str): """ 觸發事件時,依序呼叫所有已註冊的 callback """ for callback in self.callbacks: callback(data) def test_event_handler_calls_all_callbacks(mocker: MockerFixture): handler = EventHandler() mock_callback_1 = mocker.stub("callback_1") mock_callback_2 = mocker.stub("callback_2") handler.register_callback(mock_callback_1) handler.register_callback(mock_callback_2) handler.trigger_event("test_event") # ✅ 確保所有 callback 都有被呼叫 mock_callback_1.assert_called_once_with("test_event") mock_callback_2.assert_called_once_with("test_event") ``` + `mocker.mock_open` : 模擬 `builtins.open` 的行為 (讓我們在測試時,不須去讀寫檔案) ```py def read_file(filepath: str) -> str: with open(filepath, "r", encoding="utf-8") as f: return f.read() def test_read_file(mocker): # 模擬 open() 回傳 'Hello, World!' 這個檔案內容 mock_open = mocker.mock_open(read_data="Hello, World!") mocker.patch("builtins.open", mock_open) # 測試函式是否正確讀取內容 result = read_file("fake_file.txt") assert result == "Hello, World!" # 確保 open() 被正確呼叫 mock_open.assert_called_once_with("fake_file.txt", "r", encoding="utf-8") ``` + mock 物件 + 每種 `mocker.?` 都會回傳一個 mock 物件 + 你可以藉由這個 mock 物件,進行監控、停止功能等操作。 + 方法 | method | description | |------|------| | `mock.assert_called()` | 確保 mock 物件至少被呼叫一次 | | `mock.assert_called_once()` | 確保 mock 物件只被呼叫一次 | | `mock.assert_called_with(*args, **kwargs)` | 確保 mock 物件最後一次呼叫的參數符合 | | `mock.assert_called_once_with(*args, **kwargs)` | 確保 mock 物件只被呼叫一次,且參數完全匹配 | | `mock.assert_any_call(*args, **kwargs)` | 確保 mock 物件至少有一次以該參數呼叫 | | `mock.assert_has_calls([call(*args, **kwargs), call(*args, **kwargs), ...], any_order=False)` | 確保 mock 物件有一組特定的呼叫序列,可選擇順序是否無關。記得 `from unittest.mock import call`! | | `mock.assert_not_called()` | 確保 mock 物件從未被呼叫過 | | `mock.assert_call_count(n)` | 確保 mock 物件被呼叫 `n` 次 | ## Note ### dependency injection ```py # src/payment.py class PaymentGateway: def process_payment(self, amount): # 模擬實際的付款邏輯,例如呼叫第三方 API return f"Processed payment of {amount}" ``` ```py # src/order.py from src.payment import PaymentGateway class OrderService: def __init__(self, payment_gateway: PaymentGateway): self.payment_gateway = payment_gateway def create_order(self, amount): return self.payment_gateway.process_payment(amount) ``` ```py # tests/test_order.py from pytest_mock import MockerFixture from src.order import OrderService def test_order_service_with_dependency_injection(mocker: MockerFixture): # 創建一個模擬的 PaymentGateway mock_payment_gateway = mocker.MagicMock() # 設定 mock 的行為 mock_payment_gateway.process_payment = mocker.Mock(return_value="Mocked payment processed") # 注入 mock 到 OrderService order_service = OrderService(mock_payment_gateway) # 調用服務方法 result = order_service.create_order(100) # 驗證 mock_payment_gateway.process_payment.assert_called_once_with(100) assert result == "Mocked payment processed" ``` ### non-dependency injection + case 1 : MUT 使用到了外部依賴 ```py # src/payment.py class PaymentGateway: def process_payment(self, amount): # 模擬實際的付款邏輯,例如呼叫第三方 API return f"Processed payment of {amount}" ``` ```py # src/order.py from src.payment import PaymentGateway class OrderService: def create_order(self, amount): payment_gateway = PaymentGateway() # 內部創建 PaymentGateway 實例 return payment_gateway.process_payment(amount) ``` ```py # tests/test_order.py from pytest_mock import MockerFixture from src.order import OrderService def test_order_service_with_non_dependency_injection(mocker: MockerFixture): # 使用 mocker.patch 來模擬 PaymentGateway 的方法 mock_process_payment = mocker.patch("src.order.PaymentGateway.process_payment", return_value="Mocked payment processed") # 創建 OrderService 並調用 create_order 方法 order_service = OrderService() result = order_service.create_order(100) # 驗證 mock_process_payment.assert_called_once_with(100) assert result == "Mocked payment processed" ``` |🚨 <span class="caution">CAUTION</span>| |:---| |當 SUT 採用 `from A import B` 使用外部依賴時,<br />`mocker.patch` 的路徑應填上 `"SUT_path.B"`,<br />否則會導致 SUT 沒有使用到你設定好的 mock 物件!| + case 2 : MUT 呼叫了 SUT 內的另一個方法 ```py class OrderService: def create_order(self, amount): if amount <= 0: raise ValueError("Amount must be greater than zero") return self._process_payment(amount) def _process_payment(self, amount): # 模擬實際的付款邏輯,例如呼叫第三方 API return f"Processed payment of {amount}" ``` ```py import pytest def test_create_order_with_mocked_internal_method(mocker): order_service = OrderService() # 使用 mocker.patch.object 來 mock _process_payment 方法 mock_process_payment = mocker.patch.object(order_service, "_process_payment", return_value="Mocked payment processed") # 執行帶測方法 result = order_service.create_order(100) # 驗證內部方法是否被呼叫,並且返回 mock 值 mock_process_payment.assert_called_once_with(100) assert result == "Mocked payment processed" ```