Property-Based Testing trong Python với Hypothesis: Tự Động Hóa Phát Hiện Edge Case

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Điểm mù của Example-Based Testing

Hầu hết các nhóm kỹ thuật đều dựa vào example-based test (kiểm thử dựa trên ví dụ cụ thể). Chúng ta viết một hàm, phác thảo ba hoặc bốn dữ liệu đầu vào điển hình, assert giá trị trả về kỳ vọng rồi triển khai. Nhược điểm ở đây quá rõ ràng: các edge case chắc chắn sẽ lọt ra môi trường production. Những lỗi tiềm ẩn tinh vi như Unicode surrogate pair chưa được xử lý, payload rỗng, số 0 có dấu âm (-0.0), hay lỗi off-by-one rất hiếm khi xuất hiện trong các fixture tự tạo bằng tay.

Theo lẽ tự nhiên, chúng ta thường thiết kế unit test nhằm xác nhận những kỳ vọng sẵn có của mình. Khi kiểm thử một bộ phân tích chuỗi (string parser), ta thường nhập "hello" hoặc "user_123" thay vì các chuỗi chứa đầy ký tự null (\x00) hay ký tự ghi đè hướng từ phải sang trái (right-to-left override). Hypothesis thay thế sự phỏng đoán này bằng property-based testing (kiểm thử dựa trên đặc tính). Thay vì hardcode các ví dụ riêng lẻ, bạn định nghĩa các quy tắc chung—tức các invariant (bất biến)—mà mã nguồn bắt buộc phải luôn tuân thủ. Sau đó, engine sẽ tự động sinh và nạp hàng trăm biến thể dữ liệu vào mã nguồn để tìm ra điểm gãy vụn.

So sánh phương pháp: Example-Based vs. Property-Based Testing

Dưới đây là cách hai mô hình xử lý một hàm chia mảng thành từng phần nhỏ (array-chunking):

Tiêu chí Example-Based (pytest) Property-Based (Hypothesis)
Nguồn dữ liệu đầu vào Fixture cố định (ví dụ: [1, 2, 3, 4]) Generative strategy (st.lists(st.integers()))
Số lượng kiểm thử 1 lần chạy cho mỗi kịch bản tham số hóa Hơn 100 biến thể được sinh tự động mỗi lần thực thi
Chẩn đoán lỗi Hiển thị trường hợp tĩnh bị lỗi Tự động thu gọn (shrink) đầu vào về ca tái hiện tối thiểu
Trọng tâm tư duy “Đầu vào A chính xác phải trả về giá trị nào?” “Những invariant nào bắt buộc phải đúng trên mọi đầu vào hợp lệ?”

Ưu và nhược điểm của Hypothesis

Việc chuyển từ các ví dụ tĩnh sang mô hình hóa invariant mang lại những lợi ích vượt trội, dù đi kèm chi phí tài nguyên tính toán nhất định.

Ưu điểm

  • Tự động thu gọn lỗi (Automated Shrinking): Nếu một bài test thất bại trên danh sách 80 phần tử chứa các chuỗi lồng nhau phức tạp, Hypothesis sẽ không đổ dồn cả 80 phần tử đó vào bạn. Nó sẽ loại bỏ dần các giá trị không liên quan cho đến khi tìm ra chính xác nguyên nhân kích hoạt lỗi—thường thu gọn payload xuống chỉ còn một chuỗi rỗng "" hoặc duy nhất một số 0.
  • Khám phá triệt để các giá trị biên: Công cụ liên tục thử nghiệm các corner case. Tập hợp rỗng, số thực dấu phẩy động subnormal, các tổ hợp khoảng trắng và ngưỡng số nguyên tối đa đều được tự động đưa vào hàm của bạn.
  • Cache hồi quy tất định (Deterministic Regression Cache): Khi Hypothesis phát hiện một lỗi, nó sẽ lưu ca tái hiện tối thiểu đó vào thư mục cục bộ .hypothesis. Mọi lần chạy test tiếp theo sẽ luôn xác minh lại chính xác lỗi hồi quy đó trước tiên, ngay cả trên các máy khác nhau.

Đánh đổi

  • Độ trễ thực thi cao hơn: Một unit test tiêu chuẩn chạy trong khoảng 0.4 ms. Việc chạy 100 ca sinh tự động cho mỗi bài test với Hypothesis có thể đẩy thời lượng lên 30–120 ms.
  • Thay đổi tư duy tiếp cận: Bạn không thể chỉ dựa vào việc so sánh giá trị đơn thuần. Đội ngũ phát triển phải học cách assert các mối quan hệ cấu trúc, chẳng hạn như tính đối xứng mã hóa hai chiều (round-trip encoding symmetry), tính lũy đẳng (idempotency), hoặc các cận toán học.

Cài đặt khuyến nghị

Cài đặt cả hai package vào môi trường ảo đang hoạt động của bạn:

pip install pytest hypothesis

Để giữ phản hồi nhanh khi chạy cục bộ mà vẫn thực hiện kiểm tra toàn diện trên pull request, hãy đăng ký các profile chuyên dụng trong file conftest.py ở thư mục gốc:

