iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Claude AI

把 Claude 練成專家:30 天打造可驗證的 Agent Skills系列 第 10

Day 10|用 pytest 鎖住分類器:日期固定,邊界才不會漂

  • 分享至 

  • xImage
  •  

昨日回顧

Day 9 完成 sources.md 的 loader:只接受一個 fenced YAML、驗證 schema、正規化日期與 host,並拒絕重複或重疊規則。到這裡,分類器和信任根都有明確契約。今天開始寫 pytest,把這些契約變成每次修改都能重跑的回歸測試。

測試先守邊界,不追求覆蓋率數字

第一批測試不需要追求 100% coverage。更重要的是鎖住最容易被「順手重構」破壞的邊界:

  • URL 必須是完整 HTTP(S) URL
  • host 正規化不能改變域名邊界
  • exact 與 include_subdomains 的語意不可混用
  • stale、revoked 與 active 必須由固定日期決定
  • registry 設定錯誤必須在分類前失敗

這些都是安全與可重現性的契約,不只是實作細節。

測試目錄與 import

第一版保持簡單:

ticket-guard/
├── references/
│   └── sources.md
├── scripts/
│   ├── __init__.py
│   └── check_domain.py
└── tests/
    ├── fixtures/
    │   ├── sources_valid.md
    │   └── sources_overlap.md
    ├── test_normalize_url.py
    ├── test_matching.py
    ├── test_classification.py
    └── test_source_loader.py

scripts/ 放空的 __init__.py,讓測試直接 import 純函式:

from scripts.check_domain import (
    SourceConfigError,
    check,
    host_matches,
    load_sources,
    normalize_url,
)

測試純函式比呼叫 subprocess 更快,也能精確指出哪一層失敗。CLI 只需要少量端到端測試。

URL 正規化用參數化測試

import pytest


@pytest.mark.parametrize(
    ("raw", "expected_host"),
    [
        ("https://tickets.example.com/event/1", "tickets.example.com"),
        ("HTTPS://TICKETS.EXAMPLE.COM./event/1", "tickets.example.com"),
        ("https://tickets.example.com:443/event/1", "tickets.example.com"),
        ("https://例子.example/event/1", "xn--fsqu00a.example"),
    ],
)
def test_normalize_url(raw: str, expected_host: str) -> None:
    checked_url, host = normalize_url(raw)

    assert checked_url == raw
    assert host == expected_host

同一個測試函式可以清楚展示多個等價輸入。checked_url 保留原字串,host 才是正規化後的比對值,兩者不可混為一談。

錯誤輸入也要參數化

@pytest.mark.parametrize(
    "raw",
    [
        "not-a-url",
        "tickets.example.com/event/1",
        "ftp://tickets.example.com/file",
        "https:///missing-host",
    ],
)
def test_normalize_url_rejects_incomplete_or_unsupported_input(raw: str) -> None:
    with pytest.raises(ValueError):
        normalize_url(raw)

不要只測 happy path。最常見的回歸,是有人為了「方便」開始接受缺 scheme 的字串,讓 parser 對 host 的解讀變得不一致。

域名比對要專門測攻擊形狀

EXACT = {
    "host": "tickets.example.com",
    "match": "exact",
}

SUBDOMAINS = {
    "host": "tickets.example.com",
    "match": "include_subdomains",
}


@pytest.mark.parametrize(
    ("host", "source", "expected"),
    [
        ("tickets.example.com", EXACT, True),
        ("pay.tickets.example.com", EXACT, False),
        ("tickets.example.com", SUBDOMAINS, True),
        ("pay.tickets.example.com", SUBDOMAINS, True),
        ("tickets.example.com.attacker.net", SUBDOMAINS, False),
        ("notickets.example.com", SUBDOMAINS, False),
    ],
)
def test_host_matches_on_label_boundaries(host, source, expected) -> None:
    assert host_matches(host, source) is expected

這裡最重要的是最後兩筆。若實作被改回 substring 或沒有 . 的 suffix,比對就會立刻失敗。

用 fixture 固定有效來源

import pytest


@pytest.fixture
def active_source() -> dict:
    return {
        "id": "example-primary",
        "host": "tickets.example.com",
        "match": "include_subdomains",
        "evidence_url": "https://organizer.example/sources/tickets",
        "verified_at": "2026-09-01",
        "review_after": "2026-10-01",
        "status": "active",
    }

fixture 使用保留給文件與測試的 example.com,避免讀者誤以為某個真實品牌或票務網站已被驗證。

日期必須由測試傳入

from datetime import date


def test_active_source_is_official_before_review_date(active_source) -> None:
    result = check(
        "https://pay.tickets.example.com/event/1",
        [active_source],
        today=date(2026, 9, 20),
    )

    assert result["status"] == "OFFICIAL"
    assert result["evidence"] == ["source:example-primary"]

測試裡不要呼叫 date.today(),也不要 freeze 整個系統時鐘。純函式已經接受 today,直接傳入固定日期最清楚。

