# Property Based Test Gen

> 從 TC 的邊界欄位（前置 / 預期）+ 既有 pytest，產生 hypothesis 的 @given strategies，把 example-based test 升級成 fuzz test。每條 property test 跑 100+ input 自動探邊界 bug，跟 mutation-testing 互補（mutation 找漏點 / property 用 fuzz 封死）。當使用者提到「property-based testing / hypothesis / fuzz test / 隨機 input 測 / 邊界自動掃 / 把 example test 變 property test」，或剛跑完 mutation-testing 看到 boundary survived 想補強時觸發。配套：tc-to-pytest（前置 example test）、mutation-testing（找漏點）、test-review（驗 strategy 合理）。

- Skill: `kao273183/property-based-test-gen` (Agent Skill, multi-file: 6 files)
- Install (CLI): `npx skillmds@latest add kao273183/property-based-test-gen`
- Raw SKILL.md: https://api.skillmd.com/api/skills/kao273183/property-based-test-gen/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: kao273183 (https://skillmd.com/u/kao273183)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/kao273183/property-based-test-gen

---


# property-based-test-gen

> ⚙️ **執行前先讀 [`modules/config-loader.md`](./modules/config-loader.md)**。
> 啟用條件：`backend.pytest_enabled = true` 且 `backend.property_based.enabled = true`。

> 💡 **第一次聽到 property-based testing？** 先看 [`concept-zh.md`](./concept-zh.md) 中文入門導讀（5 分鐘看完搞懂概念）。

## 為什麼要這個 skill

**Example-based test**（你現在的 pytest）= 寫死 1-2 個 input + 預期值。
- 易讀、跑很快
- 但 boundary / 邊角 case 容易漏
- 100% line coverage 也可能漏 off-by-one

**Property-based test**（hypothesis）= 給 input 範圍 + 不變的 property，工具隨機生成 100+ 個 input 自動跑。
- 自動找邊界（fp 誤差、NaN、Infinity、極大/極小、空字串、空陣列）
- shrink 機制：找到 fail input 後自動縮到最小 reproducer
- 跟既有 example test **共存**，不取代

→ 這個 skill 把既有 pytest 升級加 property test，主要對 critical 邏輯。

## 適用場景

- ✅ BE pytest 已寫，想升級加 fuzz（反作弊 / 邊界 / 範圍）
- ✅ 跑完 `/mutation-testing` 看到 boundary survived，想用 fuzz 封死
- ✅ Sprint review 後 critical 模組要強化

## 不適用場景

- ❌ 純 schema 驗證 / status code 檢查（example test 就夠）
- ❌ UI / Flutter / Swift 測試（hypothesis 是 Python only；JS 用 fast-check / Dart 用 glados）
- ❌ 有 IO 副作用（call API / 寫 DB）的 test（fuzz 會打爆 BE）→ 改 fuzz pure function only
- ❌ 沒明確 property 可寫 → 維持 example test

## 對齊基準

- **目標 codebase**：`{{PYTEST_PROJECT_ROOT}}`
- **核心套件**：`hypothesis`（pip install hypothesis）
- **輸出位置**：`tests/test_<feature>_property.py`（跟 example test 並存，不混在同檔）
- **對齊**：每條 property test 的 docstring 寫對應「strengthens TC: WB-XXX-XXX」（雙向 traceability）

## Phase 1: 取輸入

| Argument | 動作 |
|----------|------|
| pytest 檔路徑 | 讀檔，挑 critical function 升級 |
| 模組名 | 從 `tc-index.md` 找對應 Sheet + pytest 檔 |
| `--from-tc <Sheet>` | 額外讀 Sheet 的「前置 / 預期」欄找邊界描述 |
| `--from-mutation-report <path>` | 讀 mutation-testing report 的 survived 列表，**直接針對 survived 點產 property test** |

## Phase 2: 找 critical function

不是每個 function 都值得 property test。挑這些：

