Day 9 完成 sources.md 的 loader:只接受一個 fenced YAML、驗證 schema、正規化日期與 host,並拒絕重複或重疊規則。到這裡,分類器和信任根都有明確契約。今天開始寫 pytest,把這些契約變成每次修改都能重跑的回歸測試。
第一批測試不需要追求 100% coverage。更重要的是鎖住最容易被「順手重構」破壞的邊界:
這些都是安全與可重現性的契約,不只是實作細節。
第一版保持簡單:
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 只需要少量端到端測試。
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,比對就會立刻失敗。
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 當天就失效,只需要改規格與這個測試,不必從程式碼猜原本意圖。
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 必須來自正向來源證據。
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 測試只確認設定壞掉時真的回傳非零,且不在 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,而不是只看程式有沒有退出。