Skip to content

บทที่ 10 — Testing

← บทที่ 9 | สารบัญ | บทที่ 11 →

หลังจบบท คุณจะ:

  • เขียนเทสต์ 3 รูปแบบหลัก:
    • unit test = เทสต์ทีละหน่วยย่อย เช่น 1 ฟังก์ชัน
    • table-driven test = เทสต์ที่ไล่หลายเคสจากตาราง (Go idiom)
    • subtests = เทสต์ย่อยในเทสต์เดียวด้วย t.Run
  • ใช้ plain testing package ของมาตรฐาน (idiomatic) และรู้จัก testify (ไลบรารียอดนิยม) เป็นทางเลือก
  • เขียน benchmark (วัดความเร็วโค้ด) + วัด coverage (เทสต์ครอบคลุมโค้ดกี่ %)
  • เขียน fuzz test = สุ่มข้อมูลจำนวนมากมาลองยิงใส่ฟังก์ชัน เพื่อหาเคสที่ทำให้พัง
  • เทสต์ concurrent code (โค้ดที่ทำงานขนานกัน) + HTTP handler
  • เข้าใจ mocking (สร้างตัวปลอมมาแทน dependency จริงตอนเทสต์)

ก่อนอ่านบทนี้ ควรเข้าใจ: บท 3 — Functions, บท 5 — Structs + Methods, บท 6 — Interfaces (จำเป็นสำหรับ mocking), บท 9 — Packages (จำเป็นสำหรับ _test.go convention)


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 packages

2. 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 _test package)

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 testing package ที่เห็นข้างบน. หลายคนในชุมชน Go (เช่น Dave Cheney — Go contributor ที่มีชื่อเสียงเรื่องเขียนบทความ idiomatic Go) ไม่แนะนำ testify. เหตุผลคือมัน "ซ่อน" การตรวจสอบและทำให้ error message (ข้อความแจ้ง error) แม่นยำน้อยลง — แต่นี่เป็นความเห็นของชุมชน ไม่ใช่จุดยืนทางการของทีม Go เอง. ในงานจริงหลายทีมยังใช้ testify เพราะอ่านง่ายและเขียนสั้น.

แนะนำสำหรับมือใหม่: ใช้ plain testing package ก่อน. ถ้าทีมของคุณใช้ 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/testify
go
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.WithinDuration

6. 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.out

Coverage 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 thoroughly

7. 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/goleak
go
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 memory

Output

text
BenchmarkAdd-8       1000000000      0.234 ns/op    0 B/op    0 allocs/op
                 │             │         │             │           │
                 GOMAXPROCS    iterations  per-op    bytes/op   allocs/op

Sub-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@latest
bash
# 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 test

Tags 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 integration

18. 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 / main

19. 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.62

20. 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: 15

21. 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/op

23. ⚠️ Pitfalls

สรุปกับดักเรื่องการเทสต์ทั้งหมดในบทไว้ในตารางเดียว คู่กับวิธีที่ถูก — ใช้เป็นเช็คลิสต์ทบทวนก่อนส่งโค้ด:

Global state in testt.Cleanup or per-test setup
Tests depend on orderIndependent tests
Test internal implementationTest behavior
time.Sleep instead of syncUse channel/sync
Mock everythingMock only external boundary
100% coverage at all costFocus critical path
Test in main packageUse _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.Sprintf
  • strings.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


← บทที่ 9 | บทที่ 11 → Standard Library