【pytest 教學】#05 實戰整合:mock、測試組織與 CI 整合

測驗:pytest 實戰整合 – mock、測試組織與 CI 整合

共 5 題,點選答案後會立即顯示結果

1. 為什麼在測試中需要使用 mock?

  • A. 讓測試程式碼變得更短更簡潔
  • B. 隔離外部依賴,讓測試更穩定、更快速
  • C. 讓測試可以跳過不想測的功能
  • D. 自動產生測試資料

2. 使用 monkeypatch.setattr 時,應該 mock 哪裡的參照?

# api_client.py import requests def get_user_data(user_id): response = requests.get(f”https://api.example.com/users/{user_id}”) return response.json()
  • A. monkeypatch.setattr("requests.get", fake_get)
  • B. monkeypatch.setattr("urllib.request.get", fake_get)
  • C. monkeypatch.setattr("api_client.requests.get", fake_get)
  • D. monkeypatch.setattr("get_user_data.requests", fake_get)

3. conftest.py 檔案的主要用途是什麼?

  • A. 設定 pytest 的命令列參數
  • B. 定義可被同目錄及子目錄測試共用的 fixtures
  • C. 儲存測試的執行結果報告
  • D. 記錄測試失敗的錯誤訊息

4. 執行 pytest --cov=myproject --cov-fail-under=80 時,什麼情況下測試會失敗?

  • A. 任何一個測試案例失敗時
  • B. 有超過 80 個測試案例時
  • C. 測試執行時間超過 80 秒時
  • D. 程式碼覆蓋率低於 80% 時

5. 在 Mock 物件上設定 side_effect = [1, 2, 3] 會產生什麼效果?

  • A. 每次呼叫都回傳列表 [1, 2, 3]
  • B. 依序回傳 1、2、3,每次呼叫回傳下一個值
  • C. 隨機回傳 1、2 或 3 其中一個
  • D. 呼叫時會拋出包含 [1, 2, 3] 的例外

本篇是 pytest 教學系列的最後一篇。我們將學習如何使用 mock 隔離外部依賴、組織大型專案的測試結構,並將 pytest 整合到 CI 流程中。

為什麼需要 mock

在真實專案中,程式碼常常會依賴外部服務:

# api_client.py
import requests

def get_user_data(user_id):
    response = requests.get(f"https://api.example.com/users/{user_id}")
    return response.json()
Code language: PHP (php)

直接測試這段程式碼會遇到幾個問題:

  1. 速度慢:每次測試都要發送網路請求
  2. 不穩定:網路問題或 API 服務中斷會導致測試失敗
  3. 難以控制:無法測試錯誤處理(如 API 回傳 500)
  4. 有副作用:可能真的修改遠端資料

Mock 的作用是用「假的」物件取代真實的依賴,讓我們能夠:

  • 控制依賴的行為和回傳值
  • 驗證程式碼是否正確呼叫依賴
  • 加速測試執行

monkeypatch:pytest 內建的 mock 工具

pytest 提供 monkeypatch fixture,這是最簡單的 mock 方式:

# test_api_client.py
from api_client import get_user_data

def test_get_user_data(monkeypatch):
    # 建立假的 response
    class FakeResponse:
        def json(self):
            return {"id": 1, "name": "Alice"}

    # 用假的 get 取代 requests.get
    def fake_get(url):
        return FakeResponse()

    monkeypatch.setattr("api_client.requests.get", fake_get)

    # 現在 get_user_data 會使用假的 requests.get
    result = get_user_data(1)
    assert result == {"id": 1, "name": "Alice"}

monkeypatch 常用方法

def test_monkeypatch_examples(monkeypatch):
    # 替換屬性
    monkeypatch.setattr(obj, "attribute", value)

    # 替換字典項目
    monkeypatch.setitem(my_dict, "key", "new_value")

    # 設定環境變數
    monkeypatch.setenv("API_KEY", "test-key")

    # 刪除環境變數
    monkeypatch.delenv("DEBUG", raising=False)

    # 修改 sys.path
    monkeypatch.syspath_prepend("/custom/path")
Code language: PHP (php)

讀懂 setattr 的路徑

monkeypatch.setattr 的第一個參數是「從測試檔案看過去的路徑」:

# 檔案結構
# myproject/
#   api_client.py  <- 裡面 import requests
#   tests/
#     test_api.py

