Code được test
Công cụ test của Go là một phần của bộ công cụ chuẩn: package testing và lệnh go test. Không có framework nào phải cài, không cần thư viện assertion.
Các ví dụ trên trang này test một hàm nhỏ, Slugify, biến một tiêu đề thành slug cho URL. Đây là hàm đó, chạy được, kèm một bước kiểm tra tự viết trong main:
Trình soạn thảo trên trình duyệt chạy main; nó không chạy được go test. Mọi thứ bên dưới được viết đúng như trong một dự án thật, với đầu ra terminal hiện sau mỗi lệnh. Tất cả đều đã được chạy với Go 1.24.
Test đầu tiên
Trong một dự án, hàm nằm trong một package, và test của nó nằm ngay bên cạnh trong một tệp có tên kết thúc bằng _test.go:
textutil/
├── go.mod module example.com/textutil
├── slug.go package textutil, func Slugify
└── slug_test.go package textutil, the tests
// slug_test.go
package textutil
import "testing"
func TestSlugify(t *testing.T) {
got := Slugify("Hello, World!")
want := "hello-world"
if got != want {
t.Errorf("Slugify(%q) = %q, want %q", "Hello, World!", got, want)
}
}
Các quy tắc mà go test dùng:
- Chỉ những tệp kết thúc bằng
_test.gomới là tệp test.go buildbỏ qua chúng, nên code test không bao giờ lọt vào tệp nhị phân của bạn. - Test là một hàm tên
TestXxx(phần sauTestkhông được bắt đầu bằng chữ thường) nhận một*testing.T. - Test được tính là đạt trừ khi nó gọi một trong các phương thức báo thất bại hoặc bị panic.
go test # the package in the current directory
go test ./... # every package in the module
$ go test
PASS
ok example.com/textutil 0.318s
Định dạng thông báo lỗi Func(input) = got, want expected là quy ước của Go. Hãy đưa đầu vào vào thông báo: một lỗi chỉ ghi got "a", want "b" sẽ bắt bạn quay lại code để tìm xem đó là đầu vào nào.
t.Errorf và t.Fatalf
| Phương thức | Đánh dấu thất bại | Dừng test |
|---|---|---|
t.Error, t.Errorf | có | không, vẫn chạy tiếp |
t.Fatal, t.Fatalf | có | có, ngay lập tức |
t.Log, t.Logf | không | không, chỉ in ra khi có -v hoặc khi thất bại |
t.Skip, t.Skipf | không | có, được báo là bị bỏ qua |
Mặc định hãy dùng Errorf, để một lần chạy báo hết mọi trường sai. Dùng Fatalf khi phần còn lại của test không thể chạy, thường là sau một lỗi bất ngờ:
func TestCountWords(t *testing.T) {
path := writeFile(t, "doc.txt", "the quick brown\nfox jumps\n")
got, err := CountWords(path)
if err != nil {
t.Fatalf("CountWords: unexpected error: %v", err) // stop: got is meaningless
}
if got != 5 {
t.Errorf("CountWords = %d, want 5", got)
}
}
Fatal dừng test bằng cách gọi runtime.Goexit, nên nó phải được gọi từ chính goroutine của test. Từ một goroutine bạn tự khởi chạy, hãy báo bằng t.Error rồi return.
Table-driven test với t.Run
Phần lớn test trong Go là dạng bảng: một slice các trường hợp, một vòng lặp, và t.Run để đặt tên riêng cho từng trường hợp.
func TestSlugifyTable(t *testing.T) {
tests := []struct {
name, in, want string
}{
{"simple", "Go Testing", "go-testing"},
{"punctuation", "What's new in Go 1.24?", "what-s-new-in-go-1-24"},
{"extra spaces", " lots of space ", "lots-of-space"},
{"non-ascii dropped", "Café au lait", "caf-au-lait"},
{"empty", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Slugify(tt.in); got != tt.want {
t.Errorf("Slugify(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
Thêm một trường hợp chỉ tốn một dòng. Mỗi subtest được báo cáo riêng, một Fatal bên trong chỉ kết thúc subtest đó, và bạn có thể chạy riêng một subtest theo tên. Chạy tất cả với -v:
$ go test -v
=== RUN TestSlugify
--- PASS: TestSlugify (0.00s)
=== RUN TestSlugifyTable
=== RUN TestSlugifyTable/simple
=== RUN TestSlugifyTable/punctuation
=== RUN TestSlugifyTable/extra_spaces
=== RUN TestSlugifyTable/non-ascii_dropped
=== RUN TestSlugifyTable/empty
--- PASS: TestSlugifyTable (0.00s)
--- PASS: TestSlugifyTable/simple (0.00s)
--- PASS: TestSlugifyTable/punctuation (0.00s)
--- PASS: TestSlugifyTable/extra_spaces (0.00s)
--- PASS: TestSlugifyTable/non-ascii_dropped (0.00s)
--- PASS: TestSlugifyTable/empty (0.00s)
=== RUN TestCountWords
--- PASS: TestCountWords (0.00s)
=== RUN TestCountWordsMissingFile
--- PASS: TestCountWordsMissingFile (0.00s)
=== RUN ExampleSlugify
--- PASS: ExampleSlugify (0.00s)
PASS
ok example.com/textutil 0.182s
Khoảng trắng trong tên subtest thành dấu gạch dưới. Khi một trường hợp thất bại, đầu ra nêu tên subtest và dòng code:
--- FAIL: TestSlugifyTable (0.00s)
--- FAIL: TestSlugifyTable/underscore (0.00s)
slug_test.go:27: Slugify("snake_case_name") = "snake-case-name", want "snake_case_name"
FAIL
FAIL example.com/textutil 0.289s
Từ Go 1.22, mỗi lần lặp có biến tt riêng, nên closure bên trong t.Run thấy đúng trường hợp kể cả khi các subtest chạy song song. Code cũ thường có tt := tt ở đầu thân vòng lặp vì lý do đó; giờ không cần nữa.
Để chạy các subtest song song, gọi t.Parallel() ở đầu hàm subtest. Chỉ làm vậy khi các trường hợp độc lập với nhau và đủ chậm để việc này có ý nghĩa.
Các cờ go test bạn sẽ dùng
| Lệnh | Tác dụng |
|---|---|
go test -v | in tên, kết quả và đầu ra t.Log của mọi test |
go test -run TestSlugify | chạy các test có tên khớp với biểu thức chính quy |
go test -run 'TestSlugifyTable/empty' | chạy một subtest |
go test -count=1 | bỏ qua kết quả trong cache và chạy lại |
go test -race | chạy kèm bộ phát hiện data race |
go test -short | báo cho test chạy lâu tự bỏ qua (if testing.Short() { t.Skip() }) |
go test -failfast | dừng sau test thất bại đầu tiên |
go test -timeout 30s | báo thất bại nếu chạy lâu hơn (mặc định 10 phút) |
go test -cover | in độ bao phủ câu lệnh |
go test -bench=. | chạy cả benchmark |
Khi bạn chỉ định package (go test ., go test ./...), go test lưu cache kết quả cho những package có code và đầu vào không đổi, và in (cached) phía sau chúng. go test không kèm đối số thì không bao giờ dùng cache. Cache theo dõi các tệp và biến môi trường mà test đọc qua package os, nhưng không theo dõi trạng thái bên ngoài như cơ sở dữ liệu hay dịch vụ mạng, nên một test phụ thuộc vào chúng có thể đạt nhờ cache trong khi chạy thật thì sẽ thất bại; -count=1 buộc phải chạy thật.
Helper, thư mục tạm và dọn dẹp
Lời gọi writeFile trong TestCountWords ở trên là một test helper:
// writeFile is a test helper: t.Helper makes failures point at the caller.
func writeFile(t *testing.T, name, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), name) // removed automatically after the test
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("writing %s: %v", name, err)
}
return path
}
t.Helper()đánh dấu hàm là helper, nên lỗi được báo ở dòng trong test đã gọi nó, không phải bên trong helper.t.TempDir()tạo một thư mục mới và xóa nó khi test kết thúc. Test không bao giờ phải tự dọn tệp.t.Cleanup(func() { ... })đăng ký mọi bước dọn dẹp khác (đóng máy chủ, xóa bảng). Các hàm dọn dẹp chạy sau test và các subtest của nó, hàm đăng ký sau chạy trước.t.Setenv("KEY", "value")đặt biến môi trường chỉ cho test này và khôi phục lại sau đó.t.Context()(Go 1.24) trả về một context bị hủy ngay trước khi các hàm dọn dẹp chạy.
Các tệp dữ liệu mẫu cho test nằm trong thư mục tên testdata cạnh các test. Công cụ go không coi nó là package, và test chạy với thư mục của package làm thư mục làm việc, nên os.ReadFile("testdata/input.json") chạy được.
Test lỗi và HTTP handler
Kiểm tra lỗi bằng errors.Is hoặc errors.As, giống cách code gọi hàm sẽ làm:
func TestCountWordsMissingFile(t *testing.T) {
_, err := CountWords(filepath.Join(t.TempDir(), "nope.txt"))
if !errors.Is(err, fs.ErrNotExist) {
t.Errorf("err = %v, want fs.ErrNotExist", err)
}
}
So sánh chuỗi lỗi sẽ hỏng ngay khi có người sửa câu chữ của thông báo.
Với HTTP handler, net/http/httptest cho bạn một ResponseWriter giả để gọi handler trực tiếp, không cần mạng:
func TestHealth(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
healthHandler(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
if body := rec.Body.String(); body != "ok\n" {
t.Errorf("body = %q, want %q", body, "ok\n")
}
}
httptest.NewServer khởi chạy một máy chủ thật trên localhost để test code phía máy khách. Trang HTTP client dùng nó cho mọi ví dụ.
Độ bao phủ
$ go test -cover
PASS
coverage: 100.0% of statements
ok example.com/textutil 0.578s
$ go test -coverprofile=cover.out
$ go tool cover -func=cover.out
example.com/textutil/slug.go:11: Slugify 100.0%
example.com/textutil/words.go:9: CountWords 100.0%
total: (statements) 100.0%
$ go tool cover -html=cover.out # opens a browser with covered lines in green
Độ bao phủ (coverage) cho bạn biết những dòng nào chưa từng chạy, rất có ích để tìm các nhánh chưa được test. Con số cao không nói lên rằng các phép kiểm tra là tốt: một test gọi mọi hàm mà không kiểm tra gì vẫn đạt 100%.
Benchmark
Benchmark là func BenchmarkXxx(b *testing.B). Go 1.24 bổ sung b.Loop, giờ là dạng được khuyên dùng:
func BenchmarkSlugify(b *testing.B) {
for b.Loop() {
Slugify("The Go Programming Language, 2nd Edition")
}
}
b.Loop chạy thân vòng lặp bao nhiêu lần cần thiết để có số đo ổn định, không tính code chuẩn bị trước vòng lặp vào thời gian đo, và ngăn trình biên dịch tối ưu bỏ lời gọi hàm. Code viết trước Go 1.24 dùng for i := 0; i < b.N; i++, vẫn chạy được nhưng cần b.ResetTimer() sau bước chuẩn bị tốn kém và có thể bị đánh lừa bởi việc loại bỏ code chết.
Benchmark không chạy khi gọi go test thường. Hãy yêu cầu chạy chúng, và bỏ qua các test thường bằng -run='^$':
$ go test -bench=. -benchmem -run='^$'
goos: darwin
goarch: arm64
pkg: example.com/textutil
cpu: Apple M4
BenchmarkSlugify-10 5061945 238.1 ns/op 168 B/op 5 allocs/op
PASS
ok example.com/textutil 1.410s
Các cột lần lượt là: tên kèm GOMAXPROCS, số lần lặp đã chạy, thời gian mỗi lời gọi, số byte cấp phát mỗi lời gọi, số lần cấp phát mỗi lời gọi. Các con số phụ thuộc vào máy; hãy so sánh các lần chạy trên cùng một máy. Để so sánh trước và sau một thay đổi cho đáng tin, chạy mỗi bên vài lần với -count=10 rồi đưa cả hai đầu ra vào benchstat (golang.org/x/perf/cmd/benchstat).
Example cũng là test
Một hàm example in ra thứ gì đó và khai báo đầu ra mong đợi trong một chú thích. go test chạy nó và báo thất bại nếu đầu ra khác, còn go doc và pkg.go.dev hiển thị nó như tài liệu:
// example_test.go
package textutil_test
import (
"fmt"
"example.com/textutil"
)
func ExampleSlugify() {
fmt.Println(textutil.Slugify("Hello, World!"))
// Output: hello-world
}
Tên package textutil_test biến đây thành test bên ngoài: nó chỉ dùng được API đã export, giống một nơi gọi thật. Những tệp như vậy được phép nằm cùng thư mục với package. Không có chú thích // Output:, example chỉ được biên dịch chứ không chạy. Dùng // Unordered output: khi các dòng có thể xuất hiện theo bất kỳ thứ tự nào.
Sơ lược về fuzzing
Go 1.18 bổ sung fuzz test, loại test tự sinh đầu vào để tìm lỗi crash và các bất biến bị phá vỡ:
func FuzzSlugify(f *testing.F) {
f.Add("Hello, World!") // seed input
f.Fuzz(func(t *testing.T, s string) {
slug := Slugify(s)
if strings.Contains(slug, "--") || strings.HasPrefix(slug, "-") {
t.Errorf("Slugify(%q) = %q: bad hyphens", s, slug)
}
})
}
go test thường chỉ chạy các đầu vào gốc (giá trị trong f.Add và các tệp đã lưu trong testdata/fuzz). go test -fuzz=FuzzSlugify liên tục sinh đầu vào mới cho đến khi tìm ra lỗi hoặc bạn dừng nó, và lưu các đầu vào gây lỗi vào testdata/fuzz để chúng trở thành test case thông thường.
Lỗi thường gặp
- Tệp test đặt sai chỗ hoặc đặt tên sai. Tên tệp phải kết thúc bằng
_test.govà nằm trong thư mục của package.slug_tests.gosẽ được biên dịch vào package chứ không được chạy như test. - Chữ thường sau
Test.Testslugifykhông phải là test.TestSlugifyvàTest_slugifythì đúng. - Gọi
t.Fataltừ goroutine khác. Ở đó hãy báo bằngt.Error, và chờ goroutine xong trước khi test return. - Thông báo không kèm đầu vào. Hãy ghi rõ đã truyền gì vào, nhận được gì, và mong đợi gì.
- Tin vào kết quả đạt từ cache. Dùng
-count=1khi test phụ thuộc vào bất cứ thứ gì bên ngoài package. - Test phụ thuộc vào thứ tự chạy của nhau hoặc biến toàn cục dùng chung. Mỗi test nên tự chuẩn bị trạng thái của riêng nó, và đó là việc của
t.TempDir,t.Setenvvàt.Cleanup.
Câu hỏi thường gặp
Viết unit test trong Go như thế nào?
Tạo một tệp có tên kết thúc bằng _test.go trong cùng thư mục với code, import testing, rồi viết hàm func TestName(t *testing.T) gọi code của bạn và báo chỗ sai lệch bằng t.Errorf. Chạy go test trong thư mục đó, hoặc go test ./... cho toàn bộ module. Không cần framework hay thư viện assertion nào.
t.Error và t.Fatal trong Go khác nhau thế nào?
t.Error và t.Errorf đánh dấu test thất bại nhưng vẫn chạy tiếp, nên một lần chạy có thể báo nhiều vấn đề. t.Fatal và t.Fatalf đánh dấu thất bại và dừng test ngay lập tức. Dùng Fatal khi chạy tiếp không còn ý nghĩa, chẳng hạn sau một lỗi bất ngờ khiến kết quả không dùng được.
Chạy một test duy nhất trong Go như thế nào?
Truyền một biểu thức chính quy cho -run: go test -run TestSlugify chạy mọi test có tên khớp, còn go test -run 'TestSlugifyTable/punctuation' chạy một subtest (khoảng trắng trong tên subtest thành dấu gạch dưới). Thêm -v để xem tên và kết quả của từng test, và -count=1 để bỏ qua cache test.
Viết benchmark trong Go như thế nào?
Viết func BenchmarkName(b *testing.B) trong một tệp _test.go và đặt code cần đo vào vòng lặp for b.Loop() { ... } (Go 1.24; code cũ hơn dùng for i := 0; i < b.N; i++). Chạy bằng go test -bench=. -benchmem, lệnh này báo số nano giây, số byte và số lần cấp phát cho mỗi thao tác.
Xem độ bao phủ test trong Go như thế nào?
go test -cover in ra phần trăm câu lệnh mà test đã chạy qua. Để xem chi tiết, ghi một profile bằng go test -coverprofile=cover.out, rồi chạy go tool cover -func=cover.out để xem số liệu theo từng hàm hoặc go tool cover -html=cover.out để xem các dòng đã và chưa được bao phủ trên trình duyệt.