Go có testing trong thư viện chuẩn, và nó không có assertEquals, không có @Test, không có mock framework. Hình dung viết test kiểu Go như chấm bài: bạn không nghĩ ra một nghi thức chấm riêng cho từng học sinh, bạn có một đáp án và một danh sách bài — chấm lần lượt xuống. Bài này về cách đó.

Test đầu tiên

// tệp tien_test.go, cùng package
func TestDinhDang(t *testing.T) {
	if got := DinhDang(100); got != "1,00" {
		t.Errorf("DinhDang(100) = %q, mong %q", got, "1,00")
	}
}

Quy ước: tệp *_test.go, hàm TestXxx(t *testing.T). Không cần chú thích, không cần đăng ký.

t.Errorf báo hỏng nhưng chạy tiếp; t.Fatalf dừng ngay test đó. Dùng Fatal khi các bước sau phụ thuộc bước này.

Không có assert là có chủ đích. Nhóm Go cho rằng if cộng thông điệp rõ ràng dễ đọc hơn một tầng trừu tượng, và nó buộc bạn viết thông điệp lỗi có ích — ghi hẳn "em viết X, đáp án là Y" thay vì đóng một dấu ✗ cụt lủn.

Thông điệp nên theo mẫu: got, want, và đầu vào. Khi CI đỏ lúc 6 giờ chiều thứ Sáu, đó là thứ bạn cần.

Bảng test: mẫu chuẩn của Go

cases := []struct {
	ten  string
	xu   int64
	mong string
}{
	{"không", 0, "0,00"},
	{"một xu", 1, "0,01"},
	{"âm", -150, "-1,50"},
}
for _, c := range cases {
	t.Run(c.ten, func(t *testing.T) {
		if got := DinhDang(c.xu); got != c.mong {
			t.Errorf("DinhDang(%d) = %q, mong %q", c.xu, got, c.mong)
		}
	})
}
  === RUN   TestDinhDang/không
  === RUN   TestDinhDang/một_xu
  === RUN   TestDinhDang/âm
  --- PASS: TestDinhDang (0.00s)
      --- PASS: TestDinhDang/một_xu (0.00s)

t.Run tạo subtest có tên riêng, chạy độc lập, và hỏng một cái không che các cái khác — mỗi bài một dấu PASS/FAIL riêng, một bài sai không giấu các bài còn lại. Đây là tương đương @ParameterizedTest của JUnit, nhưng không cần chú thích nào.

Chạy riêng một trường hợp:

go test -run 'TestDinhDang/âm'

Thêm trường hợp mới là thêm một dòng vào bảng — thêm một học sinh vào danh sách, không viết lại quy trình chấm. Đó là lý do mẫu này phổ biến tới mức gần như bắt buộc trong mã Go.

t.Parallel

t.Run(n, func(t *testing.T) {
	t.Parallel()
	...
})

Subtest gọi t.Parallel() sẽ tạm dừng, và chạy song song với các subtest song song khác sau khi hàm cha xong.

Rất đáng cho test có I/O. Nhưng nhớ: chúng dùng chung mọi trạng thái toàn cục, nên phải chạy được độc lập — và nên chạy với -race.

Tổ chức

package tien (cùng package) — test được cả hàm viết thường. Đây là mặc định.

package tien_test (khác package) — chỉ thấy API công khai. Dùng khi muốn kiểm rằng API của bạn đủ dùng từ bên ngoài, và tránh phụ thuộc vòng.

Hai kiểu này cùng tồn tại trong một thư mục được — Go cho phép đúng ngoại lệ này.

TestMain để dựng và dọn cho cả gói:

func TestMain(m *testing.M) {
	dungCSDL()
	ma := m.Run()
	donDep()
	os.Exit(ma)
}

t.Cleanup để dọn theo từng test, chạy kể cả khi test hỏng:

t.Cleanup(func() { os.Remove(tep) })

