Unit test truyền thống hỏi “với input này, output có đúng không?”, còn property-based testing hỏi “quy tắc nào phải luôn đúng với cả một miền input?”. Công cụ sẽ sinh nhiều trường hợp kiểm thử, chạy cùng một property và khi tìm thấy lỗi, cố gắng thu nhỏ input thành phản ví dụ dễ hiểu.
Cách tiếp cận này đặc biệt hữu ích cho parser, serializer, thuật toán sắp xếp, xử lý ngày giờ, validation, state machine và các hàm có không gian input lớn. Nó không thay thế toàn bộ unit test; nó bổ sung một lăng kính giúp đội ngũ khám phá trường hợp biên mà con người khó liệt kê hết.
1. Ba thành phần cốt lõi
| Thành phần | Vai trò | Câu hỏi thiết kế |
|---|---|---|
| Property/invariant | Mệnh đề phải đúng với mọi input hợp lệ | Điều gì luôn đúng bất kể ví dụ cụ thể? |
| Generator | Tạo dữ liệu thuộc miền cần kiểm tra | Dữ liệu hợp lệ và edge case được tạo ra thế nào? |
| Shrinker | Thu nhỏ input gây lỗi | Phản ví dụ tối giản nào vẫn làm property thất bại? |
Vòng lặp cơ bản là: sinh input → kiểm tra precondition → chạy hệ thống → assert property → lưu và shrink phản ví dụ nếu thất bại. Một số framework còn tái sử dụng case đã thất bại từ database để regression được chạy lại ở những lần sau.
2. Property không phải một danh sách expected output khổng lồ
Property tốt mô tả quan hệ hoặc quy luật chung. Ví dụ, với hàm sort, không cần biết trước toàn bộ output cho mọi danh sách. Ta có thể kiểm tra output được sắp thứ tự, giữ nguyên số phần tử và giữ nguyên multiset giá trị.
property sort_preserves_values(xs):
ys = sort(xs)
assert is_non_decreasing(ys)
assert length(ys) == length(xs)
assert frequencies(ys) == frequencies(xs)
Chỉ kiểm tra sort(sort(xs)) == sort(xs) là chưa đủ: một implementation luôn trả về danh sách rỗng cũng idempotent. Nhiều property độc lập giúp tránh “oracle yếu” chấp nhận kết quả sai.
3. Các mẫu property thực dụng
- Round trip:
decode(encode(x)) == xvới mọi giá trị được hỗ trợ. - Inverse: thêm rồi xóa cùng phần tử đưa cấu trúc về trạng thái tương đương.
- Idempotence: chuẩn hóa hai lần cho kết quả như một lần.
- Commutativity: đổi thứ tự operand không đổi kết quả, nếu domain thật sự có tính chất này.
- Conservation: tổng tiền, số bản ghi hoặc multiset không tự sinh hay biến mất qua phép biến đổi.
- Metamorphic relation: thay input theo một quy tắc đã biết thì output phải thay theo quan hệ tương ứng.
- Model-based: hành vi implementation phải khớp mô hình đơn giản nhưng đáng tin cậy.
- No crash: mọi input hợp lệ không gây panic, exception ngoài contract hoặc treo vô hạn.
“Không crash” là điểm khởi đầu hữu ích cho parser, nhưng thường quá yếu để chứng minh kết quả đúng. Hãy bổ sung property về semantics.
4. Generator phải biểu diễn đúng miền dữ liệu
Generator chỉ tạo chuỗi ngẫu nhiên hiếm khi đủ. Input thường có cấu trúc: email hợp lệ, cây cú pháp, đơn hàng có ít nhất một dòng, khoảng thời gian có ngày bắt đầu không sau ngày kết thúc. Generator nên tạo dữ liệu hợp lệ theo construction thay vì sinh rác rồi loại bỏ phần lớn bằng filter.
Hãy chủ động bao phủ:
- Giá trị rỗng, một phần tử và kích thước rất lớn.
- Biên số nguyên, số âm, zero, giá trị trùng lặp.
- Unicode, ký tự kết hợp, khoảng trắng và chuỗi dài.
- Ngày chuyển tháng/năm, leap day, DST nếu domain liên quan.
- Cấu trúc sâu, rộng, mất cân bằng hoặc có nhiều nhánh giống nhau.
- Trạng thái hợp lệ hiếm gặp trong dữ liệu production.
Nếu generator phụ thuộc chặt vào implementation, cả hai có thể lặp cùng một sai lầm. Tốt hơn là sinh từ contract hoặc từ mô hình độc lập.
5. Shrinking biến lỗi ngẫu nhiên thành lỗi có thể sửa
Một input thất bại gồm 5.000 phần tử gần như vô dụng khi debug. Shrinker thử loại phần tử, giảm số, rút ngắn chuỗi hoặc đơn giản hóa cấu trúc trong khi vẫn giữ lỗi. Kết quả lý tưởng là phản ví dụ nhỏ nhất hoặc gần tối giản, chẳng hạn [0, -1] thay vì một danh sách dài.
Shrinking phải tôn trọng invariant của input. Nếu đơn hàng bắt buộc có ít nhất một dòng và tổng tiền khớp các dòng, shrinker không nên tạo object vô hiệu rồi báo lỗi validation không liên quan. Các framework hiện đại thường tích hợp generation và shrinking, nhưng custom type vẫn cần thiết kế cẩn thận.
6. Precondition và nguy cơ loại bỏ quá nhiều case
Property đôi khi chỉ hợp lệ khi có điều kiện, ví dụ phép chia cần mẫu khác zero. Có thể dùng assumption/precondition, nhưng nếu loại bỏ gần hết dữ liệu thì test vừa chậm vừa che phủ kém. Hãy chuyển điều kiện vào generator khi có thể.
# Kém hiệu quả: sinh mọi integer rồi bỏ zero
assume(divisor != 0)
# Tốt hơn: generator tạo integer thuộc miền khác zero
divisor = non_zero_integer()
Theo dõi tỷ lệ case bị reject và phân bố theo nhóm nghiệp vụ. “Chạy 10.000 case” không có nhiều ý nghĩa nếu 9.900 case cùng thuộc một vùng đơn giản.
7. Tái hiện lỗi: seed chưa phải toàn bộ câu chuyện
Khi lỗi xuất hiện, CI cần lưu phản ví dụ đã shrink, phiên bản code, cấu hình framework và nếu có thì seed. Chỉ lưu seed có thể không đủ khi generator hoặc phiên bản thư viện thay đổi. Một regression test tường minh cho phản ví dụ quan trọng giúp bảo vệ hành vi dài hạn.
- In case thất bại ở định dạng có thể copy hoặc serialize.
- Lưu case tối giản vào corpus/database của framework.
- Chuyển bug quan trọng thành example-based regression test.
- Không mặc định rerun đến khi test “xanh”; đó là che flakiness.
8. Kiểm thử stateful và chuỗi lệnh
Với API hoặc object có trạng thái, generator có thể tạo chuỗi command như tạo tài khoản, nạp tiền, chuyển tiền và đóng tài khoản. Một model đơn giản theo dõi trạng thái kỳ vọng; sau mỗi command, implementation được so với model và invariant hệ thống.
Stateful property-based testing có thể phát hiện bug chỉ xuất hiện theo một thứ tự hiếm. Shrinker sẽ cố rút chuỗi 200 bước thành vài thao tác cốt lõi. Tuy nhiên command generator phải hiểu precondition để không dành phần lớn thời gian cho thao tác bất hợp lệ.
9. Khi nào không nên dùng một mình?
| Nhu cầu | Cách kiểm thử phù hợp |
|---|---|
| Business example dễ đọc, làm tài liệu | Example-based unit/acceptance test |
| Không gian input lớn, nhiều quy luật đại số | Property-based test |
| Đầu vào byte tùy ý và mục tiêu crash/security | Fuzzing chuyên dụng kết hợp sanitizer |
| Luồng qua database, queue, network thật | Integration/contract test |
| Giao diện và trải nghiệm người dùng | E2E, visual và usability test |
Test pyramid tốt có thể dùng ví dụ để truyền đạt ý định, property để mở rộng miền input và integration test để kiểm tra wiring. Không cần biến mọi assertion thành property.
10. Anti-pattern thường gặp
- Property lặp lại nguyên implementation nên cùng sai theo một cách.
- Chỉ kiểm tra hàm không ném exception mà bỏ qua kết quả.
- Generator chỉ sinh dữ liệu “đẹp”, không chạm biên hoặc cấu trúc hiếm.
- Dùng random trực tiếp, không có cơ chế lưu và tái hiện case thất bại.
- Test gọi dịch vụ ngoài không kiểm soát, khiến kết quả chậm và flaky.
- Số case rất lớn nhưng property yếu hoặc miền sinh sai.
- Shrinker tạo dữ liệu invalid, làm phản ví dụ lệch khỏi bug thật.
11. Quy trình đưa vào dự án
- Chọn một hàm thuần, serializer hoặc validator có contract rõ.
- Viết 2–3 property độc lập trước khi tối ưu số case.
- Dùng generator có sẵn cho primitive; compose thành domain object hợp lệ.
- Chạy local với số case vừa phải và xem chất lượng shrinking.
- Phân loại case bằng label/statistics để nhìn phân bố.
- Lưu phản ví dụ trong CI và tạo regression test khi sửa bug.
- Sau đó mới mở rộng sang stateful test hoặc concurrency.
Checklist review property
- Property có thể thất bại nếu implementation trả kết quả hằng số vô nghĩa không?
- Generator có tạo giá trị biên và case hiếm không?
- Precondition có loại bỏ quá nhiều input không?
- Phản ví dụ có tái hiện được sau khi đổi seed hoặc phiên bản không?
- Shrinker có giữ dữ liệu hợp lệ theo domain không?
- Test có deterministic đối với dependency ngoài không?
- Case quan trọng đã được giữ làm regression test chưa?
Tài liệu tham khảo
- Claessen & Hughes: QuickCheck—A Lightweight Tool for Random Testing of Haskell Programs
- Hypothesis documentation: execution phases and shrinking
- PropEr: property-based testing for Erlang
Kết luận
Property-based testing chuyển trọng tâm từ vài ví dụ được chọn tay sang những quy luật phải đúng trên cả miền dữ liệu. Giá trị lớn nhất không chỉ nằm ở số lượng case, mà ở chất lượng invariant, generator đúng domain và khả năng shrink lỗi thành phản ví dụ nhỏ. Khi kết hợp với unit, integration và regression test, phương pháp này giúp đội ngũ tìm edge case sớm mà vẫn giữ bộ kiểm thử dễ chẩn đoán.




Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.