iT邦幫忙

2025 iThome 鐵人賽

DAY 11
0
Software Development

30 天 Python 專案工坊:環境、結構、測試到部署全打通系列 第 11

Day 11 - 測試策略藍圖:pytest 目錄結構、fixtures 與 coverage

  • 分享至 

  • xImage
  •  

在前十天,我們已經完成了從環境、設定、專案結構、依賴管理、一鍵化工作流、程式風格到型別與資料契約的基礎建設。

專案終於能「穩定地跑」,但還缺一塊關鍵拼圖:自動化測試

測試不是附屬品,而是工程化專案的生命線。今天,我們會建立一個完整的測試藍圖,涵蓋:

  1. pytest 測試目錄結構設計
  2. conftest.py 與 fixtures 的共用策略
  3. 單元/整合/端到端測試的切分與標記
  4. coverage(測試覆蓋率)的導入與 CI 門檻
  5. 與 Hatch / Nox 串接的一鍵化工作流
  6. 常見實戰範例(API、資料庫、外部服務 mock、日誌驗證)

為什麼要規劃測試?

沒有測試的專案,開發就像拆定時炸彈:每次修改都擔心哪裡會爆。

常見問題包括:

  • 測試檔案四散無章,日後難以維護。
  • 測試邏輯重複,改一處忘一處。
  • 沒有覆蓋率檢查,測試看似存在卻漏掉關鍵路徑。

一個良好的測試策略應具備:

  • 結構清楚:檔案與程式碼一一對應。
  • 可重複:測試在本地與 CI 都能跑出一致結果。
  • 可量測:覆蓋率數字讓品質有依據。

測試目錄結構

延續 Day 4 的專案結構,建議用 src/ + tests/ 架構:

my_project/
├─ src/my_project/
│   ├─ __init__.py
│   ├─ core.py
│   └─ adapters/
│       └─ web.py
└─ tests/
    ├─ conftest.py
    ├─ unit/
    │   └─ test_core.py
    ├─ integration/
    │   └─ test_api.py
    └─ e2e/
        └─ test_cli.py

  • tests/unit/:快速驗證函式與類別邏輯。
  • tests/integration/:測 API、DB、檔案等組件交互。
  • tests/e2e/:模擬真實使用場景,確保主要路徑能走通。

conftest.py 與 fixtures

pytest 的超能力就是 fixtures。它能統一產生測試需要的資源,減少重複。

範例 tests/conftest.py

import subprocess
import sys
from pathlib import Path
from typing import NamedTuple

import pytest

class CliResult(NamedTuple):
    code: int
    out: str
    err: str

@pytest.fixture
def run_cli(tmp_path: Path) -> callable:

    def _run(args: list[str] | None = None) -> CliResult:
        cmd = [sys.executable, "-m", "demo_app.main"]
        if args:
            cmd.extend(args)
        proc = subprocess.run(cmd, capture_output=True, text=True, cwd=str(tmp_path))
        return CliResult(proc.returncode, proc.stdout, proc.stderr)

    return _run

使用方式:

import pytest

pytestmark = pytest.mark.e2e

def test_cli_prints_hello_world(run_cli):
    result = run_cli()
    assert result.code == 0
    assert "Hello World" in result.out

單元/整合/端到端測試範例

單元測試(unit)

from your_package.core import add

def test_add_basic():
    assert add(2, 3) == 5

FastAPI API 測試(integration)

def test_avg_api(client):
    resp = client.post("/avg", json={"nums": [1, 2, 3]})
    assert resp.status_code == 200
    assert resp.json()["result"] == 2

SQLite 測試(integration)

def test_sqlite_roundtrip(temp_db):
    cur = temp_db.cursor()
    cur.execute("CREATE TABLE users (id TEXT, name TEXT)")
    cur.execute("INSERT INTO users VALUES (?, ?)", ("u1", "Alice"))
    temp_db.commit()
    cur.execute("SELECT name FROM users WHERE id='u1'")
    assert cur.fetchone()[0] == "Alice"

外部服務 mock(pytest-mock)

import httpx
from your_package.services.ext import fetch_title

def test_fetch_title(mocker):
    mock_resp = mocker.Mock(text="Hello World", raise_for_status=lambda: None)
    mocker.patch.object(httpx, "get", return_value=mock_resp)
    assert fetch_title("http://fake") == "Hello World"

驗證日誌(caplog)

import logging

logger = logging.getLogger("demo")

def work(x):
    logger.info("working %s", x)
    return x*2

def test_logging(caplog):
    caplog.set_level(logging.INFO)
    assert work(3) == 6
    assert "working 3" in caplog.text

Coverage(測試覆蓋率)

pyproject.toml 加入:

[tool.pytest.ini_options]
addopts = """
  -ra
  --strict-markers
  --disable-warnings
  --cov=your_package
  --cov-report=term-missing
  --cov-report=xml:coverage.xml
  --cov-fail-under=80
"""

這樣就能:

  • 顯示哪些行沒測到。
  • 在 CI 強制覆蓋率低於 80% 時失敗。

執行:

pytest -q

https://ithelp.ithome.com.tw/upload/images/20250925/20178117QcD4PjBNzs.png

串接 Hatch × Nox

延續 Day 8,我們可以在 pyproject.toml 增加:

[tool.hatch.envs.test]
dependencies = ["pytest", "pytest-cov", "pytest-mock", "httpx", "fastapi"]
[tool.hatch.envs.test.scripts]
unit = "pytest -m 'not slow and not e2e'"
all = "pytest"
ci = "pytest --junitxml=report.xml"

noxfile.py:

import nox

@nox.session(python=["3.10", "3.11", "3.12"])
def tests(session):
    session.install(".[dev]")
    session.run("pytest", "--cov=your_package", "--cov-fail-under=80")

我們還可以在pyproject.toml增加下列,確保在push前的unit test可以達到一定的覆蓋率。

pre-commit-install-push = "pre-commit install --hook-type pre-push"

最後調整.pre-commit的檔案,就能達到在push前檢查單元測試的執行狀況和覆蓋率

      # 建議在 push 時跑完整測試(含 coverage gate)
      - id: hatch-tests
        name: hatch tests (with coverage gate)
        entry: hatch
        language: system
        args: ["run", "test-all"]
        stages: [push]
        pass_filenames: false

如下圖,我們在push就觸發了pre-commit的機制,讓他再次檢查,並且確認一下test Coverage是否有達標,為什麼測試覆蓋率要在push前,因為我們在編寫程式的時候,如果測試覆蓋率還不足,那會造成我們無法commit。在工作的過程中,會是一件很惱人的事情。

https://ithelp.ithome.com.tw/upload/images/20250925/201781177CRYMSHdXU.png

最佳實務藍圖

  • 單元測試多、整合測試少但真實、端到端測試更少但關鍵。
  • fixtures 提供共用資源,避免複製貼上。
  • 覆蓋率報告納入 CI,品質有數字依據。
  • Hatch/Nox 一鍵化測試,避免「本地 OK、CI 爆炸」。

結語

從今天開始,我們的專案已經具備了完整的測試基礎。

  • Day 9 確保風格一致。
  • Day 10 加上型別與資料契約。
  • Day 11 再用 pytest 建立品質閉環。

接下來的 Day 12,我們會進一步探討 設定與祕密管理,看看如何處理 .env、pydantic-settings 與雲端祕密服務,讓專案更安全地管理敏感資訊。 🚀


上一篇
Day 10 -型別與資料契約:mypy / pyright 與 Pydantic v2
系列文
30 天 Python 專案工坊:環境、結構、測試到部署全打通11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言