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:

  • tests là 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 name giúp bạn nhận ra case nào bị fail.
  • Vòng for gọi hàm và so sánh kết quả. Thông báo lỗi theo mẫu F(input) = got, want X là 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:

  1. t.Fatalf dừng toàn bộ test. Một case lỗi sẽ khiến các case phía sau không được chạy.
  2. 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, wantErr mang zero value nil. 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ề false và test fail ngay.
  • errors.Is vẫn hoạt động khi lỗi được wrap bằng fmt.Errorf("...: %w", err). Phép so sánh err == tc.wantErr thì 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 bool hay expectCall int, rồi thân vòng lặp đầy if, 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.Fatal ngoài t.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: 50000 với GIAM50K).

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 qua f.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.Is với trường wantErr 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.Diff khi 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.
golangtestingtable-driven testunit test