# conftest.py
import os
from hypothesis import settings

settings.register_profile("ci", max_examples=250, deadline=None)
settings.register_profile("dev", max_examples=40, deadline=300)

# Chuyển đổi profile qua biến môi trường, mặc định là dev khi chạy cục bộ
current_profile = os.getenv("HYPOTHESIS_PROFILE", "dev")
settings.load_profile(current_profile)

Hướng dẫn triển khai

Xét một bộ tính giá cho ứng dụng thương mại điện tử xử lý chi phí cơ bản, phần trăm chiết khấu và thuế bán hàng. Lỗi làm tròn tinh vi hoặc dữ liệu thập phân bất thường thường xuyên gây ra sai lệch hóa đơn trong các pipeline này.

1. Logic nghiệp vụ

Phần tính toán được đặt trong pricing.py, sử dụng module Decimal của Python để tránh lỗi dấu phẩy động nhị phân:

# pricing.py
from decimal import Decimal, ROUND_HALF_UP

def calculate_final_price(
    base_price: Decimal,
    discount_percent: Decimal,
    tax_percent: Decimal,
) -> Decimal:
    if base_price < Decimal("0.00"):
        raise ValueError("Giá cơ bản không thể âm")
    if not (Decimal("0.00") <= discount_percent <= Decimal("100.00")):
        raise ValueError("Chiết khấu phải nằm trong khoảng từ 0 đến 100")
    if tax_percent < Decimal("0.00"):
        raise ValueError("Thuế không thể âm")

    discount_factor = Decimal("1.00") - (discount_percent / Decimal("100.00"))
    tax_factor = Decimal("1.00") + (tax_percent / Decimal("100.00"))
    
    total = base_price * discount_factor * tax_factor
    return total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)

2. Viết các đặc tính bất biến (Invariant Properties)

Hãy tạm quên các giá trị tiền tệ hardcode như $19.99. Thay vào đó, hãy định hình các yêu cầu cốt lõi thành các chân lý phổ quát:

  1. Đáy không chiết khấu (Zero-Discount Floor): Nếu chiết khấu là 0% và thuế dương hoặc bằng 0, giá cuối cùng không bao giờ được thấp hơn giá cơ bản.
  2. Bảo đảm chiết khấu toàn phần (Full-Discount Guarantee): Chiết khấu 100% phải luôn trả về chính xác Decimal("0.00"), bất kể thuế hay giá khởi điểm là bao nhiêu.
  3. Tính đơn điệu của giá (Price Monotonicity): Với bất kỳ giá cơ bản và mức thuế nào, việc tăng mức chiết khấu không bao giờ được làm tăng giá cuối cùng.

Bộ test tương ứng trong test_pricing.py thể hiện trực tiếp ba quy tắc này:

# test_pricing.py
from decimal import Decimal
from hypothesis import given, strategies as st
import pytest
from pricing import calculate_final_price

# Giới hạn giá trị trong phạm vi hoạt động thực tế
valid_money = st.decimals(min_value=Decimal("0.01"), max_value=Decimal("50000.00"), places=2)
valid_percentage = st.decimals(min_value=Decimal("0.00"), max_value=Decimal("100.00"), places=2)

@given(base_price=valid_money, tax=valid_percentage)
def test_zero_discount_preserves_or_increases_price(base_price, tax):
    result = calculate_final_price(base_price, Decimal("0.00"), tax)
    assert result >= base_price

@given(base_price=valid_money, tax=valid_percentage)
def test_full_discount_yields_zero(base_price, tax):
    result = calculate_final_price(base_price, Decimal("100.00"), tax)
    assert result == Decimal("0.00")

@given(
    base_price=valid_money,
    d1=valid_percentage,
    d2=valid_percentage,
    tax=valid_percentage,
)
def test_higher_discount_lowers_or_equals_price(base_price, d1, d2, tax):
    low_discount, high_discount = sorted([d1, d2])
    price_with_low = calculate_final_price(base_price, low_discount, tax)
    price_with_high = calculate_final_price(base_price, high_discount, tax)
    assert price_with_high <= price_with_low

3. Thực thi bộ kiểm thử

Chạy bộ test với chế độ hiển thị chi tiết (verbose output):

pytest test_pricing.py -v

Nếu ai đó vô tình thay đổi thứ tự làm tròn hoặc bỏ quên một cận đầu vào, Hypothesis không chỉ đơn thuần thông báo lỗi. Nó sẽ chỉ ra chính xác các giá trị biên vi phạm assertion và tự động thu gọn traceback về ca đầu vào tối thiểu nhất.

Hãy áp dụng property-based testing cho các logic phân tích cốt lõi, tính toán tài chính và tuần tự hóa dữ liệu. Các test case truyền thống giúp xác nhận các luồng dữ liệu chuẩn (happy path) hoạt động tốt; còn Hypothesis sẽ chứng minh rằng các quy tắc nghiệp vụ của bạn luôn đứng vững ở mọi trường hợp biên còn lại.

Share: