在前十天,我們已經完成了從環境、設定、專案結構、依賴管理、一鍵化工作流、程式風格到型別與資料契約的基礎建設。
專案終於能「穩定地跑」,但還缺一塊關鍵拼圖:自動化測試。
測試不是附屬品,而是工程化專案的生命線。今天,我們會建立一個完整的測試藍圖,涵蓋:
沒有測試的專案,開發就像拆定時炸彈:每次修改都擔心哪裡會爆。
常見問題包括:
一個良好的測試策略應具備:
延續 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/:模擬真實使用場景,確保主要路徑能走通。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
from your_package.core import add
def test_add_basic():
    assert add(2, 3) == 5
def test_avg_api(client):
    resp = client.post("/avg", json={"nums": [1, 2, 3]})
    assert resp.status_code == 200
    assert resp.json()["result"] == 2
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"
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"
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
在 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
"""
這樣就能:
執行:
pytest -q

延續 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。在工作的過程中,會是一件很惱人的事情。

從今天開始,我們的專案已經具備了完整的測試基礎。
接下來的 Day 12,我們會進一步探討 設定與祕密管理,看看如何處理 .env、pydantic-settings 與雲端祕密服務,讓專案更安全地管理敏感資訊。 🚀