Property-based testing — Để máy tự sinh input tìm bug giùm mình

Phong Hy

Hồi đó mình có một cái bug mà nhớ hoài. Hàm tính giá sau giảm giá, test viết đủ mấy case "kinh điển": giảm 0%, 10%, 50%, thậm chí 100% — pass hết. Vậy mà lên production, user nào đó nhập phần trăm kiểu 100.5, thế là giá ra số âm. Cả team nhìn nhau, không hiểu vì sao test xanh mà vẫn lỗi.

Lý do đơn giản thôi: mình mới chỉ test những case mình nghĩ ra. Còn những case mình không nghĩ tới — thì không ai test. Hôm nay mình muốn kể về cách giải quyết chuyện đó: property-based testing.

Developer viết test Ảnh: Mikhail Nilov — Pexels

Property-based testing là gì?

Cách viết test truyền thống (example-based) kiểu: đưa input cụ thể, mong output cụ thể. Property-based testing thì ngược lại — mình không cần nghĩ input, mà khai báo một đặc tính (property) mà hàm phải luôn luôn đúng, rồi để máy tự sinh ra hàng trăm ngàn input random để tìm case phá vỡ đặc tính đó.

Ví dụ kinh điển nhất: hàm reverse. Property của nó là reverse(reverse(x)) == x. Viết bằng Go với thư viện rapid:

func TestReverseTwice(t *testing.T) {
    rapid.Check(t, func(t *rapid.T) {
        s := rapid.String().Draw(t, "input")
        if got := reverse(reverse(s)); got != s {
            t.Fatalf("reverse(reverse(%q)) = %q", s, got)
        }
    })
}

Chạy một phát, rapid sinh ra mấy chục ngàn chuỗi đủ loại — có ký tự unicode, emoji, chuỗi rỗng... Nếu có bug ở bất kỳ case nào, test đỏ ngay, và rapid còn shrink: rút gọn input lỗi về dạng tối thiểu để mình debug dễ hơn.

Case thực chiến của mình

Quay lại bug giá âm lúc nãy. Property mà mình cần là: giá sau giảm giá phải luôn nằm trong khoảng [0, tổng tiền]:

rapid.Check(t, func(t *rapid.T) {
    total := rapid.Float64Range(0, 1_000_000).Draw(t, "total")
    pct := rapid.Float64Range(0, 100).Draw(t, "discount_pct")

    got := applyDiscount(total, pct)
    if got < 0 || got > total {
        t.Fatalf("discount %v%% on %v -> %v (out of range)", pct, total, got)
    }
})

Chạy chưa tới 1 giây, nó bắt được ngay mấy case mình chưa từng nghĩ tới (total = 0, pct lẻ 100.5, làm tròn sai...). Mỗi lần sửa xong, chạy lại, nó lại tìm case khác. Kiểu chơi trốn tìm với máy vậy đó — mà máy thì không bao giờ chán.

Code debugging Ảnh: Daniil Komov — Pexels

Khi nào nên dùng, và mấy cái bẫy

Theo kinh nghiệm của mình, PBT mạnh nhất ở mấy chỗ:

  • Hàm thuần (pure function): parse, format, tính toán, transform dữ liệu
  • Có sẵn property "bất biến" rõ ràng: round-trip (encode/decode, serialize/deserialize), range, idempotency
  • Cấu trúc dữ liệu phức tạp: tree, graph, danh sách — nghĩ case tay không nổi

Còn mấy cái bẫy thì cũng đáng nói:

  • Đừng viết property trùng với implementation. Kiểu assert sort(x) == mySort(x) thì đúng là trò hề — phải assert cái đặc tính thật: kết quả sắp xếp phải tăng dần, và là hoán vị của input
  • Nhớ seed. Thư viện nào cũng cho seed — lưu lại khi test fail để reproduce
  • Không thay thế hoàn toàn example-based test. Mấy case biên mình biết trước (0, rỗng, null) vẫn nên viết tay. PBT là lớp bảo vệ thêm, không phải thay thế

Lập trình viên đang gõ code Ảnh: Andy Barbour — Pexels

Kết

Từ hôm dính bug giá âm, mình chuyển qua thói quen: hễ viết hàm nào có property rõ ràng là viết luôn PBT bên cạnh test thường. Tốn thêm chút thời gian khai báo, nhưng đổi lại máy làm việc cật lực giùm mình. Có ai từng bị bug kiểu "test xanh mà production đỏ" chưa? Chia sẻ nghe chơi — chứ mình cá là ai cũng có ít nhất một lần.

📋 Phụ lục thuật ngữ

  • Property-based testing (PBT) — kỹ thuật test khai báo đặc tính bất biến của hàm, để máy tự sinh input ngẫu nhiên kiểm tra
  • Property — đặc tính luôn đúng với mọi input hợp lệ, ví dụ reverse(reverse(x)) == x
  • Shrinking — cơ chế rút gọn input gây lỗi về dạng tối thiểu để dễ debug
  • Example-based testing — cách test truyền thống: input cụ thể, output mong đợi cụ thể
  • Round-trip property — đặc tính "đi rồi về": encode rồi decode phải ra dữ liệu gốc