| 類別 | 範例 | 為什麼 |
|------|------|--------|
| **數值邊界** | `is_within_100m(distance)` | off-by-one 易漏 |
| **範圍判斷** | `compute_trust_level(...)` | 多分支 |
| **數值上限** | `is_daily_step_within_cap(steps, trust_level)` | 上限隨等級變 |
| **編碼/解碼可逆** | `encode_token` / `decode_token` | round-trip 必須一致 |
| **集合操作** | `merge_logs(...)` | 順序 / 重複處理 |
| **狀態機** | 挑戰 join → progress | 序列 invariant |

不挑的：純 IO / call BE / 純配置讀寫 / 純驗 schema。

## Phase 3: 推 invariant

每個挑出來的 function，從 spec / TC / docstring 推「不變的規則」。範例：

### `is_within_100m(distance: float) -> bool`

來自：spec §X.X + WB-{FEATURE}-039

Invariants：
1. `distance < 100` → 必 True
2. `distance > 100` → 必 False
3. `distance == 100` → 看 spec（這是 boundary，spec 必須明指）
4. `distance < 0` → 應 raise（GPS 不可能負）
5. NaN / Infinity → 應 raise（防偽）

### `compute_trust_level(source, history_anomaly, manual_overrides) -> int`

Invariants：
1. result always in `0..5`
2. source 是 watch / phone / manual 不同對映
3. `history_anomaly >= 3` → trust_level <= 2
4. `manual_overrides > 0` → trust_level == 0

### `is_daily_step_within_cap(steps, trust_level) -> bool`

Invariants：
1. `trust_level == 5` → cap 80000
2. `trust_level == 0` → cap 25000
3. cap 隨 trust_level **單調遞增**
4. steps < 0 → raise

## Phase 4: 產 hypothesis @given strategy

寫到 `tests/test_<feature>_property.py`：

```python
"""
Property-based tests for <feature> critical functions.
跟 test_<feature>_api.py example test 並存，**不取代** — fuzz 補強邊界。

跑：
  {FEATURE}_API_LIVE=1 pytest tests/test_<feature>_property.py -v --hypothesis-show-statistics

慢執行（每條 ~100+ examples），不適合每次 PR 跑；建議週跑或 release 前。
"""
import math
import pytest
from hypothesis import given, settings, strategies as st, assume, HealthCheck
from src.{feature}.checkin import is_within_100m
from src.{feature}.anti_cheat import compute_trust_level, is_daily_step_within_cap


# ─── is_within_100m ───────────────────────────────────────────

@given(d=st.floats(min_value=0, max_value=99.99, allow_nan=False, allow_infinity=False))
@settings(max_examples=200)
def test_within_100m_property(d):
    """
    Strengthens TC: WB-{FEATURE}-039（打卡 100m 內）
    Invariant: distance ∈ [0, 100) → True
    """
    assert is_within_100m(d) is True


@given(d=st.floats(min_value=100.01, max_value=99999, allow_nan=False, allow_infinity=False))
@settings(max_examples=200)
def test_outside_100m_property(d):
    """
    Strengthens TC: WB-{FEATURE}-039（打卡 100m 外）
    Invariant: distance ∈ (100, ∞) → False
    """
    assert is_within_100m(d) is False


@given(d=st.one_of(
    st.just(float("nan")),
    st.just(float("inf")),
    st.just(float("-inf")),
    st.floats(max_value=-0.01),
))
def test_invalid_distance_property(d):
    """
    Strengthens TC: WB-{FEATURE}-039 邊界（NaN / Infinity / 負）
    Invariant: 無效輸入 → ValueError
    """
    with pytest.raises((ValueError, AssertionError)):
        is_within_100m(d)


# ─── compute_trust_level ──────────────────────────────────────

@given(
    source=st.sampled_from(["apple_watch", "iphone", "android", "manual", "unknown"]),
    history_anomaly=st.integers(min_value=0, max_value=10),
    manual_overrides=st.integers(min_value=0, max_value=10),
)
@settings(max_examples=300)
def test_trust_level_in_range(source, history_anomaly, manual_overrides):
    """
    Strengthens TC: WB-{FEATURE}-024
    Invariant: result always in 0..5
    """
    level = compute_trust_level(source, history_anomaly, manual_overrides)
    assert 0 <= level <= 5
```

## Phase 5: settings 與 CI 整合

property test **比 example test 慢 100×+**。CI 策略：

