โหมดมืด
บทที่ 10 — Testing
หลังจบบท คุณจะ:
- เขียนเทสต์ 3 รูปแบบหลัก:
- unit test = เทสต์ทีละหน่วยย่อย เช่น 1 ฟังก์ชัน
- table-driven test = เทสต์ที่ไล่หลายเคสจากตาราง (Go idiom)
- subtests = เทสต์ย่อยในเทสต์เดียวด้วย
t.Run
- ใช้ plain
testingpackage ของมาตรฐาน (idiomatic) และรู้จัก testify (ไลบรารียอดนิยม) เป็นทางเลือก - เขียน benchmark (วัดความเร็วโค้ด) + วัด coverage (เทสต์ครอบคลุมโค้ดกี่ %)
- เขียน fuzz test = สุ่มข้อมูลจำนวนมากมาลองยิงใส่ฟังก์ชัน เพื่อหาเคสที่ทำให้พัง
- เทสต์ concurrent code (โค้ดที่ทำงานขนานกัน) + HTTP handler
- เข้าใจ mocking (สร้างตัวปลอมมาแทน dependency จริงตอนเทสต์)
ก่อนอ่านบทนี้ ควรเข้าใจ: บท 3 — Functions, บท 5 — Structs + Methods, บท 6 — Interfaces (จำเป็นสำหรับ mocking), บท 9 — Packages (จำเป็นสำหรับ
_test.goconvention)
1. Test เกือบฟรี ใน Go
ข้อดีใหญ่ของ Go คือมีระบบเทสต์มาให้ ในตัวตั้งแต่แรก — ไม่ต้องติดตั้งไลบรารีเพิ่มเหมือน Java/JS. ขั้นตอน: (1) ตั้งชื่อไฟล์ลงท้าย _test.go, (2) เขียนฟังก์ชันขึ้นต้นด้วย Test, (3) สั่ง go test:
🔄 เทียบกับ C#: attribute
[Fact](xUnit) หรือ[Test](NUnit) ≈ ฟังก์ชันที่ขึ้นต้นด้วยTestใน Go — แต่ Go ไม่มี attribute-based test discovery แบบ C#, ใช้แค่ naming convention (ชื่อฟังก์ชันขึ้นต้นด้วยTest) บวก parameter*testing.Tแทน.Assert.Equal(expected, actual)ของ MSTest ≈assert.Equal(t, expected, got)ของ testify — สังเกตว่า Go ต้องส่งtเป็น parameter แรกเสมอ
text
File convention:
user.go ← production code
user_test.go ← test code (same package)go
// user_test.go
package user
import "testing"
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
t.Errorf("Add(2,3) = %d; want %d", got, want)
}
}bash
go test # run tests in current package
go test ./... # all packages2. Test Function Signature
ฟังก์ชันเทสต์ทุกตัวมีรูปแบบตายตัว: ขึ้นต้นด้วย Test, รับ *testing.T ตัวเดียว. ผ่าน t นี้เราสั่งให้เทสต์ "ล้มเหลว" ได้หลายแบบ:
t.Error— ล้มแต่ทำต่อ (ไล่ดูเคสอื่นต่อ)t.Fatal— ล้มแล้วหยุดทันที (เมื่อสภาพต่อไปเทสต์ไม่ได้)t.Skip— ข้าม (เช่น เทสต์ต้องใช้ network แต่ไม่มี)
go
func TestXxx(t *testing.T) {
// test body
}Rules:
- Function must start with
Test - Take
*testing.T - File must end with
_test.go - Function in same package (or
_testpackage)
Failure Methods
go
t.Error("msg") // mark fail, continue
t.Errorf("got %v", x)
t.Fatal("msg") // mark fail, STOP
t.Fatalf("got %v", x)
t.Log("info") // log (shown if -v or fail)
t.Skip("reason") // skip test
t.Helper() // mark as helper (line number adjustment)go
func TestSubtract(t *testing.T) {
got := Subtract(10, 3)
want := 7
if got != want {
t.Errorf("Subtract(10,3) = %d; want %d", got, want)
}
}3. Table-Driven Test — Go Idiom (วิธีเขียนเทสต์แบบ Go สำนวนนิยม)
Table-driven test เป็นสำนวนที่ชาว Go ใช้กันมากที่สุด — เรียกว่า "table" เพราะมองเคสทดสอบเป็น "ตาราง" (แต่ละแถว = 1 เคส). แทนที่จะเขียนฟังก์ชันเทสต์แยกทุกกรณี เรารวบทุกเคส (input + ผลลัพธ์คาดหวัง) ไว้ใน slice ของ struct แล้ววนทดสอบทีละแถวด้วย t.Run. ข้อดี: เพิ่มเคสใหม่แค่เพิ่ม 1 แถว และเห็นผลแต่ละเคสแยกชัดเจน:
go
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"two positive", 2, 3, 5},
{"positive + zero", 5, 0, 5},
{"negative + positive", -2, 3, 1},
{"both negative", -2, -3, -5},
{"zero", 0, 0, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.want {
t.Errorf("Add(%d,%d) = %d; want %d", tt.a, tt.b, got, tt.want)
}
})
}
}t.Run = subtest — แสดงใน output แยกแต่ละ case (ดูง่ายว่าเคสไหน fail).
💡 เคล็ดลับ: ถ้าเคสในตารางเป็นอิสระต่อกัน (ไม่มี shared state) ใส่
t.Parallel()ที่ต้นt.Runได้ — รันขนานเร็วขึ้น. และถ้ามี helper function ที่ใช้ซ้ำ อย่าลืมt.Helper()ในนั้น (รายละเอียดอยู่หัวข้อ 14 — Test Helpers ด้านล่างในบทนี้).
Output
text
$ go test -v
=== RUN TestAdd
=== RUN TestAdd/two_positive
=== RUN TestAdd/positive_+_zero
=== RUN TestAdd/negative_+_positive
=== RUN TestAdd/both_negative
=== RUN TestAdd/zero
--- PASS: TestAdd (0.00s)
--- PASS: TestAdd/two_positive (0.00s)
--- PASS: TestAdd/positive_+_zero (0.00s)
...→ ดูง่ายว่าอะไรผ่าน/fail
4. Setup + Teardown
Per-Test
go
func TestUserRepo(t *testing.T) {
// Setup
db := setupDB(t)
defer teardownDB(db)
repo := NewRepo(db)
// Test
u, err := repo.Save(User{Name: "Anna"})
if err != nil { t.Fatal(err) }
got, err := repo.FindByID(u.ID)
if err != nil { t.Fatal(err) }
if got.Name != "Anna" { t.Errorf("got %s", got.Name) }
}TestMain — Setup สำหรับทั้ง package
go
import (
"os"
"testing"
)
func TestMain(m *testing.M) {
// Setup
setupTestDB()
// Run all tests
code := m.Run()
// Teardown
teardownTestDB()
os.Exit(code)
}t.Cleanup (Go 1.14+)
go
func TestUser(t *testing.T) {
db := setupDB(t)
t.Cleanup(func() {
db.Close()
})
// ... test
}→ Cleanup ถูกเรียกหลัง test จบ (LIFO order, เหมือน defer)
5. Testify — ไลบรารียอดนิยม (แต่เป็นทางเลือก)
💡 idiomatic Go (วิธีเขียนโค้ดแบบที่ชาว Go ถือว่าถูกสไตล์และธรรมชาติ) คือ plain
testingpackage ที่เห็นข้างบน. หลายคนในชุมชน Go (เช่น Dave Cheney — Go contributor ที่มีชื่อเสียงเรื่องเขียนบทความ idiomatic Go) ไม่แนะนำ testify. เหตุผลคือมัน "ซ่อน" การตรวจสอบและทำให้ error message (ข้อความแจ้ง error) แม่นยำน้อยลง — แต่นี่เป็นความเห็นของชุมชน ไม่ใช่จุดยืนทางการของทีม Go เอง. ในงานจริงหลายทีมยังใช้ testify เพราะอ่านง่ายและเขียนสั้น.แนะนำสำหรับมือใหม่: ใช้ plain
testingpackage ก่อน. ถ้าทีมของคุณใช้ testify แล้ว เรียนรู้ตามได้ แต่ถ้าเริ่มใหม่แนะนำ plain ก่อน. ส่วน testify ด้านล่างนี้เป็น Optional / Team standard — อ่านเพื่อรู้จักไว้เมื่อต้องทำงานกับโค้ดที่ใช้มันอยู่แล้ว.
ปัญหาของ plain testing: เขียน if got != want { t.Errorf(...) } ทุกครั้งก็ยืดยาว.
testify เป็นไลบรารียอดนิยมที่ให้ฟังก์ชัน assert อ่านง่าย (assert.Equal, assert.NoError). มีสองตระกูล:
assert— ล้มแล้วทำต่อ (เหมือนt.Error)require— ล้มแล้วหยุด (เหมือนt.Fatal)
💡 ทางเลือก plain testing สมัยใหม่: ตั้งแต่ Go 1.21+ มี
slices.Equalและmaps.Equalให้เปรียบเทียบ collection โดยไม่ต้องพึ่ง testify — เขียนสั้นและ idiomatic กว่าเดิม.
bash
go get github.com/stretchr/testifygo
import "github.com/stretchr/testify/assert"
func TestAdd(t *testing.T) {
got := Add(2, 3)
assert.Equal(t, 5, got) // ⚠️ signature: assert.Equal(t, expected, actual) — expected (ค่าที่คาดหวัง) มาก่อน actual (ค่าที่ได้จริง) — สลับกันบ่อย!
assert.NotEqual(t, 0, got)
assert.True(t, got > 0)
assert.Greater(t, got, 0)
assert.Contains(t, []int{1,2,3}, 2)
assert.NoError(t, err)
assert.ErrorIs(t, err, ErrNotFound)
}assert vs require
go
assert.Equal(t, x, y) // fail but continue
require.Equal(t, x, y) // fail and STOP (like t.Fatal)go
// Common pattern
user, err := getUser(1)
require.NoError(t, err) // can't continue if error
assert.Equal(t, "Anna", user.Name)
assert.Equal(t, 25, user.Age)Useful Assertions
go
// Equality
assert.Equal, NotEqual, Same (pointer), NotSame
assert.EqualValues // type-loose compare
// Boolean
assert.True, False
// Numeric
assert.Greater, GreaterOrEqual, Less, LessOrEqual
// String
assert.Contains, NotContains, HasPrefix, Regexp
// Collection
assert.Len, Empty, NotEmpty
assert.ElementsMatch // same elements, any order
// Error
assert.NoError, Error
assert.ErrorIs (errors.Is)
assert.ErrorAs (errors.As)
assert.EqualError
// Type
assert.IsType, Implements
// nil
assert.Nil, NotNil
// Panic
assert.Panics, NotPanics
// File / time
assert.FileExists
assert.WithinDuration6. Test Coverage
Coverage บอกว่าเทสต์ของเรารันถึงโค้ดกี่เปอร์เซ็นต์. Go มีเครื่องมือวัดในตัวด้วย flag -cover. ดูเป็นรายงาน HTML ได้ว่าบรรทัดไหนยังไม่ถูกทดสอบ (เห็นเป็นสี).
⚠️ ระวัง: ตัวเลข coverage สูงไม่ได้แปลว่า "ไม่มีบั๊ก" — เน้นทดสอบ critical path (เส้นทางสำคัญที่โค้ดธุรกิจต้องผ่าน เช่น การสร้างออร์เดอร์ การชำระเงิน) ให้ครบ ดีกว่าไล่ล่า 100%:
bash
# Run with coverage
go test -cover ./...
# coverage: 75.3% of statements
# Save coverage profile
go test -coverprofile=coverage.out ./...
# View HTML report
go tool cover -html=coverage.outCoverage Per Function
bash
go tool cover -func=coverage.out→ See which function need more test
Coverage in CI
yaml
# GitHub Actions
- run: go test -coverprofile=coverage.out ./...
- run: go tool cover -func=coverage.out
# Fail if < 80% (ใช้ awk ล้วน ไม่พึ่ง bc — bc ไม่การันตีว่ามีติดตั้งมาบน runner ทุกภาพ)
- run: |
total=$(go tool cover -func=coverage.out | grep total | awk '{print $3}')
threshold=80.0
awk -v t="${total%\%}" -v th="$threshold" 'BEGIN{exit !(t<th)}' && {
echo "Coverage $total below threshold $threshold%"
exit 1
}Coverage ไม่ใช่ทุกอย่าง
text
80% coverage = good baseline
100% coverage ≠ bug-free
- High coverage + bad assertions = false confidence
- Low coverage + critical path tested = OK
→ Test critical paths thoroughly7. Test File Layout
Go ให้เลือกได้ว่าจะเทสต์แบบ white-box หรือ black-box. white-box คือเทสต์โดยเห็นภายใน — อยู่ใน package เดียวกัน เข้าถึง private ได้. black-box คือเทสต์โดยมองเป็นกล่องดำ — อยู่ใน package xxx_test แยกต่างหาก ทดสอบผ่าน public API เท่านั้น. แบบ black-box แนะนำในระยะยาว เพราะเวลา refactor (ปรับโครงสร้างโค้ดภายใน) แล้วเทสต์จะไม่พัง:
text
user/
├── user.go
├── user_test.go ← package user (white-box test, access private)
└── user_ext_test.go ← package user_test (black-box test, public only)go
// Same package — access private
package user
import "testing"
func Test_validate(t *testing.T) {
// can test private function "validate"
}
// Separate package — only public API
package user_test
import (
"testing"
"github.com/myuser/myapp/user"
)
func TestNew(t *testing.T) {
u := user.New("Anna") // only public
}→ Black-box test ดีกว่าในระยะยาว — refactor (ปรับโครงสร้างโค้ดภายใน) ไม่ทำให้ test พัง
8. Mocking — Interface ช่วย
💡 ทบทวนสั้น ๆ: interface = "สัญญา" ที่กำหนดว่า type ต้องมี method อะไรบ้าง — ถ้า struct ของเรามี method ครบตามที่ interface กำหนด ก็ใช้แทนกันได้ทันทีโดยไม่ต้องประกาศ. (รายละเอียดเต็มใน บท 6 — Interfaces)
เวลาเทสต์โค้ดที่พึ่ง service อื่น (เช่น database, HTTP API) เราไม่อยากเรียกของจริงเพราะจะช้าและไม่เสถียร.
วิธีของ Go ไม่ต้องใช้ mocking library วิเศษ — แค่ให้โค้ดพึ่ง interface แล้วเขียน struct ปลอม (fake/mock) ที่ implement interface นั้นขึ้นมาใช้ตอนเทสต์. ตรงไปตรงมาและคุมง่าย:
Go ไม่มี mocking library ที่ "magic" — ใช้ interface
🔄 เทียบกับ C#: Moq/NSubstitute สร้าง mock แบบ dynamic proxy รันไทม์ (
mock.Setup(x => x.FindById(1)).Returns(...)) โดยไม่ต้องเขียน class ปลอมเอง — Go ไม่มีกลไกแบบนี้ (การทำ reflection-based proxy ยากเพราะ Go เป็น static type + implicit interface implementation) จึงต้องเขียน struct ปลอมเองตรง ๆ (hand-written mock อย่างด้านล่าง) หรือใช้ code-gen tool อย่าง mockery/mockgen สร้างให้ล่วงหน้าตอน build ไม่ใช่สร้างตอนรันไทม์
เพราะ Service ด้านล่างรับ UserRepo เป็น interface ไม่ใช่ struct ตรง ๆ เราจึงส่ง struct ปลอมใดๆ ที่มี method FindByID ครบตามสัญญา เข้าไปแทนได้เลยตอนเทสต์ — ไม่ต้องพึ่ง database จริง:
go
// Production
type UserRepo interface {
FindByID(id int) (*User, error)
}
type Service struct {
repo UserRepo
}
func (s *Service) Get(id int) (*User, error) {
return s.repo.FindByID(id)
}Hand-Written Mock
go
// user_test.go
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type mockRepo struct {
findFunc func(id int) (*User, error)
}
func (m *mockRepo) FindByID(id int) (*User, error) {
return m.findFunc(id)
}
func TestService_Get(t *testing.T) {
mock := &mockRepo{
findFunc: func(id int) (*User, error) {
return &User{ID: id, Name: "Mock"}, nil
},
}
svc := &Service{repo: mock}
u, err := svc.Get(1)
require.NoError(t, err)
assert.Equal(t, "Mock", u.Name)
}Mock Generator — mockgen vs mockery
มี 2 ตัวหลักให้เลือก ตามรสนิยมและสไตล์ทีม:
bash
# mockery (testify-flavored, อ่านง่าย)
go install github.com/vektra/mockery/v2@latest
mockery --name UserRepo --output mocks
# mockgen (gomock — เก่าแก่กว่า, strict กว่า, รองรับ generic ดี)
go install go.uber.org/mock/mockgen@latest
mockgen -source=user.go -destination=mocks/user_mock.go→ ใหม่ ๆ ทีมเลือก mockery เพราะ config ง่าย แต่ gomock ยังนิยมในงานที่ต้องการ strict call-order assertion (การตรวจสอบว่า mock ถูกเรียกถูกลำดับและจำนวนครั้งที่กำหนดไว้)
go
// In test
mock := mocks.NewUserRepo(t)
// อ่านว่า: "เมื่อมีคนเรียก FindByID(1) ให้คืนค่า &User{ID:1, Name:"Mock"} กับ nil error"
mock.On("FindByID", 1).Return(&User{ID: 1, Name: "Mock"}, nil)
svc := &Service{repo: mock}
u, _ := svc.Get(1)
mock.AssertExpectations(t) // ตรวจว่า FindByID(1) ถูกเรียกจริง→ Auto-generate mock — verify call count + arguments
9. HTTP Handler Testing
การเทสต์ HTTP handler ไม่ต้องเปิด server จริงหรือยิง network.
Package net/http/httptest ให้เครื่องมือ 2 อย่าง: (1) สร้าง request ปลอม ด้วย httptest.NewRequest, (2) สร้าง recorder (ตัวจับ response) ด้วย httptest.NewRecorder. แล้วเรียก handler ตรง ๆ จากนั้นตรวจ status code และ body ได้ทันที — เร็วและไม่ต้องพึ่งเครือข่าย:
go
import (
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestUserHandler(t *testing.T) {
handler := http.HandlerFunc(GetUser)
req := httptest.NewRequest("GET", "/users/1", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close() // ⚠️ อย่าลืม close body ไม่งั้น leak
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err) // ตรวจ error ของ ReadAll
assert.Contains(t, string(body), "Anna")
}→ httptest — fake request + recorder, no real network
Test Server
go
func TestClient(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, `{"id": 1, "name": "Anna"}`)
}))
defer server.Close()
client := NewClient(server.URL)
u, err := client.GetUser(1)
require.NoError(t, err)
assert.Equal(t, "Anna", u.Name)
}10. Test Concurrent Code
โค้ดที่ใช้ goroutine เทสต์ยากกว่าปกติ ส่วนนี้รวมเทคนิคจำเป็น — รันด้วย -race เพื่อจับ data race, ใช้ select + time.After กันเทสต์ค้าง, และ t.Parallel() ให้หลายเทสต์รันพร้อมกันเพื่อความเร็ว (แต่ระวัง shared state):
Race Detector
bash
go test -race ./...→ Detect data race ขณะ run test
🔄 เทียบกับ C#: .NET ไม่มี built-in race detector แบบนี้ — ที่ใกล้เคียงสุดคือเครื่องมือ third-party (เช่น Concurrency Visualizer ใน Visual Studio หรือ static analyzer) แต่ Go ผูก
-raceมากับ toolchain ให้ใช้ฟรีทันที ควรใช้เป็นนิสัยเวลาเทสต์โค้ด concurrent
Test Goroutine
go
// produce ต้อง close(ch) เมื่อจบ ไม่งั้น for range รอตลอดและเทสต์ค้าง!
func produce(ch chan<- int, n int) {
defer close(ch) // ⭐ สำคัญ — บอก for range ให้หยุด
for i := 0; i < n; i++ {
ch <- i
}
}
func TestProducer(t *testing.T) {
ch := make(chan int, 5)
go produce(ch, 5)
var results []int
for v := range ch { // วนจน ch ถูก close
results = append(results, v)
}
assert.Equal(t, []int{0, 1, 2, 3, 4}, results)
}Use Timeout
go
func TestWithTimeout(t *testing.T) {
done := make(chan struct{})
go func() {
slowOperation()
close(done)
}()
select {
case <-done:
// success
case <-time.After(5 * time.Second):
t.Fatal("timeout")
}
}Parallel Tests
go
func TestSomething(t *testing.T) {
t.Parallel() // run concurrently with other Parallel tests
// ... test
}→ Speed up test suite
bash
go test -parallel 4 ./... # max 4 concurrent⚠️ Beware shared state — use t.Cleanup, no globals
💡 Go 1.22+ note: ก่อนหน้านี้ใน table-driven test ที่ใช้
t.Parallel()ต้องเขียนtt := tt(shadowing) เพื่อกัน loop variable capture bug — ปัญหาเดียวกับที่อธิบายไว้ในบท 8 ข้อ 2 (closure capture ตัวแปร loop). ตั้งแต่ Go 1.22 loop variable มี scope ต่อรอบแล้ว — ไม่ต้องเขียนบรรทัดนั้นอีก. แต่ถ้าโค้ดเก่ายังเห็นtt := ttก็เข้าใจได้ว่าเป็นมรดกตกทอด.
testing/synctest (Go 1.24+ experimental)
💡 ข้ามไปก่อนได้ — เป็น experimental API ที่อาจเปลี่ยนแปลง ใช้เมื่อคุ้นกับ Go มากแล้วค่อยลอง
Go 1.24 เพิ่ม testing/synctest (experimental) สำหรับเทสต์ concurrency แบบ deterministic (ผลลัพธ์แน่นอนซ้ำได้ทุกครั้ง ไม่ขึ้นกับจังหวะเวลาจริง). มันมี fake clock (นาฬิกาจำลองที่ควบคุมได้จากโค้ดเทสต์ แทนนาฬิกาเวลาจริง) และรอให้ goroutine ทั้งหมด blocked ก่อนจะเดินเวลาต่อ. ผลคือเทสต์ที่ใช้ time.Sleep หรือ time.After จะไม่ flaky อีกต่อไป — คำว่า flaky หมายถึงเทสต์ที่บางครั้งผ่านบางครั้งไม่ผ่านโดยไม่ได้แก้โค้ดอะไรเลย (เพราะจังหวะเวลาจริงไม่แน่นอน). คุ้มลองเมื่อทำงานกับ timer/timeout เยอะ.
Detect Goroutine Leak — uber-go/goleak
-race ตรวจ data race ได้ แต่ไม่ตรวจ goroutine ที่ leak (ค้างไม่จบ) — ใช้ goleak ของ Uber ช่วยจับ:
bash
go get go.uber.org/goleakgo
import "go.uber.org/goleak"
// ตรวจ package-wide หลังเทสต์ทั้ง package
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
// หรือต่อเทสต์
func TestNoLeak(t *testing.T) {
defer goleak.VerifyNone(t)
// ... test ที่ spawn goroutine
}→ จำเป็นมากสำหรับ code ที่ใช้ goroutine — leak จะไม่แสดงอาการจนกว่าจะขึ้น production แล้วหน่วยความจำเต็มจนระบบล่ม
t.TempDir — ไม่ต้อง cleanup เอง
go
func TestWriteFile(t *testing.T) {
dir := t.TempDir() // auto-removed หลังเทสต์จบ
path := filepath.Join(dir, "out.txt")
err := os.WriteFile(path, []byte("hi"), 0644)
// ไม่ต้อง defer os.RemoveAll(dir)
}→ ใช้แทน os.MkdirTemp + defer RemoveAll ทุกครั้ง — ปลอดภัยกว่าและสะอาดกว่า
11. Benchmark
นอกจากทดสอบความถูกต้อง Go ยังวัด ความเร็ว ได้ในตัวด้วย benchmark.
วิธีเขียน: ฟังก์ชันขึ้นต้น Benchmark แล้ววน b.N รอบ. Go จะหาจำนวนรอบที่เหมาะเอง (มากพอจะวัดเวลาได้แม่น) และรายงานเวลา/หน่วยความจำต่อครั้งเมื่อเติม flag -benchmem.
ใช้ เทียบก่อน-หลัง optimize ด้วยเครื่องมือ benchstat เพื่อบอกว่า "เร็วขึ้นจริงไหม มีนัยสำคัญทางสถิติหรือเปล่า":
go
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}💡 Go 1.24+ style: เขียนได้สั้นกว่าด้วย
for range b.N { Add(2, 3) }— เหมือนกันทุกประการ แค่กระชับกว่า
bash
go test -bench=. ./...
go test -bench=BenchmarkAdd ./...
go test -bench=. -benchmem ./... # include memoryOutput
text
BenchmarkAdd-8 1000000000 0.234 ns/op 0 B/op 0 allocs/op
│ │ │ │ │
GOMAXPROCS iterations per-op bytes/op allocs/opSub-benchmarks
go
func BenchmarkStringConcat(b *testing.B) {
b.Run("plus", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = "hello" + " " + "world"
}
})
b.Run("builder", func(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
sb.WriteString("hello")
sb.WriteString(" ")
sb.WriteString("world")
_ = sb.String()
}
})
}benchstat — Compare Benchmarks
ต้องติดตั้งก่อนใช้ครั้งแรก (เป็นเครื่องมือแยก ไม่ได้มากับ Go):
bash
go install golang.org/x/perf/cmd/benchstat@latestbash
# Run baseline
go test -bench=. -count=10 > old.txt
# Make change
go test -bench=. -count=10 > new.txt
# Compare
benchstat old.txt new.txt→ Statistical comparison (mean, ±X%, significance)
12. Fuzz Testing (Go 1.18+)
Fuzz testing (Go 1.18+) ช่วยหา bug ที่เรานึกไม่ถึง.
กลไก: แทนที่จะป้อน input เองทีละค่า Go จะสุ่มสร้าง input แปลก ๆ จำนวนมหาศาลแล้วยิงใส่ฟังก์ชัน เพื่อหาเคสที่ทำให้พัง (เช่น panic หรือผลไม่ตรงคุณสมบัติที่ควรเป็น).
ผลลัพธ์: ถ้าเจอ input ที่ทำพัง Go จะเก็บไว้เป็น test corpus อัตโนมัติ — ครั้งหน้าจะรันซ้ำเสมอ:
go
func FuzzReverse(f *testing.F) {
// Seed corpus
f.Add("hello")
f.Add("")
f.Add("a")
f.Fuzz(func(t *testing.T, s string) {
rev := Reverse(s)
revRev := Reverse(rev)
if s != revRev {
t.Errorf("Reverse(Reverse(%q)) = %q", s, revRev)
}
})
}bash
go test -fuzz=FuzzReverse -fuzztime=10s ./...→ Generate random input, find edge case
→ Save crash input as test corpus
13. Example Tests — Document + Test
ฟังก์ชันขึ้นต้นด้วย Example เป็นได้ทั้งเอกสารและเทสต์ในตัวเดียว — เขียนตัวอย่างการใช้งานจริงพร้อมคอมเมนต์ // Output: แล้ว go test จะรันตรวจว่า output ตรงไหม ส่วน pkg.go.dev ก็แสดงเป็นตัวอย่างให้ผู้ใช้ดู:
go
func ExampleReverse() {
s := Reverse("hello")
fmt.Println(s)
// Output: olleh
}
func ExampleUser_Greet() {
u := User{Name: "Anna"}
fmt.Println(u.Greet())
// Output: Hello, Anna!
}bash
go test # runs Example* — verify Output matches→ Documentation + Test ในตัวเดียว — pkg.go.dev แสดงด้วย
14. Test Helpers
เมื่อหลายเทสต์ต้องเตรียมข้อมูลเหมือนกัน เราแยกออกเป็น "helper function" ได้ จุดสำคัญคือเรียก t.Helper() ข้างใน เพื่อให้ตอน assertion ล้ม Go ชี้บรรทัดที่ผิดในเทสต์จริง (ไม่ใช่ในตัว helper) และใช้ t.Cleanup() เก็บกวาดหลังเทสต์จบ:
go
func setupTestUser(t *testing.T) *User {
t.Helper() // line number adjustment
u, err := repo.Create(User{Name: "test"})
require.NoError(t, err)
t.Cleanup(func() {
repo.Delete(u.ID)
})
return u
}
func TestSomething(t *testing.T) {
u := setupTestUser(t)
// ... test
}t.Helper() = mark function as helper — when assertion fails, line number show caller, not helper
15. Golden Files
เวลาเทสต์ที่ output ใหญ่ (HTML ที่ render, ผลจาก code generator) การเขียนค่าคาดหวังในโค้ดจะยุ่งยากและอ่านไม่ออก.
เทคนิค golden file แก้โดยเก็บผลลัพธ์ที่ถูกต้องไว้ในไฟล์ (เช่น testdata/render.golden) แล้วเทสต์เทียบกับไฟล์นั้น. เวลาโค้ดเปลี่ยน "โดยตั้งใจ" ก็รันด้วย flag -update เพื่อสร้างไฟล์ใหม่:
For test ที่ compare ผล big output:
go
import (
"bytes"
"flag"
"os"
"path/filepath"
"testing"
)
var update = flag.Bool("update", false, "update golden files")
// Render คืน []byte ของผล output (เช่น HTML ที่ render)
func TestRender(t *testing.T) {
got := Render(input)
golden := filepath.Join("testdata", "render.golden")
if *update {
if err := os.WriteFile(golden, got, 0644); err != nil {
t.Fatal(err)
}
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("cannot read golden file: %v", err)
}
if !bytes.Equal(got, want) {
t.Errorf("output differs (run with -update to regenerate)")
}
}bash
go test # run normal
go test -update # regenerate golden→ Useful for HTML render, code generator, etc.
16. Testcontainers — Integration Test
สำหรับ integration test ที่ต้องใช้ database จริง การ mock อาจไม่สะท้อนพฤติกรรมจริง testcontainers ช่วยสตาร์ท database จริง (เช่น Postgres) ใน Docker container ชั่วคราวระหว่างเทสต์ แล้วลบทิ้งเมื่อจบ — ได้ความสมจริงโดยไม่ต้องตั้ง DB ถาวร:
go
import (
"context"
"database/sql"
"fmt"
"testing"
_ "github.com/lib/pq"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
func TestRepository(t *testing.T) {
ctx := context.Background()
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "postgres:16-alpine",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_PASSWORD": "test",
"POSTGRES_DB": "testdb",
},
WaitingFor: wait.ForListeningPort("5432/tcp"),
},
Started: true,
})
require.NoError(t, err)
defer container.Terminate(ctx)
host, _ := container.Host(ctx)
port, _ := container.MappedPort(ctx, "5432")
dsn := fmt.Sprintf("postgres://postgres:test@%s:%s/testdb?sslmode=disable", host, port.Port())
db, _ := sql.Open("postgres", dsn)
repo := NewRepository(db)
// ... test against real Postgres
}→ Real database in container — better than mocks สำหรับ integration test
17. Test Organization
เมื่อโปรเจกต์โต ควรจัดระเบียบเทสต์ตามระดับ: unit (เร็ว — อยู่ข้าง production code), integration และ e2e (ช้า — แยกโฟลเดอร์). ใช้ build tag (//go:build integration) แยกเทสต์ที่ช้า เพื่อให้รัน unit test เร็ว ๆ ระหว่างพัฒนา แล้วรันตัวช้าเฉพาะตอนจำเป็น (CI nightly, ก่อน release):
text
myapp/
├── user/
│ ├── user.go
│ ├── user_test.go ← unit test
│ ├── repository.go
│ ├── repository_test.go ← unit test
│ └── service_test.go ← service test
├── integration/
│ └── user_flow_test.go ← integration test
└── e2e/
└── api_test.go ← end-to-end testTags for Slow Tests
Build tag = comment พิเศษที่บอก compiler ว่า "จะ include ไฟล์นี้เมื่อมี tag ที่ตรงเท่านั้น". ใช้แยกไฟล์เทสต์ช้า/ต้องใช้ resource พิเศษ ไม่ให้รันใน CI ปกติ:
go
//go:build integration
package user_test
func TestUserFlow(t *testing.T) {
// ... slow integration test
}💡 note: สมัยก่อนต้องเขียน 2 บรรทัด (
//go:build integration+// +build integration). แบบ// +buildเลิกใช้ตั้งแต่ Go 1.17 (ปี 2021) — โค้ดใหม่ใช้แค่//go:buildพอ.
bash
go test ./... # unit tests only
go test -tags=integration ./... # include integration18. Coverage Best Practice
แนวทางการตั้งเป้า coverage ที่สมเหตุผล — แบ่งสัดส่วนเทสต์ (unit เยอะสุด, e2e น้อยสุด) และตั้งเป้าต่างกันตามความสำคัญของโค้ด (business logic สูง, glue code ปานกลาง) ไม่ต้องไล่ล่า 100% กับโค้ดที่ไม่คุ้ม:
text
Test types + ratio:
- Unit tests: 70%
- Integration: 20%
- E2E: 10%
Coverage target:
- 80%+ for core business logic
- 60%+ for handler/glue code
- 100% for shared utility / framework code
Don't chase 100%:
- Generated code (skip)
- Trivial getter/setter
- Init / main19. CI Integration
เทสต์มีค่าสูงสุดเมื่อรันอัตโนมัติทุกครั้งที่ push — ตัวอย่าง GitHub Actions นี้รัน go test -race พร้อมวัด coverage และ lint ทุก push/PR ถ้าเทสต์ไม่ผ่านก็บล็อกการ merge:
yaml
# GitHub Actions
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Test
run: go test -race -coverprofile=coverage.out ./...
- name: Coverage
run: |
total=$(go tool cover -func=coverage.out | grep total | awk '{print $3}')
echo "Coverage: $total"
- name: Lint
uses: golangci/golangci-lint-action@v6
with:
version: v1.6220. Linting + Static Analysis
นอกจากเทสต์ เครื่องมือวิเคราะห์โค้ดแบบ static ช่วยจับปัญหาก่อนรันด้วย — go vet (มาในตัว) จับ bug ทั่วไป, golangci-lint (นิยมสุด) รวม linter หลายตัวไว้ด้วยกัน ควรรันใน CI เพื่อรักษาคุณภาพโค้ดทั้งทีม:
bash
# Built-in
go vet ./...
# Format
gofmt -l .
goimports -l -w .
# golangci-lint (most popular)
golangci-lint run
# staticcheck
staticcheck ./...golangci-lint config
yaml
# .golangci.yml
linters:
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- unused
- revive
- gosec
- misspell
- unparam
- gocyclo
linters-settings:
gocyclo:
min-complexity: 1521. Test Anti-patterns
รวมรูปแบบเทสต์ที่ "ดูเหมือนดีแต่เป็นปัญหา" — ทดสอบ internal state แทนพฤติกรรม (เปราะเวลา refactor), รวมหลายเรื่องในเทสต์เดียว, เทสต์ที่ไม่ได้ตรวจอะไรจริง, และเทสต์ที่ขึ้นกับลำดับการรัน หลีกเลี่ยงสิ่งเหล่านี้แล้วเทสต์จะเชื่อถือได้:
❌ Test implementation, not behavior
go
// ❌ Test internal state
func TestService(t *testing.T) {
svc := New()
svc.DoSomething()
assert.Equal(t, 5, svc.internalCounter) // brittle
}
// ✅ Test observable behavior
func TestService(t *testing.T) {
svc := New()
svc.DoSomething()
result := svc.GetResult()
assert.Equal(t, expectedResult, result)
}❌ Test multiple things
go
// ❌
func TestEverything(t *testing.T) {
// 200 lines testing 10 different things
}
// ✅
func TestCreate(t *testing.T) { ... }
func TestUpdate(t *testing.T) { ... }
func TestDelete(t *testing.T) { ... }❌ Test that's not really testing
go
// ❌
func TestNewUser(t *testing.T) {
u := NewUser("Anna")
if u == nil {
t.Error("expected non-nil")
}
// No real assertion of behavior
}
// ✅
func TestNewUser(t *testing.T) {
u := NewUser("Anna", "anna@x.com")
assert.Equal(t, "Anna", u.Name)
assert.Equal(t, "anna@x.com", u.Email)
assert.NotZero(t, u.CreatedAt)
}❌ Order-dependent tests
go
// ❌ Test1 sets global, Test2 reads it
var globalCount int
func Test1(t *testing.T) { globalCount = 5 }
func Test2(t *testing.T) { assert.Equal(t, 5, globalCount) }
// ✅ Independent
func Test2(t *testing.T) {
setupCount := 5
// ...
}22. Real Example — Complete
รวมทุกอย่างในบทเข้าด้วยกันเป็นชุดเทสต์ที่สมบูรณ์ของ package เดียว — มีครบทั้ง table-driven test, การทดสอบ error ด้วย errors.Is, benchmark และ example test ใช้เป็นแม่แบบของไฟล์ _test.go ที่ดีได้เลย:
go
// math/calculator.go
package calculator
import "errors"
var ErrDivideByZero = errors.New("division by zero")
func Add(a, b int) int { return a + b }
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, ErrDivideByZero
}
return a / b, nil
}go
// math/calculator_test.go
package calculator_test
import (
"errors"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/myuser/myapp/math/calculator"
)
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"basic", 2, 3, 5},
{"zeros", 0, 0, 0},
{"negative", -2, -3, -5},
{"mixed", -5, 10, 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := calculator.Add(tt.a, tt.b)
assert.Equal(t, tt.want, got)
})
}
}
func TestDivide(t *testing.T) {
t.Run("success", func(t *testing.T) {
got, err := calculator.Divide(10, 2)
require.NoError(t, err)
assert.Equal(t, 5, got)
})
t.Run("divide by zero", func(t *testing.T) {
_, err := calculator.Divide(10, 0)
require.Error(t, err)
assert.True(t, errors.Is(err, calculator.ErrDivideByZero))
})
}
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
calculator.Add(2, 3)
}
}
func ExampleAdd() {
fmt.Println(calculator.Add(2, 3))
// Output: 5
}bash
go test ./...
# PASS
go test -v ./...
# === RUN TestAdd
# === RUN TestAdd/basic
# --- PASS: TestAdd (0.00s)
# --- PASS: TestAdd/basic (0.00s)
# ...
go test -cover ./...
# coverage: 100.0% of statements
go test -bench=. -benchmem ./...
# BenchmarkAdd-8 1000000000 0.234 ns/op 0 B/op 0 allocs/op23. ⚠️ Pitfalls
สรุปกับดักเรื่องการเทสต์ทั้งหมดในบทไว้ในตารางเดียว คู่กับวิธีที่ถูก — ใช้เป็นเช็คลิสต์ทบทวนก่อนส่งโค้ด:
| ❌ | ✅ |
|---|---|
| Global state in test | t.Cleanup or per-test setup |
| Tests depend on order | Independent tests |
| Test internal implementation | Test behavior |
time.Sleep instead of sync | Use channel/sync |
| Mock everything | Mock only external boundary |
| 100% coverage at all cost | Focus critical path |
| Test in main package | Use _test package |
| Slow unit test (DB call) | Mock or integration test |
if assert.Equal(...) เข้าใจผิดว่าต้อง if ตรวจ | assert.Equal เรียก t.Error ให้แล้วเมื่อ fail — ถ้าอยากหยุดต่อเมื่อ fail ใช้ require.Equal |
24. Checkpoint
ลองฝึกเขียนเทสต์ด้วยตัวเองตามโจทย์ด้านล่าง ไล่จาก unit test พื้นฐาน, mock, HTTP test, benchmark ไปจนถึง fuzz test เพื่อให้จับครบทุกเทคนิคในบท:
🛠️ Checkpoint 10.1 — Basic Test (เทสต์พื้นฐาน)
เขียนเทสต์ให้:
Add(a, b)— แบบ table-driven, 5+ เคสDivide(a, b)— รวมเคสที่เกิด error ด้วย- รัน + ดู coverage (เปอร์เซ็นต์ที่เทสต์ครอบคลุม)
🛠️ Checkpoint 10.2 — Mock (ตัวจำลองสำหรับเทสต์)
- นิยาม interface
Storage - Service พึ่งพา Storage
- เขียน mock (ตัวปลอม) เอง
- ทดสอบ service ด้วย mock
🛠️ Checkpoint 10.3 — HTTP Test
- HTTP handler ที่คืนค่า JSON
- ทดสอบด้วย
httptest.NewRecorder - ตรวจ (assert) status + body
🛠️ Checkpoint 10.4 — Benchmark (วัดความเร็ว)
เปรียบเทียบ:
- ต่อ string ด้วย
+ fmt.Sprintfstrings.Builder
รัน benchmark + ดูว่าตัวไหนเร็วสุด
🛠️ Checkpoint 10.5 — Fuzz (เทสต์ป้อนข้อมูลสุ่ม)
เขียน fuzz test ให้:
- ฟังก์ชัน Reverse (คุณสมบัติ:
Reverse(Reverse(s)) == s) - ฟังก์ชัน Parse
- ฟังก์ชัน Hash (ต้องไม่ panic)
รัน 30 วินาที → ดูว่ามี crash ไหม
25. สรุปบท
ทบทวนภาพรวมการเทสต์ใน Go — ตั้งแต่ testing ในตัว, table-driven, testify, mock ด้วย interface, httptest, benchmark, fuzz, ไปจนถึงการจัด coverage และหลีกเลี่ยง anti-pattern:
✅ Test = function TestXxx(t *testing.T) ใน *_test.go file
✅ Table-driven + t.Run = Go idiom สำหรับ multiple case
✅ testify (assert + require) = community standard
✅ t.Helper(), t.Cleanup(), t.Parallel() — built-in helpers
✅ Mock with interface + struct — no magic library needed
✅ httptest สำหรับ HTTP — fake request + recorder
✅ go test -race — detect race condition
✅ Benchmark = BenchmarkXxx(b *testing.B) + b.N
✅ Fuzz (Go 1.18+) — generate random input
✅ Example tests = documentation + verification
✅ Coverage: target 80% on critical, less on glue
✅ Anti-patterns: test impl, order-dependent, global state