一. 前言:你測到的是案例,還是規格? #
很多 Python 測試一開始都長這樣:
def test_upper_example() -> None:
assert "abc".upper() == "ABC"
assert "hello".upper() == "HELLO"
assert "".upper() == ""
這種測試沒有錯,但它驗證的是幾個你自己想得到的例子,不是整個行為空間。真正讓程式出事的,常常是那些你沒想到的輸入,例如空 list、極端整數、奇怪的 Unicode 字元、重複值超多的資料,或巢狀結構裡藏著很怪的組合。
hypothesis 的有趣之處就在這裡。它不是叫你多寫一百個例子,而是請你先描述「這個函式應該永遠成立的性質」,再由工具自動生成大量資料去挑戰它。這就是 property-based testing。
你可以把它想成下面這幾種問題:
- 不管輸入是什麼,某個不變量是否都成立?
- 某個正規化函式做兩次,結果是否應該一樣?
- encode 再 decode,是否應該回到原值?
- 切分後再攤平,內容與順序是否應該保留?
如果你平常會寫 CLI 工具、資料清理腳本、parser、helper function、內部工具庫,hypothesis 很值得學。它有點像一個不知疲倦的測試同事,會一直幫你找那些你沒想到的反例。
二. 安裝:先讓測試可以自動生資料 #
安裝方式很簡單:
pip install hypothesis pytest
# 或用 uv
uv add --dev hypothesis pytest
最常見的 import 長這樣:
from hypothesis import given
from hypothesis import strategies as st
這裡的 st 就是 strategies 模組。你可以把 strategy 想成「資料生成規則」,像是:
st.integers()
st.text()
st.lists(st.integers())
st.dictionaries(st.text(min_size=1), st.integers())
2.1 第一個最小範例 #
from hypothesis import given
from hypothesis import strategies as st
@given(st.text())
def test_upper_never_shorter(text: str) -> None:
result = text.upper()
assert len(result) == len(text)
這不是只測一個字串。hypothesis 會自動生成很多不同的字串,包括空字串、長字串、符號、Unicode,然後逐一丟進去跑。如果有一筆失敗,它就會停下來並把失敗案例報給你。
2.2 跟傳統單元測試差在哪裡 #
傳統測試偏向「列舉案例」,hypothesis 偏向「描述規格」。像剛剛那個例子,真正的敘述其實是:「對所有字串來說,upper() 之後長度應該不變。」這種寫法比較抽象,但也更接近你心裡真正的預期。
三. 什麼叫 property:不要只問輸入,改問規則 #
學 hypothesis 最難的部分通常不是 API,而是思考方式。你要從「我要測哪些值」改成「不管值是什麼,哪些規則都必須成立」。常見的 property 大概有四類:
- 不變量:排序前後長度不變,去重後不應再有重複值。
- 冪等性:
normalize(normalize(x)) == normalize(x)。 - round trip:
decode(encode(x)) == x。 - 結構保持:chunk 再 flatten 後應回到原資料。
3.1 用 slugify 來看最直觀 #
假設你在做部落格或 CLI 工具,需要把標題轉成 slug:
import re
def slugify(text: str) -> str:
text = text.strip().lower()
text = re.sub(r"[^a-z0-9]+", "-", text)
return text.strip("-")
傳統測試可能長這樣:
def test_slugify_examples() -> None:
assert slugify("Hello World") == "hello-world"
assert slugify(" Python Tips ") == "python-tips"
assert slugify("A/B/C") == "a-b-c"
這樣可以,但我們其實還知道更多規格。比如說,結果只能包含小寫英數字與 -,不應該以 - 開頭或結尾,而且對結果再做一次 slugify 應該不變。改成 hypothesis 之後可以寫:
import re
from hypothesis import given
from hypothesis import strategies as st
@given(st.text())
def test_slugify_output_charset(text: str) -> None:
result = slugify(text)
assert re.fullmatch(r"[a-z0-9-]*", result)
@given(st.text())
def test_slugify_idempotent(text: str) -> None:
result = slugify(text)
assert slugify(result) == result
這裡沒有列舉幾個特定字串,而是在說:「不管你給我什麼字串,這兩個性質都要成立。」這就是 property-based testing 最核心的味道。
3.2 為什麼 shrinking 很香 #
hypothesis 還有一個超好用的能力叫 shrinking。它不只會幫你找到失敗案例,還會盡量把那個案例縮成最小、最好理解的版本。
來看一個帶 bug 的函式:
def remove_suffix(text: str, suffix: str) -> str:
if text.endswith(suffix):
return text[: -len(suffix)]
return text
如果 suffix == "",這個函式就會出事。測試可以寫成:
@given(st.text(), st.text())
def test_remove_suffix_roundtrip(text: str, suffix: str) -> None:
result = remove_suffix(text + suffix, suffix)
assert result == text
一旦失敗,hypothesis 常常會把反例縮成像 text="a"、suffix="" 這樣極短的版本。你就不必從一大坨亂七八糟輸入裡自己找關鍵問題,debug 體驗好很多。
四. 常用 strategies:你不是只能丟整數 #
hypothesis 的威力,很大一部分來自 strategies 很豐富。常用的有這些:
st.integers(min_value=0, max_value=100)
st.text(min_size=1, max_size=20)
st.lists(st.integers(), max_size=20)
st.sampled_from(["debug", "info", "warning", "error"])
st.one_of(st.none(), st.integers(), st.text())
上面幾種其實已經能解掉很多日常需求了。你可以測有限選項、容器結構、混合型態,也能限制大小和範圍,避免資料空間大到測不動。
4.1 用 builds 產生結構化資料
#
如果你在專案裡常用 dataclass,可以直接生成物件:
from dataclasses import dataclass
@dataclass
class Job:
name: str
retry: int
enabled: bool
job_strategy = st.builds(
Job,
name=st.text(min_size=1, max_size=20),
retry=st.integers(min_value=0, max_value=5),
enabled=st.booleans(),
)
這樣測 helper function 時,就不用手刻一堆假資料。
五. 跟 pytest 一起用,威力才真的完整 #
雖然 @given(...) 已經很好用,但跟 pytest 搭配起來會更實戰。最常見的是 example()、assume() 與 settings()。
5.1 example():把你已知的坑固定留下來
#
假設你已經知道某個輸入曾經害你踩雷,不想只靠隨機生成碰到,可以把它固定加進測試:
from hypothesis import assume, example, settings
@example("---")
@example(" Hello World ")
@settings(max_examples=300)
@given(st.text())
def test_slugify_idempotent(text: str) -> None:
result = slugify(text)
assert slugify(result) == result
如果某個性質只在特定前提下成立,也可以搭配 assume()。例如整數除法的 round trip 就要先排掉分母為 0 的情況。至於 settings(),常見用途是調整 max_examples、deadline 與 verbosity。實戰上,拍拍君通常會把固定規格案例交給 pytest.mark.parametrize,把一般性質與邊界探索交給 hypothesis。這兩者不是競爭關係,而是互補。
六. 實戰案例:用 chunked() 測出你原本沒想到的 bug
#
很多專案都會有一個 chunked() helper,把資料切成固定大小的批次,拿去做批次上傳、任務分段、或 API 分頁。先看一個基本版本:
def chunked(values: list[int], size: int) -> list[list[int]]:
return [values[i : i + size] for i in range(0, len(values), size)]
如果只寫 example-based test,你大概會測:
def test_chunked_examples() -> None:
assert chunked([1, 2, 3, 4], 2) == [[1, 2], [3, 4]]
assert chunked([1, 2, 3], 2) == [[1, 2], [3]]
assert chunked([], 2) == []
這樣不錯,但還可以更進一步。對 chunked() 來說,真正重要的規格至少有三個:
- 每個 chunk 長度都要大於 0 且不超過
size - 把所有 chunk 攤平後,內容與順序都要跟原資料一樣
- 如果原資料非空,chunk 數量應該符合數學上的切分關係
對應的 property test 可以這樣寫:
import math
from itertools import chain
from hypothesis import given
from hypothesis import strategies as st
@given(
st.lists(st.integers(), max_size=100),
st.integers(min_value=1, max_value=20),
)
def test_chunked_properties(values: list[int], size: int) -> None:
chunks = chunked(values, size)
assert all(1 <= len(chunk) <= size for chunk in chunks)
assert list(chain.from_iterable(chunks)) == values
if values:
assert len(chunks) == math.ceil(len(values) / size)
else:
assert chunks == []
這個測試的價值,在於它直接寫出規格,而不是只驗幾個特定案例。
6.1 一個很常見的錯誤版本 #
假設有人把函式改成下面這樣:
def chunked(values: list[int], size: int) -> list[list[int]]:
if not values:
return [[]]
return [values[i : i + size] for i in range(0, len(values), size)]
空輸入時回 [[]] 看起來不大,但如果你的下游程式預期「沒有資料就沒有 chunk」,這會造成很微妙的 bug。前面的 property test 幾乎立刻就會抓到,而且 hypothesis 多半會把失敗案例縮到 values=[]、size=1 這種一看就懂的程度。
這也是 hypothesis 很迷人的地方。你不是只能拿它測數學玩具題,而是真的可以拿來守住工具庫的核心 helper。實際專案裡,把 list 換成 dataclass、dict payload、或 API request object,思路也完全一樣。
七. 什麼時候特別適合用 Hypothesis #
不是每個測試都需要 property-based testing,但下面這些情況特別值得考慮:
- 你寫的是 pure function 或資料轉換邏輯。
- bug 常常卡在邊界值與奇怪輸入。
- 你已經知道規格,但懶得手刻很多變形案例。
- 你要重構舊 helper,希望先用規格把行為框住。
像 parser、formatter、serializer、validator、normalizer、cache key 產生器、批次切分 helper,都很適合。
八. 常見陷阱:不是加了 Hypothesis 就萬能 #
最後補幾個實戰提醒。
8.1 property 不要寫得太弱 #
像下面這種測試幾乎沒資訊量:
@given(st.text())
def test_slugify_returns_str(text: str) -> None:
assert isinstance(slugify(text), str)
這種幾乎抓不到真的 bug。請盡量寫出跟規格真正相關的條件。
8.2 不要把產品邏輯複製到測試裡 #
如果你的測試只是重寫一遍實作,那只是把 bug 複製兩份。property 應該描述規格,不是重新實作演算法。
8.3 保留傳統測試,別走極端 #
hypothesis 很強,但它不是叫你把 pytest.mark.parametrize 全部刪掉。已知商業規格、回歸測試、可讀性高的樣本案例,仍然很重要。最好的組合通常是傳統案例測試加上 property-based testing,兩者一起上。
結語:當你不知道自己漏了什麼時,它就特別有價值 #
拍拍君很喜歡 hypothesis,不只是因為它會自動生資料,而是因為它逼你把「這個函式到底應該保證什麼」講清楚。一旦規格講清楚,測試就不只是補作業,而會變成設計工具。
如果今天只記住三件事,拍拍君會希望你帶走這三句:
- 測試不要只列舉例子,也要描述性質。
strategies決定你怎麼探索輸入空間。shrinking會讓失敗案例變得非常好 debug。
如果你正在維護一堆 helper function、CLI 工具、資料轉換流程,真的很推薦挑一個小函式先試試看,例如 slugify、路徑正規化、chunked、query 參數整理。很可能第一天你就會抓到幾個自己原本沒想到的邊角 bug。
延伸閱讀 #
- Hypothesis 官方文件:https://hypothesis.readthedocs.io/
- Hypothesis strategies 參考:https://hypothesis.readthedocs.io/en/latest/data.html
- pytest 官方文件:https://docs.pytest.org/
- 拍拍延伸閱讀:Python pytest 實戰:單元測試與測試流程完整入門
- 拍拍延伸閱讀:Python typing 實戰:型別註解與型別檢查完整指南