Phần 2 kết thúc với một bảng 24 ca mà tôi tự nghĩ ra. Bảng đó chỉ tốt bằng trí tưởng tượng của người viết: ca biên 16:00 có mặt vì tôi biết nó là biên. Lỗi thật thường nằm ở biên mà không ai nghĩ tới — số âm nhỏ hơn một đơn vị, hai ký tự lạ đứng cạnh nhau, danh sách có đúng một phần tử. Property-based testing đổi cách làm: thay vì liệt kê ví dụ, bạn phát biểu điều luôn đúng, để máy sinh hàng nghìn đầu vào, và khi tìm được phản ví dụ thì tự thu nhỏ nó về dạng nhỏ nhất còn làm lỗi. Bài này đo cả ba lời hứa đó trên bốn hàm có lỗi thật.
Bốn hàm, hai cách kiểm

Hình 1: Bốn lỗi được cấy ở biên; test ví dụ liệt kê ca, test tính chất phát biểu bất biến và giao việc sinh dữ liệu cho rapid.
Bốn hàm thuần kiểu hay gặp trong backend, mỗi hàm một lỗi:
FormatMoney/ParseMoney: xu sang chuỗi "đồng.xu" và ngược lại. Lỗi: với số âm nhỏ hơn một đồng, phần đồng là 0 nên dấu trừ biến mất — −5 xu thành "0.05".Slugify: chữ thường, ký tự lạ thành gạch nối, gộp gạch nối liên tiếp, cắt hai đầu. Lỗi: gộp trước khi thay, nên hai ký tự lạ liền nhau sinh ra "--" mà không được gộp.Paginate: chia danh sách thành trang. Lỗi: số trang tính bằnglen/size + 1, đúng khi có dư, thừa một trang rỗng khi chia hết.Merge: gộp các khoảng đóng chồng nhau. Lỗi: dùng<thay vì<=, nên hai khoảng chạm nhau ở biên không được gộp.
Test ví dụ (42 dòng) là những ca một người viết cẩn thận sẽ nghĩ ra: tiền 0, 5, 100, 1.234, −1.234; slug của "Hello World" và "x---y"; bảy phần tử chia ba; bốn khoảng chồng nhau. Có một chi tiết đáng chú ý ở ca Paginate([], 3): người viết chạy thử, thấy trả về một trang rỗng, thấy "cũng hợp lý", và ghi đúng hành vi đó vào test.
Test tính chất (78 dòng, dùng pgregory.net/rapid): tiền đi vòng qua định dạng rồi phân tích phải về đúng giá trị; slug phải luỹ đẳng (slug của slug bằng chính nó) và không chứa "--", không bắt đầu hay kết thúc bằng gạch; phân trang không có trang rỗng khi còn phần tử, mỗi trang không quá size, ghép lại đủ; gộp khoảng ra kết quả tăng dần, không chạm nhau, và phủ đúng tập điểm nguyên của đầu vào. Không một ví dụ cụ thể nào — chỉ có luật.
Thêm một bộ dùng testing/quick của thư viện chuẩn cho cùng tính chất slug, để so cái thiếu của nó: không thu nhỏ.
Đo thật: ai tìm được gì, và phản ví dụ dài bao nhiêu

