Try   HackMD

pytest

References

Note

📘 NOTE
1. 放置在 test / tests 目錄內
2. 模組名稱為 test_*.py
☢️ WARNING
1. 測試目錄與被測試目錄皆要有 __init__.py
2. 測試目錄路徑不可有不合法名稱 (比如有中括號 [] 的名稱,會被誤當成 .ini 的 section)。
否則會產生錯誤 OSError: Starting path not found
3. 測試目錄路徑若有任一目錄改過名稱,請務必清除測試目錄底下的__pycache__
否則會產生錯誤 OSError: Starting path not found
4. TestClass 的第一個引數都要是 self,就如同在寫一般 class 一樣。

CLI

  • 選項

    ​​pytest
    
    • --markers : 查看所有已註冊的 markers
    • --fixtures : 查看所有已註冊的 fixtures
    • -h : 參數說明 (help)
    • -v : 詳細結果 (verbose)
    • -q : 簡潔結果 (quiet)
    • -s : 允許 stdout / stderr 印出 (會打亂 pytest 測試結果輸出格式)
  • 測試範圍

    • 全部測試

      ​​​​pytest
      
    • 針對某個子目錄進行測試

      ​​​​pytest tests/end_to_end
      
    • 針對某個模組進行測試

      ​​​​pytest tests/test_module.py
      
    • 針對某個模組中某個函式進行測試

      ​​​​pytest tests/test_module.py::test_func
      
    • 針對某個模組中某個類別進行測試

      ​​​​pytest tests/test_module.py::TestClass
      
    • 針對某個模組中某個類別的某個方法進行測試

      ​​​​pytest tests/test_module.py::TestClass::test_method
      
  • CLI 測試結果

    • . / PASSED : 代表一個通過的 test case
    • s / SKIPPED : 代表一個跳過的 test case
    • x / XFAILED : 代表一個已預期失敗 (等待修復) 的 test case
    • F / FAILED : 代表一個失敗的 test case
    • [80%] : 代表的是總完成進度 (百分比)

Config

.vscode/settings.json

{
  "python.testing.pytestEnabled": true, // 開啟 Pytest
  "python.testing.cwd": "${workspaceFolder}/tests" // test case 目錄
}

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

pytest.ini

📗 TIP
如果你使用 pyproject.toml,只要將以下設定都置於 [tool.pytest.ini_options] 即可
[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 可見,無須 import
    2. 自訂命令列選項
  • example

    • tests/conftest.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

      ​​​​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

斷言驗證

​​def test_demo():
​​    # 1 個 test case
​​    assert 1 + 1 == 2

錯誤驗證 raises

🚨 CAUTION
每個 with 述句只能驗證一個錯誤
​​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

  • context management
    缺點 : 無法接受引數
    ​​​​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

    • scope : 變數生命週期的作用域,預設為 "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") 因而派上用場。

    ​​​​# 功能
    ​​​​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。

    ​​# 每次 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
    ​​​​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
    
  • 覆蓋環境變數
    ​​​​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

  • 輸出流

    ​​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"
    
  • 錯誤流

    ​​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

​​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

📘 NOTE
在 Windows 中,這些暫時目錄或檔案會放在 %APPDATA%/Local/Temp/pytest-of-user
​​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

​​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.?

🚨 CAUTION
要先在 pytest.ini 註冊,方可使用自訂標記
  • 標記自訂 marker

    ​​​​# 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 進行測試

    ​​​​pytest -m database
    

內建標記 : 條件跳過案例 mark.skip / mark.skipif

  • decorator kwargs

    • condition : 條件為真時跳過該 test case
    • reason : 跳過測試的原因 (在 verbose 模式會印出來)
  • example

    ​​​​# 測試
    ​​​​@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

    ​​​​@pytest.mark.xfail(reason="該測試案例目前會失敗,等待 BUG 修復")
    ​​​​def test_example_xfail():
    ​​​​    assert 2 * 3 == 7
    

內建標記 : 參數化測試 mark.parametrize

🚨 CAUTION
可搭配 fixture 使用,但它一定要裝飾在最靠近函式的地方
  • decorator kwargs

    • argnames : 參數名稱
    • argvalues : 參數值 (可以有多組值)
    • ids : 每個 test case 的名稱
  • example

    ​​​​# 算兩個 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

    ​​​​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 達成 context management 的場合,
而非使用它的回傳值做用途的場合。
上下文順序 : 由左至右、由下至上

  • context management : fixture + yield

    ​​​​@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 的好處是可以傳遞引數
    如下,傳遞的引數為 test_session

    ​​​​@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

Note

🚨 CAUTION
預設的覆蓋率計算方法是行數覆蓋 (line coverage)

CLI

  • 選項

    ​​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 : 將覆蓋率計算方法設為分支覆蓋 (branch coverage)
  • 快速打開覆蓋率測試 HTML 報告 (Windows)

    ​​@echo off
    ​​set "REPORT_PATH=%CD%\tests\report\index.html"
    ​​start chrome %REPORT_PATH%
    

Config

.coveragerc

​​[run]
​​# .coverage 路徑 (含檔案名稱)
​​data_file = tests/.coverage
​​# 覆蓋率測試要包含哪些檔案
​​include = ...
​​# 覆蓋率測試要排除哪些檔案
​​omit = ...

Usage

內建夾具 : 不計入覆蓋率 no_cover

無效化某個 test case 所造成的覆蓋

def test_deactivate_user(no_cover):
    user = User("testuser", "test@example.com")
    user.deactivate()
    assert not user.is_active()

pytest-mock

插件:提供 Stub、Mock、Spy 等功能,擴充自標準庫的 unittest.mock

References

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 : 全域補丁一個函式或變數

    ​​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 : 針對某物件補丁一個方法或屬性

    ​​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 : 針對某物件補丁多個方法或屬性

    ​​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,常用於環境變數)

    ​​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 物件的功能

    ​​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 物件的功能

    ​​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 的原功能仍會執行)

    ​​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)

    ​​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 的行為 (讓我們在測試時,不須去讀寫檔案)

    ​​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

# src/payment.py

class PaymentGateway:
    def process_payment(self, amount):
        # 模擬實際的付款邏輯,例如呼叫第三方 API
        return f"Processed payment of {amount}"
# 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)
# 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 使用到了外部依賴

    ​​# src/payment.py
    
    ​​class PaymentGateway:
    ​​    def process_payment(self, amount):
    ​​        # 模擬實際的付款邏輯,例如呼叫第三方 API
    ​​        return f"Processed payment of {amount}"
    
    ​​# 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)
    
    ​​# 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"
    
    🚨 CAUTION
    當 SUT 採用 from A import B 使用外部依賴時,
    mocker.patch 的路徑應填上 "SUT_path.B"
    否則會導致 SUT 沒有使用到你設定好的 mock 物件!
  • case 2 : MUT 呼叫了 SUT 內的另一個方法

    ​​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}"
    
    ​​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"