t.TempDir cho thư mục tạm tự dọn — gọn hơn os.MkdirTemp cộng defer.

Độ phủ

go test -cover
go test -coverprofile=c.out && go tool cover -html=c.out
  coverage: 18.8% of statements

Lệnh thứ hai mở trình duyệt với mã được tô màu — dòng nào chưa chạy hiện đỏ. Đây là cách hữu ích nhất để dùng độ phủ: tìm nhánh chưa test, không phải để đạt một con số.

Đừng đặt mục tiêu phần trăm. Độ phủ 100% vẫn có thể không kiểm gì cả nếu test không có assertion đúng.

Fuzzing: có sẵn từ Go 1.18

func FuzzDinhDang(f *testing.F) {
	f.Add(int64(0))
	f.Add(int64(-1))
	f.Fuzz(func(t *testing.T, xu int64) {
		if DinhDang(xu) == "" { t.Fatal("chuỗi rỗng") }
	})
}
  go test -fuzz=FuzzDinhDang -fuzztime=5s
  fuzz: elapsed: 5s, execs: 4846391 (916029/sec), new interesting: 1

4,8 triệu lần chạy trong 5 giây, với đầu vào do Go tự sinh và tự tiến hoá theo độ phủ mã — thay vì những bài bạn tự chọn, một cái máy ném hàng triệu bài ngẫu nhiên vào quy trình chấm để tìm cái làm nó vỡ.

Khi tìm được đầu vào làm hỏng, Go lưu nó vào testdata/fuzz/ và biến thành test thường — nên nó không bao giờ quay lại.

Rất hợp cho hàm phân tích dữ liệu, giải mã, xử lý chuỗi. Đây là thứ Java không có trong thư viện chuẩn.

Test HTTP: httptest

srv := httptest.NewServer(mux)
defer srv.Close()
resp, _ := http.Get(srv.URL + "/don/1")

Hoặc gọi handler trực tiếp, không qua mạng:

w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/don/1", nil)
mux.ServeHTTP(w, r)
if w.Code != 200 { t.Errorf("mã = %d", w.Code) }

Cách thứ hai nhanh hơn nhiều và đủ cho phần lớn test handler.

Có nên dùng thư viện

testify rất phổ biến (assert.Equal, require.NoError, mock). Nó làm test ngắn hơn.

Nhưng cộng đồng Go chia rẽ về nó, và lập luận phản đối có lý: thư viện chuẩn đã đủ, và if tường minh làm rõ chính xác điều gì được kiểm.

Lời khuyên của tôi: bắt đầu bằng thư viện chuẩn. Với so sánh struct phức tạp thì google/go-cmp đáng thêm — nó in ra khác biệt thay vì chỉ "không bằng nhau":

if d := cmp.Diff(mong, got); d != "" {
	t.Errorf("khác biệt (-mong +got):\n%s", d)
}

Nếu chỉ chạy một thứ sau bài này, chạy bộ test của bạn theo cách khắt khe hơn trong ba mươi giây:

go test -race -count=5 ./...

-count=5 chạy mỗi test năm lần, tăng cơ hội trúng đường mã hiếm; -race bắt lỗi đua như bài 36. Nếu bộ test của bạn xanh với lệnh này, nó đáng tin hơn hẳn so với go test ./... trần.

Mẫu số chung

Tách các ca test (dữ liệu) khỏi logic test (một quy trình chạy xuống danh sách) là mẫu tham-số-hoá/lái-bằng-dữ-liệu mà hệ sinh thái nào cũng mọc ra: bảng cộng t.Run của Go, @ParameterizedTest của JUnit, @parametrize của pytest, và ở đầu kia là test-theo-tính-chất cùng fuzzing (QuickCheck, Hypothesis, fuzz của Go). Ca test nên là dữ liệu rẻ bạn nối thêm, không phải mã bạn chép: thân test chép đi chép lại thì mục ruỗng và giấu mất lỗ hổng, còn một cái bảng làm tập đầu vào hiện rõ và báo từng ca riêng. Và bước từ ca-bạn-chọn sang ca-máy-sinh là cùng một nước đi — thôi nhặt ví dụ, hãy phát biểu bất biến rồi để máy đi săn phản ví dụ (4,8 triệu cái trong năm giây), và nó giữ cái làm vỡ lại làm test hồi quy vĩnh viễn.