# test_api.py 中
# 錯誤:這會去 mock requests 模組本身
monkeypatch.setattr("requests.get", fake_get)

# 正確:mock api_client 模組裡的 requests.get
monkeypatch.setattr("api_client.requests.get", fake_get)
Code language: PHP (php)

重點:mock 的是「使用端」的參照,不是「來源端」

unittest.mock 與 pytest-mock

Python 標準庫的 unittest.mock 提供更強大的 mock 功能:

from unittest.mock import Mock, patch

def test_with_mock():
    # 建立 Mock 物件
    mock_response = Mock()
    mock_response.json.return_value = {"id": 1, "name": "Alice"}
    mock_response.status_code = 200

    # 使用 patch 裝飾器
    with patch("api_client.requests.get", return_value=mock_response) as mock_get:
        result = get_user_data(1)

        # 驗證 mock 被正確呼叫
        mock_get.assert_called_once_with("https://api.example.com/users/1")
        assert result == {"id": 1, "name": "Alice"}
Code language: PHP (php)

pytest-mock 插件

pytest-mockunittest.mock 包裝成 fixture,使用更方便:

pip install pytest-mock
def test_with_mocker(mocker):
    # mocker 是 pytest-mock 提供的 fixture
    mock_response = mocker.Mock()
    mock_response.json.return_value = {"id": 1, "name": "Alice"}

    # 使用 mocker.patch
    mock_get = mocker.patch("api_client.requests.get", return_value=mock_response)

    result = get_user_data(1)

    mock_get.assert_called_once()
    assert result["name"] == "Alice"
Code language: PHP (php)

Mock 物件的常用屬性

def test_mock_attributes(mocker):
    mock_func = mocker.Mock()

    # 設定回傳值
    mock_func.return_value = 42
    assert mock_func() == 42

    # 設定多次呼叫的回傳值
    mock_func.side_effect = [1, 2, 3]
    assert mock_func() == 1
    assert mock_func() == 2
    assert mock_func() == 3

    # 讓 mock 拋出例外
    mock_func.side_effect = ValueError("error")
    with pytest.raises(ValueError):
        mock_func()
Code language: PHP (php)

驗證 mock 呼叫

def test_mock_assertions(mocker):
    mock_func = mocker.Mock()

    mock_func("arg1", key="value")
    mock_func("arg2")

    # 驗證呼叫次數
    assert mock_func.call_count == 2

    # 驗證最後一次呼叫
    mock_func.assert_called_with("arg2")

    # 驗證曾經被這樣呼叫過
    mock_func.assert_any_call("arg1", key="value")

    # 取得所有呼叫記錄
    print(mock_func.call_args_list)
    # [call('arg1', key='value'), call('arg2')]
Code language: PHP (php)

測試目錄結構建議

小型專案可以把測試放在同一目錄:

myproject/
  calculator.py
  test_calculator.py

中大型專案建議使用獨立的 tests 目錄:

myproject/
  src/
    myproject/
      __init__.py
      calculator.py
      api/
        __init__.py
        client.py
  tests/
    __init__.py
    conftest.py              # 共用 fixtures
    test_calculator.py
    api/
      __init__.py
      test_client.py
Code language: PHP (php)

conftest.py 的作用

conftest.py 是 pytest 的特殊檔案,用於定義共用的 fixtures:

# tests/conftest.py
import pytest

@pytest.fixture
def api_client():
    """所有測試都可以使用這個 fixture"""
    return APIClient(base_url="https://test.example.com")

@pytest.fixture
def sample_user():
    return {"id": 1, "name": "Test User", "email": "[email protected]"}
Code language: PHP (php)

conftest.py 的特點:

  • 自動載入:不需要 import,pytest 會自動找到
  • 作用範圍:同目錄及子目錄的測試都能使用
  • 可以有多個:不同目錄可以有自己的 conftest.py

pytest.ini / pyproject.toml 設定

pytest.ini

# pytest.ini
[pytest]
# 測試檔案的目錄
testpaths = tests

# 測試檔案的命名模式
python_files = test_*.py
python_classes = Test*
python_functions = test_*

# 預設的命令列選項
addopts = -v --tb=short

# 忽略特定目錄
norecursedirs = .git .tox venv

