Table-driven test trong Go
1. Tổng quan
Khi một hàm có nhiều nhánh xử lý, bạn sẽ muốn test nhiều bộ input/output khác nhau. Nếu viết mỗi trường hợp thành một hàm test riêng, code test sẽ lặp lại rất nhiều và khó bảo trì.
Table-driven test là cách viết test phổ biến nhất trong cộng đồng Go. Bạn mô tả các trường hợp test dưới dạng một “bảng” dữ liệu, rồi dùng một vòng lặp duy nhất để chạy tất cả.
Đọc xong bài này, bạn sẽ biết cách viết table-driven test có subtest, kiểm tra lỗi đúng cách, chạy test song song và so sánh struct phức tạp. Bạn cũng sẽ biết khi nào không nên dùng kỹ thuật này.
2. Hàm cần test
Ta dùng một hàm tính tiền sau khi áp mã giảm giá cho đơn hàng. Hàm này đủ nhiều nhánh để minh họa:
// pricing/discount.go
package pricing
import (
"errors"
"strings"
)
var (
ErrNegativeTotal = errors.New("tổng tiền không được âm")
ErrInvalidCode = errors.New("mã giảm giá không hợp lệ")
)
// ApplyDiscount trả về số tiền (đồng) sau khi áp mã giảm giá.
func ApplyDiscount(total int, code string) (int, error) {
if total < 0 {
return 0, ErrNegativeTotal
}
switch strings.ToUpper(strings.TrimSpace(code)) {
case "":
return total, nil
case "SALE10":
return total * 90 / 100, nil
case "GIAM50K":
if total < 50000 {
return 0, nil
}
return total - 50000, nil
default:
return 0, ErrInvalidCode
}
}
Hàm có năm hành vi cần kiểm tra. Đó là tổng tiền âm, không có mã, mã SALE10, mã GIAM50K (hai nhánh) và mã không tồn tại. Ngoài ra, mã còn được chuẩn hóa bằng TrimSpace và ToUpper.
3. Cấu trúc cơ bản của table-driven test
3.1. Phiên bản đầu tiên
Một table-driven test gồm ba phần: khai báo bảng, lặp qua bảng và so sánh kết quả.
// pricing/discount_test.go
package pricing
import "testing"
func TestApplyDiscount(t *testing.T) {
tests := []struct {
name string
total int
code string
want int
}{
{name: "khong co ma", total: 100000, code: "", want: 100000},
{name: "sale10", total: 100000, code: "SALE10", want: 90000},
{name: "sale10 chu thuong va khoang trang", total: 100000, code: " sale10 ", want: 90000},
{name: "giam50k binh thuong", total: 200000, code: "GIAM50K", want: 150000},
{name: "giam50k khong am tien", total: 30000, code: "GIAM50K", want: 0},
}
for _, tc := range tests {
got, err := ApplyDiscount(tc.total, tc.code)
if err != nil {
t.Fatalf("%s: lỗi không mong đợi: %v", tc.name, err)
}
if got != tc.want {
t.Errorf("%s: ApplyDiscount(%d, %q) = %d, want %d",
tc.name, tc.total, tc.code, got, tc.want)
}
}
}
Giải thích:
testslà một slice của anonymous struct. Mỗi phần tử là một test case, gồm input (total,code) và output mong đợi (want).- Trường
namegiúp bạn nhận ra case nào bị fail. - Vòng
forgọi hàm và so sánh kết quả. Thông báo lỗi theo mẫuF(input) = got, want Xlà quy ước quen thuộc trong Go.
Muốn thêm case mới, bạn chỉ cần thêm một dòng vào bảng. Đây là lợi ích lớn nhất của kỹ thuật này.
3.2. Vấn đề của phiên bản đầu tiên
Code trên chạy được nhưng có hai nhược điểm:
t.Fatalfdừng toàn bộ test. Một case lỗi sẽ khiến các case phía sau không được chạy.- Bạn không thể chạy riêng một case khi debug.
Subtest giải quyết cả hai vấn đề này.
4. Dùng subtest với t.Run
t.Run tạo một subtest cho mỗi case. Mỗi subtest có *testing.T riêng, nên t.Fatal chỉ dừng case hiện tại.
func TestApplyDiscount(t *testing.T) {
tests := []struct {
name string
total int
code string
want int
}{
{name: "khong co ma", total: 100000, code: "", want: 100000},
{name: "sale10", total: 100000, code: "SALE10", want: 90000},
{name: "sale10 chu thuong va khoang trang", total: 100000, code: " sale10 ", want: 90000},
{name: "giam50k binh thuong", total: 200000, code: "GIAM50K", want: 150000},
{name: "giam50k khong am tien", total: 30000, code: "GIAM50K", want: 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := ApplyDiscount(tc.total, tc.code)
if err != nil {
t.Fatalf("lỗi không mong đợi: %v", err)
}
if got != tc.want {
t.Errorf("ApplyDiscount(%d, %q) = %d, want %d",
tc.total, tc.code, got, tc.want)
}
})
}
}
Bạn không cần in tc.name trong thông báo lỗi nữa, vì Go đã gắn tên subtest vào output. Chạy với -v:
go test ./pricing -v
=== RUN TestApplyDiscount
=== RUN TestApplyDiscount/khong_co_ma
=== RUN TestApplyDiscount/sale10
=== RUN TestApplyDiscount/sale10_chu_thuong_va_khoang_trang
=== RUN TestApplyDiscount/giam50k_binh_thuong
=== RUN TestApplyDiscount/giam50k_khong_am_tien
--- PASS: TestApplyDiscount (0.00s)
--- PASS: TestApplyDiscount/khong_co_ma (0.00s)
--- PASS: TestApplyDiscount/sale10 (0.00s)
--- PASS: TestApplyDiscount/sale10_chu_thuong_va_khoang_trang (0.00s)
--- PASS: TestApplyDiscount/giam50k_binh_thuong (0.00s)
--- PASS: TestApplyDiscount/giam50k_khong_am_tien (0.00s)
PASS
Bạn thấy khoảng trắng trong tên đã được thay bằng dấu _. Để chạy riêng một case, bạn truyền regex vào -run:
go test ./pricing -run 'TestApplyDiscount/giam50k' -v
Lệnh này chỉ chạy hai case có tên bắt đầu bằng giam50k. Khi một case fail, output sẽ trông như sau:
--- FAIL: TestApplyDiscount (0.00s)
--- FAIL: TestApplyDiscount/sale10 (0.00s)
discount_test.go:24: ApplyDiscount(100000, "SALE10") = 90000, want 85000
Bạn biết ngay case nào hỏng, ở dòng nào, với input gì.
Lưu ý: Hãy đặt tên case ngắn, không trùng nhau và dễ gõ lại trên terminal. Nếu hai case trùng tên, Go sẽ tự thêm hậu tố
#01, khiến việc chạy riêng một case trở nên khó hơn.
5. Kiểm tra lỗi trong bảng test
Các case trả về lỗi cũng nên nằm trong cùng bảng. Bạn thêm trường wantErr kiểu error, rồi so sánh bằng errors.Is:
package pricing
import (
"errors"
"testing"
)
func TestApplyDiscount_Errors(t *testing.T) {
tests := []struct {
name string
total int
code string
want int
wantErr error
}{
{name: "sale10", total: 100000, code: "SALE10", want: 90000},
{name: "tong tien am", total: -1, code: "SALE10", wantErr: ErrNegativeTotal},
{name: "ma khong ton tai", total: 100000, code: "TET2026", wantErr: ErrInvalidCode},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := ApplyDiscount(tc.total, tc.code)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
}
if tc.wantErr != nil {
return // đã kiểm tra lỗi, không cần xét giá trị trả về
}
if got != tc.want {
t.Errorf("got %d, want %d", got, tc.want)
}
})
}
}
Giải thích:
- Với case thành công,
wantErrmang zero valuenil. Khi đóerrors.Is(nil, nil)trả vềtrue. - Nếu hàm trả về lỗi trong khi bạn không mong đợi,
errors.Is(err, nil)trả vềfalsevà test fail ngay. errors.Isvẫn hoạt động khi lỗi được wrap bằngfmt.Errorf("...: %w", err). Phép so sánherr == tc.wantErrthì không.
Nhiều codebase dùng wantErr bool cho đơn giản. Cách đó chỉ cho biết có lỗi hay không, chứ không cho biết lỗi gì. Bạn nên ưu tiên so sánh với sentinel error nếu package có định nghĩa.
6. Chạy song song với t.Parallel
Khi test case chậm (gọi I/O, xử lý nặng), bạn có thể cho các subtest chạy song song:
func TestApplyDiscount_Parallel(t *testing.T) {
tests := []struct {
name string
total int
code string
want int
}{
{name: "khong co ma", total: 100000, code: "", want: 100000},
{name: "sale10", total: 100000, code: "SALE10", want: 90000},
{name: "giam50k", total: 200000, code: "GIAM50K", want: 150000},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := ApplyDiscount(tc.total, tc.code)
if err != nil {
t.Fatalf("lỗi không mong đợi: %v", err)
}
if got != tc.want {
t.Errorf("got %d, want %d", got, tc.want)
}
})
}
}
t.Parallel() báo cho test runner rằng subtest này có thể chạy cùng các subtest song song khác. Hàm cha sẽ tạm dừng subtest lại, chạy xong vòng lặp rồi mới thả tất cả subtest chạy đồng thời.
Từ Go 1.22, mỗi vòng lặp for tạo một biến tc mới, nên closure luôn bắt đúng giá trị. Với Go 1.21 trở về trước, bạn bắt buộc phải sao chép biến:
for _, tc := range tests {
tc := tc // cần cho Go < 1.22
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// ...
})
}
Nếu thiếu dòng tc := tc trên phiên bản cũ, mọi subtest đều dùng case cuối cùng. Test vẫn “pass” nhưng thực ra chỉ kiểm tra một trường hợp. Đây là lỗi rất khó phát hiện. Hãy kiểm tra dòng go trong go.mod của bạn.
Lưu ý: Chỉ dùng
t.Parallel()khi các case không chia sẻ trạng thái có thể thay đổi, như biến global, file tạm dùng chung hay cùng một bảng database. Với hàm thuần nhưApplyDiscount, lợi ích gần như bằng không. Bạn đừng thêm nó theo thói quen.
7. So sánh struct phức tạp với go-cmp
Khi hàm trả về struct lồng nhau, toán tử != không dùng được cho struct chứa slice hoặc map. Còn reflect.DeepEqual thì chỉ trả về true/false, không cho biết khác ở đâu. Thư viện go-cmp giải quyết việc này. Bạn thêm nó vào module:
go get github.com/google/go-cmp/cmp
// go.mod (trích)
require github.com/google/go-cmp v0.6.0
Giả sử bạn có hàm tạo hóa đơn:
// pricing/invoice.go
package pricing
type Invoice struct {
Customer string
Items []string
Total int
}
func BuildInvoice(customer string, items []string, total int, code string) (Invoice, error) {
final, err := ApplyDiscount(total, code)
if err != nil {
return Invoice{}, err
}
return Invoice{Customer: customer, Items: items, Total: final}, nil
}
Test dùng cmp.Diff:
package pricing
import (
"testing"
"github.com/google/go-cmp/cmp"
)
func TestBuildInvoice(t *testing.T) {
tests := []struct {
name string
customer string
items []string
total int
code string
want Invoice
}{
{
name: "co ma sale10",
customer: "An",
items: []string{"ao", "mu"},
total: 200000,
code: "SALE10",
want: Invoice{Customer: "An", Items: []string{"ao", "mu"}, Total: 180000},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := BuildInvoice(tc.customer, tc.items, tc.total, tc.code)
if err != nil {
t.Fatalf("lỗi không mong đợi: %v", err)
}
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Errorf("BuildInvoice() lệch (-want +got):\n%s", diff)
}
})
}
}
cmp.Diff trả về chuỗi rỗng khi hai giá trị bằng nhau. Nếu khác nhau, bạn nhận được một bản diff dễ đọc:
BuildInvoice() lệch (-want +got):
pricing.Invoice{
Customer: "An",
Items: {"ao", "mu"},
- Total: 180000,
+ Total: 200000,
}
Dấu - là giá trị mong đợi, dấu + là giá trị thực tế. Quy ước (-want +got) nên được ghi rõ trong thông báo để người đọc không phải đoán.
8. Lỗi hay gặp và khi nào không nên dùng
Một số lỗi thường gặp khi viết table-driven test:
- Bảng phình to với quá nhiều trường cờ. Khi struct test có các trường như
setupMock bool,skipValidation boolhayexpectCall int, rồi thân vòng lặp đầyif, bạn đang viết logic trong test. Lúc đó hãy tách thành các hàm test riêng. - Dùng
t.Fatalngoàit.Run. Một case fail sẽ làm mất kết quả của mọi case sau nó. - Dùng map thay slice mà không hiểu hệ quả.
map[string]struct{...}giúp tên case không thể trùng và thứ tự chạy ngẫu nhiên, nên dễ lộ ra phụ thuộc ẩn giữa các case. Tuy vậy, output sẽ không theo thứ tự cố định. Cả hai cách đều chấp nhận được, miễn là cả team thống nhất. - Thông báo lỗi thiếu input. Chỉ viết
t.Errorf("sai")buộc người debug phải mở code test ra đọc. Hãy luôn in input, got và want. - Quên case biên. Bảng giúp bạn dễ nhìn thấy những case còn thiếu. Hãy rà lại các giá trị 0, số âm, chuỗi rỗng và giá trị ngay sát ngưỡng (ví dụ
total: 50000vớiGIAM50K).
Bạn không nên dùng table-driven test khi:
- Mỗi case cần setup rất khác nhau, ví dụ mock trả về dữ liệu khác hoặc thứ tự gọi khác. Ép chúng vào một bảng chỉ làm test khó đọc hơn.
- Bạn chỉ có một hoặc hai case. Một hàm test thẳng thắn sẽ dễ hiểu hơn.
- Bạn cần khám phá input ngẫu nhiên. Lúc đó hãy cân nhắc fuzz test (
func FuzzXxx(f *testing.F)), có sẵn từ Go 1.18. Bạn có thể dùng chính bảng test làm seed corpus thông quaf.Add.
9. Kết luận
Table-driven test giúp bạn gom nhiều trường hợp kiểm thử vào một bảng dữ liệu và chạy chúng bằng một vòng lặp duy nhất. Kết hợp với t.Run, mỗi case trở thành một subtest độc lập. Bạn có thể chạy riêng từng case bằng -run, và một case lỗi không làm dừng các case khác.
Những điểm cần nhớ:
- Mỗi case nên có
name, input và output mong đợi. - Kiểm tra lỗi bằng
errors.Isvới trườngwantErr error. - Chỉ dùng
t.Parallel()khi thật sự cần, và chú ý ngữ nghĩa biến vòng lặp trước Go 1.22. - Dùng
cmp.Diffkhi so sánh struct phức tạp. - Khi bảng bắt đầu chứa logic điều khiển, đó là dấu hiệu bạn nên tách test ra.