Điều thứ hai: giá trị một test nằm ở cái assertion và cái thông điệp lỗi, không bao giờ ở con số. Độ phủ là tấm bản đồ để tìm nhánh chưa test, không phải một cái đích — 100% với assertion hời hợt thì chẳng kiểm gì (định luật Goodhart: một thước đo khi thành mục tiêu thì thôi đo đúng) — và cái thông điệp bạn viết (got, want, đầu vào; hay một khác-biệt có cấu trúc) mới là thứ bạn thật sự đọc lúc CI đỏ 6 giờ chiều thứ Sáu. Cùng sợi chỉ với hỏng-ồn-ào-và-rõ và đo-đúng-thứ: mục đích của một phép kiểm là tín hiệu nó phát ra khi vỡ. Cũng chính vì vậy Go cố tình bỏ thư viện assertion — ưu tiên dạng tường minh, đơn giản, và chỉ thêm thư viện ở chỗ nó xứng đáng (go-cmp khi cần một diff), đúng phép tính "mặc định chọn đơn giản" lặp lại suốt sê-ri này.

Ngày mai: benchmark và pprof — đo hiệu năng cho đúng.

Bài tập làm thử

Bài 1 (đọc hiểu). Trong đoạn test bảng sau, nếu trường hợp {"âm", -150, "-1,50"} thất bại (hàm DinhDang trả về sai), điều gì xảy ra với hai trường hợp còn lại trong bảng khi chạy go test?

cases := []struct {
	ten  string
	xu   int64
	mong string
}{
	{"không", 0, "0,00"},
	{"một xu", 1, "0,01"},
	{"âm", -150, "-1,50"},
}
for _, c := range cases {
	t.Run(c.ten, func(t *testing.T) {
		if got := DinhDang(c.xu); got != c.mong {
			t.Errorf("DinhDang(%d) = %q, mong %q", c.xu, got, c.mong)
		}
	})
}
Đáp án

Hai trường hợp còn lại ("không" và "một xu") vẫn chạy bình thường và báo PASS/FAIL độc lập. t.Run tạo một subtest riêng cho mỗi trường hợp, chạy độc lập với các subtest khác — một trường hợp hỏng không che giấu hay ảnh hưởng tới kết quả của các trường hợp còn lại. Kết quả sẽ có dạng: PASS cho "không" và "một xu", FAIL riêng cho "âm" với thông điệp lỗi cụ thể.

Bài 2 (sửa lỗi). Đoạn test sau dùng t.Errorf sai chỗ, khiến các bước sau vẫn chạy dù dữ liệu chuẩn bị đã hỏng, dẫn đến panic khó hiểu. Sửa bằng hàm đúng:

func TestLayDon(t *testing.T) {
	don, err := taoDonMau()
	if err != nil {
		t.Errorf("không tạo được đơn mẫu: %v", err) // vẫn chạy tiếp dù don == nil
	}
	if don.ID == "" { // panic nếu don là nil
		t.Error("thiếu ID")
	}
}
Đáp án

Dùng t.Fatalf thay vì t.Errorf khi bước sau phụ thuộc vào bước này thành công:

func TestLayDon(t *testing.T) {
	don, err := taoDonMau()
	if err != nil {
		t.Fatalf("không tạo được đơn mẫu: %v", err)
	}
	if don.ID == "" {
		t.Error("thiếu ID")
	}
}