邊界日要明確定義

Day 8 的規則是 today > review_after 才算 stale,因此 review date 當天仍有效:

@pytest.mark.parametrize(
    ("today", "expected_status", "expected_evidence"),
    [
        (date(2026, 10, 1), "OFFICIAL", "source:example-primary"),
        (date(2026, 10, 2), "UNCONFIRMED", "stale:example-primary"),
    ],
)
def test_review_date_boundary(
    active_source, today, expected_status, expected_evidence
) -> None:
    result = check(
        "https://tickets.example.com/event/1",
        [active_source],
        today=today,
    )

    assert result["status"] == expected_status
    assert result["evidence"] == [expected_evidence]

如果產品規格日後改成 review date 當天就失效,只需要改規格與這個測試,不必從程式碼猜原本意圖。

revoked 永遠不能變成 OFFICIAL

def test_revoked_source_is_unconfirmed_even_before_review_date(
    active_source,
) -> None:
    source = {**active_source, "status": "revoked"}

    result = check(
        "https://tickets.example.com/event/1",
        [source],
        today=date(2026, 9, 20),
    )

    assert result["status"] == "UNCONFIRMED"
    assert result["evidence"] == ["revoked:example-primary"]

複製 fixture 後覆寫欄位,避免測試之間共享可變狀態。

沒有命中不代表安全

def test_unknown_host_stays_unconfirmed(active_source) -> None:
    result = check(
        "https://other.example/event/1",
        [active_source],
        today=date(2026, 9, 20),
    )

    assert result["status"] == "UNCONFIRMED"
    assert "source:example-primary" not in result["evidence"]

這個測試守住 Day 8 的核心原則:沒有風險訊號也不能升級成官方,OFFICIAL 必須來自正向來源證據。

loader 測試用 tmp_path

def write_registry(tmp_path, body: str):
    path = tmp_path / "sources.md"
    path.write_text(body, encoding="utf-8")
    return path


def test_loader_rejects_overlapping_rules(tmp_path) -> None:
    path = write_registry(
        tmp_path,
        """```yaml
sources:
  - id: root
    host: tickets.example.com
    match: include_subdomains
    evidence_url: https://organizer.example/root
    verified_at: 2026-09-01
    review_after: 2026-10-01
    status: active
  - id: pay
    host: pay.tickets.example.com
    match: exact
    evidence_url: https://organizer.example/pay
    verified_at: 2026-09-01
    review_after: 2026-10-01
    status: active
```""",
    )

    with pytest.raises(SourceConfigError, match="overlapping source rules"):
        load_sources(path)

tmp_path 讓每個測試擁有獨立檔案,不會修改正式 registry,也不依賴工作目錄。

設定錯誤要測類型,也要測訊息

錯誤訊息是維護介面的一部分,但不要把整句鎖死。只比對穩定且能診斷的片段:

@pytest.mark.parametrize(
    ("markdown", "message"),
    [
        ("no yaml here", "exactly one fenced YAML"),
        ("```yaml\nsources: []\n```", "non-empty list"),
        (
            "```yaml\nsources:\n  - id: incomplete\n```",
            "missing fields",
        ),
    ],
)
def test_loader_fails_fast(tmp_path, markdown, message) -> None:
    path = write_registry(tmp_path, markdown)

    with pytest.raises(SourceConfigError, match=message):
        load_sources(path)

這比 snapshot 完整 traceback 穩定,也避免未來改善行號時產生無意義的差異。

CLI 只補一條失敗路徑

純函式已覆蓋大部分行為,CLI 測試只確認設定壞掉時真的回傳非零,且不在 stdout 假裝產生分類結果:

import subprocess
import sys


def test_cli_exits_nonzero_on_broken_registry(tmp_path) -> None:
    broken = write_registry(tmp_path, "```yaml\nsources: [\n```")

    completed = subprocess.run(
        [
            sys.executable,
            "scripts/check_domain.py",
            "https://tickets.example.com",
            "--sources",
            str(broken),
            "--today",
            "2026-09-20",
        ],
        capture_output=True,
        text=True,
        check=False,
    )

    assert completed.returncode == 2
    assert completed.stdout == ""
    assert "source configuration error" in completed.stderr

若目前 CLI 還沒有 --sources,先加這個參數。可注入路徑讓測試不必覆寫正式檔案,也方便未來在不同環境部署。

第一次執行

python -m pytest -q

失敗時先問:是規格改了、測試寫錯,還是實作回歸?不要為了讓綠燈回來就放寬 assertion。測試的價值正是迫使這個決定被說清楚。

明天預告

Day 11 把測試結果變成 eval:建立案例檔與 runner,同時驗證 status、evidence 與 next_step,而不是只看程式有沒有退出。


上一篇
Day 09|實作 sources loader:設定錯誤要先爆炸
系列文
把 Claude 練成專家:30 天打造可驗證的 Agent Skills10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言