Skip to main content

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) รู้ทันทีว่าจานไหน โดยไม่กระทบจานอื่น

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

ถ้า 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 แยกเป็นสิบแบบ

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

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 ไว้รายงานพร้อมกันตอนจบ)

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

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++ ให้ครอบโค้ดที่จะวัดเท่านั้น

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

-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 จริงเลย

🎯 Beyond: จุดที่ชาวบ้านไม่ค่อยรู้

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 ผ่านบ้างไม่ผ่านบ้างสลับกันไป

English Summary

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.