# 設定 markers
markers =
    slow: marks tests as slow (deselect with '-m "not slow"')
    integration: marks tests as integration tests
Code language: PHP (php)

pyproject.toml(推薦)

現代 Python 專案傾向使用 pyproject.toml 統一管理設定:

# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
addopts = "-v --tb=short"
markers = [
    "slow: marks tests as slow",
    "integration: marks tests as integration tests",
]
Code language: PHP (php)

pytest-cov:測試覆蓋率報告

測試覆蓋率告訴你「多少程式碼被測試執行過」:

pip install pytest-cov

基本使用

# 產生覆蓋率報告
pytest --cov=myproject tests/

# 輸出範例
# ---------- coverage: platform linux, python 3.11.0 ----------
# Name                    Stmts   Miss  Cover
# -------------------------------------------
# myproject/__init__.py       2      0   100%
# myproject/calculator.py    15      3    80%
# myproject/api/client.py    28     10    64%
# -------------------------------------------
# TOTAL                      45     13    71%
Code language: PHP (php)

常用選項

# 顯示哪些行沒被覆蓋
pytest --cov=myproject --cov-report=term-missing

# 產生 HTML 報告
pytest --cov=myproject --cov-report=html

# 設定最低覆蓋率門檻(低於則失敗)
pytest --cov=myproject --cov-fail-under=80
Code language: PHP (php)

讀懂覆蓋率報告

Name                    Stmts   Miss  Cover   Missing
-----------------------------------------------------
myproject/calculator.py    15      3    80%   12-14
  • Stmts:總共有多少行可執行程式碼
  • Miss:有多少行沒被執行
  • Cover:覆蓋率百分比
  • Missing:哪些行號沒被覆蓋

排除不需要測試的程式碼

# 使用 pragma: no cover 註解
if __name__ == "__main__":  # pragma: no cover
    main()
Code language: PHP (php)

或在設定檔中排除:

# pyproject.toml
[tool.coverage.run]
omit = [
    "*/tests/*",
    "*/__main__.py",
]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "if __name__ == .__main__.:",
    "raise NotImplementedError",
]
Code language: PHP (php)

GitHub Actions 整合範例

將 pytest 整合到 CI,每次 push 都自動執行測試:

# .github/workflows/test.yml
name: Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt
          pip install pytest pytest-cov

      - name: Run tests
        run: |
          pytest --cov=myproject --cov-report=xml

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          file: ./coverage.xml
Code language: PHP (php)

讀懂這個設定檔

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
Code language: CSS (css)

觸發條件:push 到 main 分支,或建立 PR 到 main 分支時執行。

steps:
  - uses: actions/checkout@v4

使用官方的 checkout action 取得程式碼。

- name: Run tests
  run: |
    pytest --cov=myproject --cov-report=xml

執行 pytest 並產生 XML 格式的覆蓋率報告(給 Codecov 用)。

多 Python 版本測試

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ['3.9', '3.10', '3.11', '3.12']

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}

      - name: Install and test
        run: |
          pip install -r requirements.txt
          pytest
Code language: JavaScript (javascript)

實戰範例:完整的測試專案結構

myproject/
  pyproject.toml
  src/
    myproject/
      __init__.py
      calculator.py
      api/
        __init__.py
        client.py
  tests/
    __init__.py
    conftest.py
    test_calculator.py
    api/
      __init__.py
      conftest.py
      test_client.py
  .github/
    workflows/
      test.yml
# pyproject.toml
[project]
name = "myproject"
version = "1.0.0"
requires-python = ">=3.9"
dependencies = [
    "requests>=2.28.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=7.0.0",
    "pytest-cov>=4.0.0",
    "pytest-mock>=3.10.0",
]

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short --cov=myproject --cov-report=term-missing"
markers = [
    "slow: marks tests as slow",
    "integration: marks tests as integration tests",
]

[tool.coverage.run]
source = ["src/myproject"]
omit = ["*/tests/*"]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "if __name__ == .__main__.:",
]
Code language: PHP (php)

總結

本篇涵蓋了 pytest 實戰中的進階技巧:

主題 重點
Mock 隔離外部依賴,控制測試環境
monkeypatch pytest 內建的簡單 mock 工具
pytest-mock 更強大的 mock 功能
測試組織 conftest.py 共用 fixtures
設定檔 pyproject.toml 統一管理
pytest-cov 測試覆蓋率報告
GitHub Actions CI 自動化測試

系列回顧

經過這五篇教學,你已經學會:

  1. 基礎篇:pytest 安裝與第一個測試
  2. 斷言篇:各種斷言方法與例外測試
  3. Fixture 篇:測試前置作業與資源管理
  4. 參數化篇:用參數化減少重複測試
  5. 實戰篇:mock、測試組織與 CI 整合

現在你已經具備在真實專案中使用 pytest 的能力。記住,好的測試不只是追求覆蓋率,更重要的是測試有意義的行為和邊界條件。

Happy testing!

進階測驗:pytest 實戰整合 – mock、測試組織與 CI 整合

測驗目標:驗證你是否能在實際情境中應用所學。
共 5 題,包含情境題與錯誤診斷題。

1. 你的專案中有一個函式會呼叫外部支付 API,你想測試當 API 回傳錯誤時程式是否能正確處理。應該怎麼做? 情境題

# payment.py import requests def process_payment(amount): response = requests.post(“https://api.payment.com/charge”, json={“amount”: amount}) if response.status_code != 200: raise PaymentError(“Payment failed”) return response.json()
  • A. 使用真實的測試環境 API,傳入會觸發錯誤的金額
  • B. 在測試中直接修改 process_payment 函式的程式碼
  • C. 使用 mock 設定 side_effect 或控制回傳的 status_code 來模擬錯誤
  • D. 暫時關閉網路連線來測試錯誤處理

2. 小明寫了以下測試,但 mock 沒有生效,get_weather 還是會真的發送網路請求。問題出在哪裡? 錯誤診斷

# weather_service.py import requests def get_weather(city): response = requests.get(f”https://api.weather.com/{city}”) return response.json() # test_weather.py from weather_service import get_weather def test_get_weather(monkeypatch): def fake_get(url): class FakeResponse: def json(self): return {“temp”: 25} return FakeResponse() monkeypatch.setattr(“requests.get”, fake_get) result = get_weather(“taipei”) assert result[“temp”] == 25
  • A. FakeResponse 類別定義有問題
  • B. 應該 mock weather_service.requests.get,而不是 requests.get
  • C. monkeypatch 不能在函式內定義 fake 函式
  • D. 應該使用 pytest-mock 而不是 monkeypatch

3. 你的團隊專案有多個測試檔案都需要使用相同的資料庫連線 fixture。為了避免重複程式碼,最佳做法是什麼? 情境題

  • A. 在每個測試檔案的開頭複製貼上 fixture 定義
  • B. 建立一個 fixtures.py 檔案,在每個測試檔案中 import
  • C. 在 tests/conftest.py 中定義 fixture,pytest 會自動載入
  • D. 使用全域變數在模組層級建立資料庫連線

4. CI 執行 pytest 時出現以下輸出,測試全部通過但最後顯示失敗。最可能的原因是什麼? 錯誤診斷

$ pytest –cov=myproject –cov-fail-under=80 ========================= test session starts ========================= collected 15 items tests/test_calculator.py ….. [ 33%] tests/test_api.py ………. [100%] ———- coverage: platform linux, python 3.11.0 ———- Name Stmts Miss Cover ——————————————- myproject/__init__.py 2 0 100% myproject/calculator.py 15 3 80% myproject/api/client.py 28 12 57% ——————————————- TOTAL 45 15 67% FAIL Required test coverage of 80% not reached. Total coverage: 67%
  • A. 有隱藏的測試案例失敗了
  • B. pytest-cov 插件版本不相容
  • C. myproject/api/client.py 檔案有語法錯誤
  • D. 總覆蓋率 67% 低於設定的門檻 80%,觸發了 --cov-fail-under

5. 你需要驗證一個函式在處理過程中確實呼叫了 send_email 函式,而且傳入的收件者是正確的。應該使用什麼方式? 情境題

# notification.py def notify_user(user_id, message): user = get_user(user_id) send_email(user.email, message) return True
  • A. 檢查函式的回傳值是否為 True
  • B. 使用 mock 物件,然後呼叫 mock_send_email.assert_called_with(expected_email, message)
  • C. 設定一個真實的測試信箱,檢查是否收到郵件
  • D. 使用 print 印出參數,人工檢查輸出

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *