Table-Driven Tests trong Go — Viết test sạch, maintain dễ

Phong Hy

Vấn đề: Test function một — code cả tá

Bạn có một hàm CalculateDiscount(price float64, tier string) (float64, error) và cần test nó với 10+ cases khác nhau. Cách đơn giản nhất là viết 10 hàm TestXxx riêng — nhưng ai mà duyệt nổi 10 functions gần giống nhau?

func TestCalculateDiscount_Gold(t *testing.T) {
    result, err := CalculateDiscount(100, "gold")
    if err != nil || result != 20 {
        t.Fatalf("expected 20, got %f", result)
    }
}

func TestCalculateDiscount_Silver(t *testing.T) {
    result, err := CalculateDiscount(100, "silver")
    if err != nil || result != 10 {
        t.Fatalf("expected 10, got %f", result)
    }
}

Nhìn chán chưa? Mỗi lần thêm 1 case là copy-paste thêm 1 hàm. Bảo trì mệt, dễ miss edge case, lại còn dễ sai do quên sửa expected.

Table-Driven Tests — giải pháp gọn nhẹ

Go community có một pattern rất đẹp gọi là table-driven tests. Thay vì viết N hàm, bạn viết 1 hàm + 1 cái bảng (table) chứa tất cả test cases:

func TestCalculateDiscount(t *testing.T) {
    tests := []struct {
        name     string
        price    float64
        tier     string
        want     float64
        wantErr  bool
    }{
        {"gold member", 100, "gold", 20, false},
        {"silver member", 100, "silver", 10, false},
        {"bronze member", 100, "bronze", 5, false},
        {"zero price", 0, "gold", 0, false},
        {"negative price", -100, "gold", 0, true},
        {"unknown tier", 100, "platinum", 0, true},
        {"large discount cap", 10000, "gold", 2000, false},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := CalculateDiscount(tt.price, tt.tier)
            if (err != nil) != tt.wantErr {
                t.Fatalf("CalculateDiscount() error = %v, wantErr %v", err, tt.wantErr)
            }
            if got != tt.want {
                t.Errorf("CalculateDiscount() = %v, want %v", got, tt.want)
            }
        })
    }
}

Tại sao pattern này lại hay?

1. Dễ đọc, dễ maintain — Tất cả test cases nằm gọn trong một cái table. Thêm case mới chỉ việc thêm 1 dòng, không động vào logic.

2. Subtests với t.Run — Mỗi case chạy trong một subtest riêng. Khi fail, bạn biết chính xác case nào sai nhờ cái tên. Chạy go test -run TestCalculateDiscount/gold để debug riêng một case.

3. Tách data vs logic — Phần data (bảng test cases) tách biệt với phần logic (vòng lặp + assertions). Dễ review, dễ cover edge case.

Kinh nghiệm thực tế từ production

Sau 3 năm viết Go ở mình, đây là vài tips:

  • Dùng []struct{...} thay vì map[string]struct{...} — Map iteration order không deterministic, gây khó chịu khi debug. Dùng slice + name field để mỗi case có định danh rõ ràng.

  • Luôn include edge cases ngay từ đầu — Zero value, negative, empty string, nil slice. Để sau mới thêm là lúc bạn vừa hotfix vừa quên mất.

  • t.Run là chuẩn, đừng bỏ qua — Nhiều bạn mới viết for range rồi t.Fatal — cái này chết cả table khi 1 case fail. Dùng t.Run thì chỉ case đó fail, các case khác vẫn chạy.

  • Golden file cho output phức tạp — Kết quả là JSON/multiline string? Đừng hardcode trong struct. Dùng testdata/*.golden + cmp.Diff. Pattern này gọi là golden files.

// testdata/discount_gold.golden
{"price":100,"tier":"gold","discount":20,"final":80}

Kết luận

Table-driven tests không phải là pattern gì cao siêu — nó là idiomatic Go. Standard library, Docker CLI, Kubernetes — tất cả đều dùng. Nếu bạn đang viết Go mà chưa áp dụng, hãy bắt đầu ngay từ pull request tiếp theo. Code review sẽ dễ thở hơn, maintain lâu dài cũng nhàn hơn.