Testing in Go
Go มี testing framework built-in (testing package) ไม่ต้องพึ่ง library ภายนอกก็เขียนเทสต์
ได้ครบ แต่ community นิยมเสริมด้วย testify เพื่อ assertion ที่อ่านง่ายขึ้น
ดูหลักการทั่วไปเรื่อง test pyramid, mocking strategy ได้ที่หมวด Unit Testing หน้านี้โฟกัสเฉพาะ syntax และ idiom ของ Go
Table-Driven Tests
รูปแบบมาตรฐานที่สุดในวงการ Go — เขียน test case เป็น slice ของ struct แล้ว loop ทดสอบ
func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b float64
want float64
wantErr bool
}{
{"normal", 10, 2, 5, false},
{"divide by zero", 10, 0, 0, true},
{"negative", -10, 2, -5, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Divide(tt.a, tt.b)
if (err != nil) != tt.wantErr {
t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
}
if got != tt.want {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}
t.Run ทำให้แต่ละ case รันเป็น subtest แยกกัน — เห็นว่า case ไหน fail ชัดเจน และรันแยกเฉพาะ
case นั้นได้ด้วย go test -run TestDivide/divide_by_zero
"Table = เมนูอาหาร, t.Run = สั่งทีละจาน" — เขียน test case ทั้งหมดเป็น "เมนู" (slice of
struct) ครั้งเดียว แล้วให้ t.Run "สั่ง" ทีละจานแยกกัน ถ้าจานไหนเสีย (fail) รู้ทันทีว่าจานไหน
โดยไม่กระทบจานอื่น
ถ้า loop ด้วย for _, tt := range tests แล้วไม่ assign tt ใหม่ในแต่ละ t.Run (เช่น capture
tt โดยตรงใน closure บน Go เวอร์ชันก่อน 1.22) การรัน test แบบ parallel ด้วย
t.Parallel() ข้างใน t.Run จะเจอบั๊ก loop-variable-capture เดียวกับที่เจอใน goroutine —
ทุก subtest อาจเห็น tt ตัวสุดท้ายเหมือนกันหมด วิธีแก้ (เวอร์ชันก่อน 1.22): ประกาศ
tt := tt ซ้ำในบรรทัดแรกของ closure ก่อนเรียก t.Parallel() เป็นแพทเทิร์นที่เจอบ่อยมากใน
code review ของทีมที่เพิ่งเริ่มรัน test แบบ parallel
Mocking ด้วย Interface
เพราะ Go ใช้ implicit interface satisfaction การ mock คือการเขียน struct ปลอมที่ implement interface เดียวกับของจริง
type UserRepository interface {
FindByID(ctx context.Context, id string) (*User, error)
}
type mockUserRepo struct {
findByIDFunc func(ctx context.Context, id string) (*User, error)
}
func (m *mockUserRepo) FindByID(ctx context.Context, id string) (*User, error) {
return m.findByIDFunc(ctx, id)
}
func TestUserService_GetUser(t *testing.T) {
repo := &mockUserRepo{
findByIDFunc: func(ctx context.Context, id string) (*User, error) {
return &User{ID: id, Name: "Alice"}, nil
},
}
svc := NewUserService(repo, slog.Default())
user, err := svc.GetUser(context.Background(), "123")
require.NoError(t, err)
assert.Equal(t, "Alice", user.Name)
}
หรือใช้ code generation: mockery /
uber-go/mock (gomock) สำหรับ interface ที่ใหญ่/เยอะ
"Mock แบบ Go คือใส่ฟังก์ชันไว้ใน field" — pattern findByIDFunc func(...) (...) ที่เป็น field
ของ struct เรียกว่า "function field" mock จำง่ายๆ ว่า "struct ที่ field เป็นฟังก์ชัน
แทนที่จะเป็นค่า" เวลาเทสต์ต้องการ behavior ไหนก็ยัดฟังก์ชันนั้นเข้าไปตอนสร้าง struct ได้เลย
ไม่ต้องสร้างคลาส mock แยกเป็นสิบแบบ
Mock ที่เขียนมือ (hand-rolled) ดีกว่า code-gen mock (mockery/gomock) ในแง่หนึ่งที่คนมักมอง
ข้าม: compile-time safety ที่ชัดเจนกว่า เวลา interface เปลี่ยน signature ถ้าใช้
hand-rolled mock จะ compile error ทันที ที่ตำแหน่ง mock struct บอกชัดเจนว่าต้องแก้อะไร
ส่วน code-gen mock ต้องสั่ง regenerate ใหม่ก่อน ถึงจะเห็น error (และถ้าลืม regenerate โค้ดจะ
compile ผ่านด้วย mock เก่าที่ไม่ตรงกับ interface ปัจจุบัน) ทีมที่มี interface เปลี่ยนบ่อยมักเลือก
hand-rolled mock ด้วยเหตุผลนี้ ส่วนทีมที่ interface ใหญ่/นิ่งแล้วเลือก code-gen เพื่อลดโค้ดซ้ำ
testify: assert vs require
assert.Equal(t, want, got) // fail แต่เทสต์ยังรันต่อ (เก็บ error ทั้งหมดในครั้งเดียว)
require.Equal(t, want, got) // fail แล้ว t.FailNow() ทันที — ใช้กับ precondition ที่ถ้าพังแล้ว
// เทสต์ที่เหลือไม่มีความหมาย (เช่น err ต้อง nil ก่อนเช็ค field)
"require = ต้องรอด ไม่งั้นหยุดทันที, assert = เจ็บแล้วไปต่อ" — require เหมือนด่านตรวจที่ถ้า
ไม่ผ่านคือกลับบ้านเลย (หยุดเทสต์ทันที) assert เหมือนเก็บแต้มลบแล้วเดินต่อจนจบด่าน (เก็บทุก
error ไว้รายงานพร้อมกันตอนจบ)
require/assert ที่เรียกจาก goroutine อื่นที่ไม่ใช่ goroutine หลักของ test function จะไม่
ทำงานถูกต้อง — require.XXX เรียก t.FailNow() ซึ่งภายในใช้ runtime.Goexit() ซึ่งทำงาน
ถูกเฉพาะกับ goroutine หลักเท่านั้น (goroutine ลูกที่เรียก Goexit() จะแค่จบ goroutine นั้นไป
เงียบๆ ไม่หยุดเทสต์จริง) ถ้าต้อง assert ภายใน goroutine ที่ spawn จากเทสต์ (เช่นเทส concurrent
code) ต้องใช้ assert (ไม่ใช่ require) แล้วส่งผลลัพธ์กลับผ่าน channel ให้ goroutine หลักเป็น
คน assert/require แทน
Benchmark & Coverage
go test -bench=. -benchmem ./... # วัด performance + memory allocation
go test -cover ./... # ดู coverage %
go test -coverprofile=cover.out && go tool cover -html=cover.out # ดูทีละบรรทัด
func BenchmarkFibonacci(b *testing.B) {
for i := 0; i < b.N; i++ {
Fibonacci(20)
}
}
"b.N ไม่ใช่เลขที่คุณตั้ง มันคือเลขที่ Go ตั้งให้" — หลายคนเข้าใจผิดว่าต้องกำหนด b.N เอง
จริงๆ testing framework จะปรับ b.N ขึ้นเรื่อยๆ เองอัตโนมัติ จนกว่าผลลัพธ์จะ stable พอจะ
วัดเวลาได้แม่นยำ (รันซ้ำหลายรอบด้วยค่า N ที่มากขึ้น) หน้าที่เราแค่เขียน loop for i := 0; i < b.N; i++ ให้ครอบโค้ดที่จะวัดเท่านั้น
-benchmem รายงาน allocs/op และ B/op ซึ่งสำคัญกว่า ns/op ในหลายสถานการณ์ — โค้ดที่เร็ว
(ns/op ต่ำ) แต่ allocate memory เยอะทุกครั้งที่เรียก จะสร้างภาระให้ GC มากขึ้นเรื่อยๆ เมื่อ
เรียกถี่ใน production จนฉุด throughput รวมของระบบ แม้ benchmark เดี่ยวๆ จะดู "เร็ว" ก็ตาม —
เวลา optimize จริงควรดูทั้งคู่พร้อมกัน ไม่ใช่แข่งกันแค่ ns/op อย่างเดียว และต้อง go test -benchmem ไม่ใช่ -bench เฉยๆ ถึงจะเห็นตัวเลข allocation (ไม่ได้มาให้ default)
httptest — เทส HTTP Handler โดยไม่ต้องรัน server จริง
func TestHealthHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
HealthHandler(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.JSONEq(t, `{"status":"ok"}`, rec.Body.String())
}
"httptest.NewRecorder = จอโทรทัศน์จำลอง" — ResponseWriter ปกติต้องเขียนไป socket จริง
httptest.NewRecorder() คือ "จอจำลอง" ที่รับการเขียนแล้วบันทึกไว้ให้ตรวจสอบทีหลัง (status
code, body, header) โดยไม่ต้องเปิด port จริงเลย
assert.JSONEq ดีกว่า assert.Equal กับ JSON string เสมอ เพราะเทียบ structural equality
ของ JSON (ไม่สนลำดับ key, whitespace) ในขณะที่ assert.Equal เทียบ string ตรงตัวทุกตัวอักษร
— ทีมจำนวนมากเขียน assert.Equal(t, expectedJSON, actualJSON) แล้วเทสต์ flaky โดยไม่รู้สาเหตุ
เพราะ key order ของ JSON ที่ encode จาก Go map ไม่รับประกันลำดับคงที่ (Go จงใจ randomize
ลำดับ key ของ map ตอน iterate เพื่อป้องกันไม่ให้โค้ดพึ่งพาลำดับที่ไม่ควรพึ่ง) ทำให้ test ที่
ใช้ Equal กับ JSON จาก map ผ่านบ้างไม่ผ่านบ้างสลับกันไป
Go's built-in testing package covers everything; testify's assert/require just make
assertions more readable (require aborts the test immediately, assert collects all failures
— and require only works correctly on the test's main goroutine, not on spawned ones).
Table-driven tests with t.Run subtests are the de facto standard, but parallel subtests before
Go 1.22 need an explicit tt := tt to avoid loop-variable capture. Mocking leans on Go's
implicit interfaces — hand-rolled "function field" mocks fail fast at compile time when an
interface changes, which is why many teams prefer them over code-gen mocks despite more
boilerplate. httptest lets you unit-test HTTP handlers without binding a real port; prefer
assert.JSONEq over assert.Equal for JSON bodies, since Go intentionally randomizes map
iteration order. go test -race -cover -bench -benchmem rounds out correctness, coverage, and
performance checks — watch allocs/op, not just ns/op.