| 何時跑 | 設定 |
|--------|------|
| 本機 dev | `pytest tests/test_*_property.py -v` |
| PR CI | **不跑**（太慢） |
| Nightly / Weekly | `pytest --hypothesis-profile=ci` |
| Release 前 | `pytest --hypothesis-profile=thorough -v --hypothesis-show-statistics` |

`conftest.py` 加 hypothesis profile：
```python
from hypothesis import settings, HealthCheck

settings.register_profile("ci", max_examples=100, deadline=None)
settings.register_profile("thorough", max_examples=1000, deadline=None,
                          suppress_health_check=[HealthCheck.too_slow])
settings.register_profile("dev", max_examples=50)
```

## Phase 6: 失敗 reproducer

hypothesis 的殺手級功能：找到 fail input 自動 **shrink** 到最小 reproducer。

```
Falsifying example: test_within_100m_property(
    d=100.0,
)
```

→ 100.0 是 boundary，spec 沒說該 True 還是 False。fail = bug 找到了。
→ 立即回 spec / PM 問清楚，補 example test 寫死該 case 結果。
→ 加進 hypothesis 的 `@example(d=100.0)` 當 regression case。

## ⚠️ 安全護欄

- ✅ 只 Write `tests/test_<feature>_property.py`（新檔，**不混 example test**）
- ✅ docstring 一定要寫「Strengthens TC: WB-XXX」對應雙向 traceability
- ✅ 預設 max_examples = 200（夠 fuzz / 不太慢）
- ❌ 不動 example test（保留 PR CI 跑得起來）
- ❌ 不對有 IO 副作用的 function 跑 fuzz（會打爆 UAT）
- ❌ 不在每次 PR CI 跑（指引週跑）
- ⚠️ 如果 fuzz 找出 fail，**先停**回 spec / PM 釐清是不是 bug

## Statful 測試（進階）

對序列 / 狀態機 invariant：

```python
from hypothesis.stateful import RuleBasedStateMachine, rule, invariant

class ChallengeStateMachine(RuleBasedStateMachine):
    """
    Strengthens TC: WB-{FEATURE}-040 + 041（挑戰 join → progress 一致）
    """
    def __init__(self):
        super().__init__()
        self.joined = set()
        self.progress = {}

    @rule(challenge_id=st.integers(min_value=1, max_value=100))
    def join(self, challenge_id):
        if challenge_id not in self.joined:
            self.joined.add(challenge_id)
            self.progress[challenge_id] = 0

    @rule(challenge_id=st.integers(min_value=1, max_value=100), steps=st.integers(min_value=1, max_value=10000))
    def add_steps(self, challenge_id, steps):
        if challenge_id in self.joined:
            self.progress[challenge_id] += steps

    @invariant()
    def joined_have_progress(self):
        for cid in self.joined:
            assert cid in self.progress

    @invariant()
    def unjoined_no_progress(self):
        for cid in self.progress:
            assert cid in self.joined

TestChallengeStateMachine = ChallengeStateMachine.TestCase
```

## 配套整合

```
寫好 example pytest
   ↓ /tc-to-pytest                       baseline N 條
   ↓ pytest baseline pass
   ↓ /mutation-testing                   找 survived（boundary 弱點）
mutation report 提示「distance 100.0 沒測」
   ↓ /property-based-test-gen <pytest> --from-mutation-report <report>
產 property test 補強
   ↓ pytest tests/test_*_property.py     跑 fuzz 找邊界 bug
   ↓ 找到 fail → 回 spec / PM 釐清
   ↓ 加 @example regression case
   ↓ /mutation-testing                   重跑驗 score 升
release-ready
```

## 設定依賴

| 設定 Key | 用途 | 缺值時行為 |
|---------|------|-----------|
| `backend.pytest_enabled` | 啟用條件 | skill 不啟用 |
| `backend.property_based.enabled` | 啟用此 skill | skill 不啟用 |
| `backend.property_based.default_max_examples` | hypothesis 預設 | 預設 200 |
| `backend.pytest_project_root` | 寫入位置 | 互動式詢問 |

## 範例

詳見 [`examples.md`](./examples.md)