Hình 2: Kết quả thật. Bên trái là mã có lỗi, bên phải là sau khi sửa; dòng cuối là test ví dụ đỏ trên mã đúng.
| hàm | test ví dụ | rapid | phản ví dụ thu nhỏ |
|---|---|---|---|
| FormatMoney/Parse | xanh (lọt) | đỏ ở ca #1 | -1 → "0.01" → 1 |
| Slugify | xanh (lọt) | đỏ ở ca #1 | "A?ᛮA" → "a--a" |
| Paginate | xanh (lọt) | đỏ ở ca #1 | [0], size 1 → 2 trang |
| Merge | xanh (lọt) | đỏ ở ca #1 | {0,3} {3,3} không gộp |
Ví dụ xanh 4/4, tính chất đỏ 4/4 — ngay ca sinh đầu tiên. Không phải may mắn: rapid cố ý sinh giá trị biên trước — số 0 và −1, chuỗi rỗng rồi chuỗi ngắn, danh sách một phần tử, size nhỏ nhất. Bốn lỗi này đều nằm ở biên, nên ca đầu tiên đã trúng. Test ví dụ thì xanh vì người viết đã chọn các giá trị "bình thường": −1.234 thay vì −1, "x---y" thay vì "x y", bảy phần tử thay vì ba, các khoảng chồng rõ rệt thay vì chạm biên. Đó không phải người viết kém; đó là bản chất của ví dụ — bạn chỉ viết ra ca mà bạn đã nghĩ tới.
Phản ví dụ sau khi thu nhỏ đọc được ngay. -1 xu là đủ để thấy dấu trừ mất ở đâu. "A?ᛮA" — bốn ký tự, hai ký tự lạ liền nhau — chỉ thẳng vào thứ tự gộp và thay. [0] với size 1 chỉ vào phép chia hết. {0,3} {3,3} chỉ vào dấu <. So với testing/quick của thư viện chuẩn trên cùng tính chất slug: tìm được lỗi, cũng ở ca đầu, nhưng phản ví dụ là 20 rune ngẫu nhiên toàn ký tự Unicode không đọc nổi. Thu nhỏ là thứ biến "có lỗi ở đâu đó" thành "lỗi ở đây".
Sửa xong, 40.000 ca trong 44 ms. Bốn tính chất với 100 ca mỗi tính chất chạy trong 2 ms; với 10.000 ca, 44 ms. Vì hàm thuần, chi phí mỗi ca là micro-giây, nên mặc định 100 ca của rapid rẻ tới mức có thể để trong bộ test thường; 10.000 ca để chạy đêm hoặc trước khi phát hành.
Và test ví dụ Paginate đỏ trên mã đúng. Paginate([], 3) giờ trả về không trang nào, test ví dụ muốn một trang rỗng — vì nó được viết bằng cách chạy mã có lỗi rồi ghi lại kết quả. Test ví dụ đóng băng hành vi tại thời điểm viết, kể cả hành vi sai; test tính chất đóng băng ý định ("không có trang rỗng khi còn phần tử"), nên nó đỏ trên mã sai và xanh trên mã đúng. Đây là lý do mạnh nhất để có cả hai: ví dụ nói "nó phải làm thế này với đầu vào này", tính chất nói "dù đầu vào gì, điều này phải đúng".
Đánh đổi cần cân nhắc
Khó nhất là tìm ra tính chất. Bốn hàm ở đây có tính chất tự nhiên: vòng tròn (định dạng rồi phân tích), luỹ đẳng (slug của slug), bảo toàn (ghép trang lại đủ), bất biến cấu trúc (không chồng, phủ đúng). Nhiều hàm nghiệp vụ không có tính chất đẹp như vậy, hoặc tính chất duy nhất là "bằng với cài đặt tham chiếu" — khi đó PBT vẫn dùng được nếu bạn có một cài đặt chậm nhưng rõ ràng đúng (ví dụ: Merge so với cách đánh dấu từng điểm nguyên, như bài này làm).
PBT bổ sung ví dụ, không thay thế. Ví dụ ghi lại ca nghiệp vụ cụ thể mà người đọc hiểu ngay; tính chất bắt biên mà không ai nghĩ tới. Khi rapid tìm được phản ví dụ, hãy thêm nó vào bảng ví dụ — giữ lại như hồi quy, đọc được không cần chạy rapid.
Bộ sinh quyết định cái bạn kiểm. rapid.String() sinh đủ loại Unicode nên tìm ra ᛮ; nếu bạn chỉ sinh ASCII, lỗi vẫn tìm được nhưng với chuỗi khác. Ngược lại, bộ sinh quá rộng làm nhiều ca vô nghĩa (số âm cho một hàm chỉ nhận dương) — dùng Filter hoặc thu hẹp miền, và chấp nhận rằng miền bạn chọn là một giả định cần ghi rõ.
Phạm vi đo. Bốn lỗi do tôi cấy, đều ở biên — nên PBT thắng 4–0 là tất yếu, không phải phát hiện. Thứ đáng mang về là cơ chế: sinh biên trước, thu nhỏ, và tính chất không đóng băng hành vi sai. Với lỗi không ở biên (sai logic giữa miền), PBT cần nhiều ca hơn và tính chất đủ chặt mới bắt được.
Ba ý mang về
- Ví dụ kiểm ca bạn nghĩ tới; tính chất kiểm ca bạn không nghĩ tới. Bốn lỗi ở biên lọt qua 42 dòng ví dụ và đỏ ngay ca đầu tiên của rapid, vì rapid sinh giá trị biên trước.
- Thu nhỏ là nửa giá trị của PBT: cùng lỗi slug,
testing/quicktrả 20 rune không đọc nổi, rapid trả"A?ᛮA". Chọn thư viện có thu nhỏ; thêm phản ví dụ tìm được vào bảng ví dụ làm hồi quy. - Test ví dụ đóng băng hành vi, kể cả hành vi sai (
Paginate([],3) == [[]]); test tính chất đóng băng ý định. Hàm thuần rẻ tới mức 40.000 ca chạy trong 44 ms — không có lý do để bộ test thường thiếu vài tính chất.
Nguồn
- Koen Claessen, John Hughes — QuickCheck: A Lightweight Tool for Random Testing of Haskell Programs (ICFP 2000): https://www.cs.tufts.edu/~nr/cs257/archive/john-hughes/quick.pdf
- pgregory.net/rapid — tài liệu và ví dụ: https://pkg.go.dev/pgregory.net/rapid
- Go — gói
testing/quick: https://pkg.go.dev/testing/quick - David R. MacIver — Hypothesis: Integrated shrinking: https://hypothesis.works/articles/integrated-shrinking/
- John Hughes — How to Specify It! A Guide to Writing Properties of Pure Functions (2019): https://research.chalmers.se/publication/517894/file/517894_Fulltext.pdf
Phần sau đi tiếp cùng hướng nhưng khác mục tiêu: thay vì tính chất nghiệp vụ, tìm panic và lỗi phân tích trong mã nhận đầu vào từ bên ngoài bằng go test -fuzz — đo thời gian tới lỗi đầu tiên và vai trò của corpus.