t.Errorf báo hỏng nhưng cho phép test chạy tiếp; t.Fatalf dừng ngay lập tức test đó (gọi runtime.Goexit()). Vì các dòng sau phụ thuộc vào don không phải nil, phải dùng Fatalf để tránh panic truy cập trường của nil.

Bài 3 (vận dụng — code Go). Viết một bảng test cho hàm ChiaHet(a, b int) (int, error) (trả lỗi khi b == 0), gồm ít nhất 3 trường hợp: chia hết bình thường, chia cho 0 (mong đợi lỗi), và số âm.

Đáp án
func TestChiaHet(t *testing.T) {
	cases := []struct {
		ten     string
		a, b    int
		mong    int
		mongLoi bool
	}{
		{"chia het", 10, 2, 5, false},
		{"chia cho 0", 10, 0, 0, true},
		{"so am", -10, 2, -5, false},
	}
	for _, c := range cases {
		t.Run(c.ten, func(t *testing.T) {
			got, err := ChiaHet(c.a, c.b)
			if c.mongLoi {
				if err == nil {
					t.Errorf("ChiaHet(%d,%d) mong có lỗi, không có", c.a, c.b)
				}
				return
			}
			if err != nil {
				t.Fatalf("ChiaHet(%d,%d) lỗi bất ngờ: %v", c.a, c.b, err)
			}
			if got != c.mong {
				t.Errorf("ChiaHet(%d,%d) = %d, mong %d", c.a, c.b, got, c.mong)
			}
		})
	}
}

Bài 4 (bẫy/đánh đổi). Một đội phát triển đặt mục tiêu "đạt 100% độ phủ test" cho một hàm phân tích chuỗi, và viết test sau để đạt mục tiêu đó:

func TestPhanTich(t *testing.T) {
	PhanTich("abc")
}

Giải thích tại sao test này đạt 100% độ phủ dòng lệnh (nếu hàm không rẽ nhánh) nhưng gần như vô giá trị, theo đúng lập luận của bài viết.

Đáp án

Test này chỉ gọi hàm mà không có bất kỳ assertion nào kiểm tra kết quả trả về đúng hay sai — nó sẽ luôn PASS bất kể PhanTich trả về gì (kể cả kết quả sai hoàn toàn), miễn là hàm không panic. Độ phủ đo được dòng lệnh nào đã chạy qua, không đo được liệu kết quả có được kiểm tra đúng hay không. Bài viết gọi đây là ví dụ về định luật Goodhart: khi độ phủ trở thành mục tiêu, nó thôi đo đúng chất lượng test. Giá trị thật của một test nằm ở assertion và thông điệp lỗi (got, want, đầu vào), không phải ở con số phần trăm.

Bài 5 (đọc hiểu số liệu/khái niệm). Giải thích sự khác biệt giữa test bảng thông thường (table-driven test) và fuzzing, dựa trên số liệu trong bài (4.846.391 lần chạy trong 5 giây). Fuzzing giải quyết vấn đề gì mà test bảng không giải quyết được?

Đáp án

Test bảng dùng các trường hợp do lập trình viên tự chọn — hữu hạn, dựa trên trực giác về những ca cần kiểm. Fuzzing để Go tự sinh hàng triệu đầu vào ngẫu nhiên (4.846.391 lần trong 5 giây, ~916.029 lần/giây) và tự tiến hoá đầu vào theo độ phủ mã đạt được, nhằm tìm ra những đầu vào bất ngờ làm hỏng bất biến mà lập trình viên không nghĩ tới. Fuzzing giải quyết vấn đề "tôi không biết trước ca nào sẽ làm hỏng hàm của mình" — thay vì liệt kê ví dụ, bạn phát biểu một bất biến (ví dụ "kết quả không bao giờ rỗng") và để máy đi săn phản ví dụ; khi tìm được, Go tự lưu nó vào testdata/fuzz/ để nó vĩnh viễn trở thành một test hồi quy.