快轉到主要內容
  1. 教學文章/

Python hypothesis 實戰:Property-Based Testing 與自動化找 bug 完全攻略

·7 分鐘· loading · loading · ·
Python Hypothesis Testing Pytest Developer-Tools
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 47: 本文

featured

一. 前言:你測到的是案例,還是規格?
#

很多 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 tripdecode(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_examplesdeadlineverbosity。實戰上,拍拍君通常會把固定規格案例交給 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。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 47: 本文

相關文章

Python watchdog 實戰:檔案變更監控與自動化完全攻略
·8 分鐘· loading · loading
Python Watchdog Automation Filesystem Developer-Tools
Python pytest:fixture + parametrize + mock 完整指南
·8 分鐘· loading · loading
Python Pytest Testing Fixture Mock Parametrize TDD
Python Textual 實戰:終端機 TUI 應用開發完全攻略
·9 分鐘· loading · loading
Python Textual TUI Cli Terminal
Python tenacity 實戰:重試、退避與容錯機制完全攻略
·9 分鐘· loading · loading
Python Tenacity Retry Backoff 容錯
Python loguru 實戰:告別複雜的 logging 設定,寫出漂亮的日誌
·6 分鐘· loading · loading
Python Logging Loguru 除錯 工具
Python argparse 實戰:CLI 參數解析、旗標設計與 subcommands 完全攻略
·9 分鐘· loading · loading
Python Argparse Cli Command-Line Automation Developer-Tools