Home > Blog > tech

Go Testing คืออะไร? สอน Unit Test, Table-Driven Test, Benchmark สำหรับ Go Developer 2026

Go Testing Benchmarking Guide 2026
2026-04-16 | tech | 4500 words

Go ออกแบบ Testing มาเป็นส่วนหนึ่งของภาษาตั้งแต่แรก — testing package อยู่ใน Standard library, go test เป็น Built-in command ไม่ต้องติดตั้ง Framework เพิ่ม ปรัชญาของ Go คือ "Testing should be simple" ไม่ต้องมี Magic, Annotation, หรือ Decorator แค่เขียน Function ที่ชื่อขึ้นต้นด้วย Test ก็พอ

Go Testing พื้นฐาน

กฎการตั้งชื่อ

เขียน Test แรก

// math.go
package math

func Add(a, b int) int {
    return a + b
}

func Subtract(a, b int) int {
    return a - b
}

func Divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, fmt.Errorf("cannot divide by zero")
    }
    return a / b, nil
}
// math_test.go
package math

import "testing"

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("Add(2, 3) = %d; want 5", result)
    }
}

func TestSubtract(t *testing.T) {
    result := Subtract(10, 3)
    if result != 7 {
        t.Errorf("Subtract(10, 3) = %d; want 7", result)
    }
}

func TestDivide(t *testing.T) {
    result, err := Divide(10, 2)
    if err != nil {
        t.Fatalf("Divide(10, 2) returned error: %v", err)
    }
    if result != 5.0 {
        t.Errorf("Divide(10, 2) = %f; want 5.0", result)
    }
}

func TestDivideByZero(t *testing.T) {
    _, err := Divide(10, 0)
    if err == nil {
        t.Error("Divide(10, 0) should return error")
    }
}
# รัน Test
go test ./...                    # ทุก Package
go test ./math                   # เฉพาะ Package math
go test -v ./...                 # Verbose output
go test -run TestAdd ./math      # เฉพาะ Test ที่ match "TestAdd"
go test -count=1 ./...           # ไม่ใช้ Cache

Table-Driven Tests — วิธี Idiomatic Go

Table-Driven Test คือ Pattern ที่ Go developers ใช้มากที่สุด แทนที่จะเขียน Test function แยกสำหรับแต่ละ Case ให้รวมทุก Case ไว้ใน Table (Slice of struct) แล้ว Loop ทดสอบ:

func TestAdd_TableDriven(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"positive numbers", 2, 3, 5},
        {"negative numbers", -1, -2, -3},
        {"mixed", -1, 5, 4},
        {"zeros", 0, 0, 0},
        {"large numbers", 1000000, 2000000, 3000000},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := Add(tt.a, tt.b)
            if result != tt.expected {
                t.Errorf("Add(%d, %d) = %d; want %d",
                    tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

func TestDivide_TableDriven(t *testing.T) {
    tests := []struct {
        name      string
        a, b      float64
        expected  float64
        expectErr bool
    }{
        {"normal division", 10, 2, 5.0, false},
        {"decimal result", 7, 2, 3.5, false},
        {"divide by zero", 10, 0, 0, true},
        {"negative", -10, 2, -5.0, false},
        {"both negative", -10, -2, 5.0, false},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result, err := Divide(tt.a, tt.b)
            if tt.expectErr {
                if err == nil {
                    t.Error("expected error but got nil")
                }
                return
            }
            if err != nil {
                t.Fatalf("unexpected error: %v", err)
            }
            if result != tt.expected {
                t.Errorf("Divide(%f, %f) = %f; want %f",
                    tt.a, tt.b, result, tt.expected)
            }
        })
    }
}
ทำไมต้อง Table-Driven?

Subtests (t.Run) และ Test Helpers (t.Helper)

// Subtests — จัดกลุ่ม Test
func TestUserService(t *testing.T) {
    t.Run("Create", func(t *testing.T) {
        t.Run("valid user", func(t *testing.T) { /* ... */ })
        t.Run("duplicate email", func(t *testing.T) { /* ... */ })
        t.Run("invalid email", func(t *testing.T) { /* ... */ })
    })

    t.Run("Get", func(t *testing.T) {
        t.Run("existing user", func(t *testing.T) { /* ... */ })
        t.Run("non-existing user", func(t *testing.T) { /* ... */ })
    })
}

// Run เฉพาะ subtest:
// go test -run TestUserService/Create/valid_user

// Test Helper — ฟังก์ชันช่วย (ไม่นับเป็น Test)
func assertEqual(t *testing.T, got, want interface{}) {
    t.Helper() // ← สำคัญ! ทำให้ Error message แสดง Line ของ Caller ไม่ใช่ Helper
    if got != want {
        t.Errorf("got %v; want %v", got, want)
    }
}

func TestWithHelper(t *testing.T) {
    assertEqual(t, Add(2, 3), 5)     // Error จะชี้ไปที่บรรทัดนี้
    assertEqual(t, Add(-1, 1), 0)
}

Testify — Popular Testing Library

// go get github.com/stretchr/testify

import (
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestWithTestify(t *testing.T) {
    // assert — ถ้า Fail ยังรัน Test ต่อ
    assert.Equal(t, 5, Add(2, 3))
    assert.NotEqual(t, 0, Add(2, 3))
    assert.True(t, Add(2, 3) > 0)
    assert.Contains(t, "hello world", "world")
    assert.Nil(t, err)
    assert.NotNil(t, result)
    assert.Len(t, slice, 3)

    // require — ถ้า Fail หยุด Test ทันที (เหมือน t.Fatal)
    result, err := Divide(10, 2)
    require.NoError(t, err)       // ถ้า Error → หยุดเลย ไม่ต้อง check result
    require.Equal(t, 5.0, result)

    // assert.Error สำหรับ Expected errors
    _, err = Divide(10, 0)
    assert.Error(t, err)
    assert.ErrorContains(t, err, "divide by zero")
}

httptest — ทดสอบ HTTP Handlers

// handler.go
func HealthHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

func UserHandler(w http.ResponseWriter, r *http.Request) {
    id := r.URL.Query().Get("id")
    if id == "" {
        http.Error(w, "id required", http.StatusBadRequest)
        return
    }
    json.NewEncoder(w).Encode(map[string]string{"id": id, "name": "Test User"})
}

// handler_test.go
import (
    "net/http"
    "net/http/httptest"
    "testing"
    "encoding/json"
)

func TestHealthHandler(t *testing.T) {
    req := httptest.NewRequest("GET", "/health", nil)
    rec := httptest.NewRecorder()

    HealthHandler(rec, req)

    // Check status code
    if rec.Code != http.StatusOK {
        t.Errorf("status = %d; want 200", rec.Code)
    }

    // Check response body
    var body map[string]string
    json.NewDecoder(rec.Body).Decode(&body)
    if body["status"] != "ok" {
        t.Errorf("status = %s; want ok", body["status"])
    }
}

func TestUserHandler_NoID(t *testing.T) {
    req := httptest.NewRequest("GET", "/user", nil)
    rec := httptest.NewRecorder()

    UserHandler(rec, req)

    if rec.Code != http.StatusBadRequest {
        t.Errorf("status = %d; want 400", rec.Code)
    }
}

// ทดสอบกับ Real HTTP server
func TestUserHandler_Integration(t *testing.T) {
    server := httptest.NewServer(http.HandlerFunc(UserHandler))
    defer server.Close()

    resp, err := http.Get(server.URL + "/user?id=123")
    if err != nil {
        t.Fatalf("request failed: %v", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != 200 {
        t.Errorf("status = %d; want 200", resp.StatusCode)
    }
}

Testing with Database (testcontainers-go)

// go get github.com/testcontainers/testcontainers-go

import (
    "context"
    "testing"
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/wait"
)

func setupPostgres(t *testing.T) (string, func()) {
    t.Helper()
    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_USER":     "test",
                "POSTGRES_PASSWORD": "test",
                "POSTGRES_DB":       "testdb",
            },
            WaitingFor: wait.ForLog("database system is ready to accept connections").
                WithOccurrence(2),
        },
        Started: true,
    })
    if err != nil {
        t.Fatalf("failed to start container: %v", err)
    }

    host, _ := container.Host(ctx)
    port, _ := container.MappedPort(ctx, "5432")
    dsn := fmt.Sprintf("postgres://test:test@%s:%s/testdb?sslmode=disable", host, port.Port())

    cleanup := func() { container.Terminate(ctx) }
    return dsn, cleanup
}

func TestUserRepository(t *testing.T) {
    dsn, cleanup := setupPostgres(t)
    defer cleanup()

    db, err := sql.Open("postgres", dsn)
    require.NoError(t, err)

    repo := NewUserRepository(db)
    // ... test CRUD operations ...
}

Coverage

# รัน Test พร้อม Coverage
go test -cover ./...
# Output: coverage: 85.2% of statements

# สร้าง Coverage profile
go test -coverprofile=coverage.out ./...

# ดู Coverage แบบ HTML (เปิด Browser)
go tool cover -html=coverage.out

# ดู Coverage ทีละ Function
go tool cover -func=coverage.out

# Coverage เฉพาะ Package
go test -coverprofile=coverage.out -coverpkg=./... ./...

# ตั้ง Minimum coverage ใน CI:
# go test -coverprofile=coverage.out ./...
# COVERAGE=$(go tool cover -func=coverage.out | tail -1 | awk '{print $3}' | tr -d '%')
# if (( $(echo "$COVERAGE < 80" | bc -l) )); then
#   echo "Coverage $COVERAGE% is below 80%"
#   exit 1
# fi

Benchmarking

// Benchmark functions ขึ้นต้นด้วย Benchmark + parameter *testing.B

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(2, 3)
    }
}

func BenchmarkDivide(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Divide(10.0, 3.0)
    }
}

// Benchmark with allocation reporting
func BenchmarkStringConcat(b *testing.B) {
    b.ReportAllocs()  // รายงาน Memory allocation
    for i := 0; i < b.N; i++ {
        s := ""
        for j := 0; j < 100; j++ {
            s += "a"
        }
    }
}

func BenchmarkStringBuilder(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        var sb strings.Builder
        for j := 0; j < 100; j++ {
            sb.WriteString("a")
        }
        _ = sb.String()
    }
}
# รัน Benchmark
go test -bench=. ./...
go test -bench=BenchmarkAdd ./...
go test -bench=. -benchmem ./...     # รวม Memory allocation
go test -bench=. -count=5 ./...      # รัน 5 ครั้ง (สำหรับ benchstat)
go test -bench=. -benchtime=5s ./... # รันนานขึ้น (default 1s)

# Output ตัวอย่าง:
# BenchmarkAdd-8            1000000000    0.2891 ns/op
# BenchmarkStringConcat-8      15241    78542 ns/op    26456 B/op   99 allocs/op
# BenchmarkStringBuilder-8    234567     5123 ns/op      752 B/op    7 allocs/op
#                                                        ↑ Builder เร็วกว่า 15 เท่า!

# เปรียบเทียบ Benchmark ด้วย benchstat:
# go install golang.org/x/perf/cmd/benchstat@latest
# go test -bench=. -count=10 ./... > old.txt
# (ปรับปรุง Code)
# go test -bench=. -count=10 ./... > new.txt
# benchstat old.txt new.txt

Fuzz Testing (Go 1.18+)

// Fuzz testing ให้ Go สร้าง Input แบบ Random เพื่อหา Edge case

func FuzzDivide(f *testing.F) {
    // Seed corpus — ตัวอย่าง Input เริ่มต้น
    f.Add(10.0, 2.0)
    f.Add(0.0, 1.0)
    f.Add(-5.0, 3.0)
    f.Add(100.0, 0.1)

    f.Fuzz(func(t *testing.T, a, b float64) {
        if b == 0 {
            // Skip divide by zero — เรารู้ว่า Error
            t.Skip("skip divide by zero")
        }
        result, err := Divide(a, b)
        if err != nil {
            t.Errorf("Divide(%f, %f) returned unexpected error: %v", a, b, err)
        }
        // Check: a / b * b ≈ a (ยกเว้น Floating point precision)
        if math.Abs(result*b-a) > 1e-9 {
            t.Errorf("Divide(%f, %f) = %f; result*b = %f != a", a, b, result, result*b)
        }
    })
}

// Fuzz สำหรับ String parsing
func FuzzParseJSON(f *testing.F) {
    f.Add(`{"name": "test"}`)
    f.Add(`{"age": 25}`)
    f.Add(`[]`)
    f.Add(`""`)

    f.Fuzz(func(t *testing.T, input string) {
        var result interface{}
        err := json.Unmarshal([]byte(input), &result)
        if err != nil {
            return // Invalid JSON — OK
        }
        // ถ้า Parse ได้ ต้อง Marshal กลับได้
        _, err = json.Marshal(result)
        if err != nil {
            t.Errorf("Marshal failed for valid JSON: %v", err)
        }
    })
}
# รัน Fuzz test
go test -fuzz=FuzzDivide ./...
go test -fuzz=FuzzDivide -fuzztime=30s ./...   # รัน 30 วินาที
go test -fuzz=FuzzDivide -fuzztime=1000x ./... # รัน 1000 iterations

# Fuzz corpus ถูกเก็บใน testdata/fuzz/
# ถ้าพบ Bug → Go สร้างไฟล์ใน testdata/fuzz/FuzzDivide/ → ใช้เป็น Regression test

Race Detection

# Go มี Built-in Race detector — ตรวจจับ Data race

# รัน Test with Race detection
go test -race ./...

# Build with Race detection
go build -race -o myapp

# Run with Race detection
go run -race main.go

# Output เมื่อพบ Race:
# ==================
# WARNING: DATA RACE
# Write at 0x00c0000b4018 by goroutine 7:
#   main.increment()
#       /path/main.go:15 +0x48
#
# Previous read at 0x00c0000b4018 by goroutine 8:
#   main.getCount()
#       /path/main.go:20 +0x38
# ==================

# ใช้ -race ตอน Test เสมอ! อย่าลืมใส่ใน CI
# (อย่าใช้ใน Production — ช้าลง 2-10x, ใช้ Memory มากขึ้น 5-10x)

Test Fixtures และ Golden Files

// Test fixtures — ข้อมูลทดสอบเก็บใน testdata/
// Go จะ ignore directory ชื่อ testdata ตอน Build

// testdata/input.json
// testdata/expected_output.json

func TestProcessData(t *testing.T) {
    // Read input fixture
    input, err := os.ReadFile("testdata/input.json")
    require.NoError(t, err)

    result := ProcessData(input)

    // Golden file pattern — ถ้า -update flag → เขียน expected output ใหม่
    golden := "testdata/expected_output.json"
    if *update {
        os.WriteFile(golden, result, 0644)
    }

    expected, err := os.ReadFile(golden)
    require.NoError(t, err)
    assert.JSONEq(t, string(expected), string(result))
}

// Flag สำหรับ Update golden files
var update = flag.Bool("update", false, "update golden files")

func TestMain(m *testing.M) {
    flag.Parse()
    os.Exit(m.Run())
}

// รัน: go test -update ./...  (อัปเดต Golden files)
// รัน: go test ./...          (เปรียบเทียบกับ Golden files)

TestMain — Setup/Teardown

// TestMain ใช้สำหรับ Setup/Teardown ที่ทำครั้งเดียว (ไม่ใช่ทุก Test)

func TestMain(m *testing.M) {
    // Setup — ทำก่อน Test ทั้งหมด
    fmt.Println("=== SETUP ===")
    db := setupDatabase()
    defer db.Close()

    // Run all tests
    code := m.Run()

    // Teardown — ทำหลัง Test ทั้งหมด
    fmt.Println("=== TEARDOWN ===")
    cleanupDatabase()

    os.Exit(code)
}

// สำหรับ Per-test setup/teardown ใช้ t.Cleanup():
func TestSomething(t *testing.T) {
    resource := createResource()
    t.Cleanup(func() {
        resource.Close() // ถูกเรียกหลัง Test จบ (แม้ Fail)
    })
    // ... test ...
}

go test Flags

Flagหน้าที่ตัวอย่าง
-vVerbose outputgo test -v ./...
-run REGEXรันเฉพาะ Test ที่ matchgo test -run TestAdd ./...
-count Nรัน N ครั้ง (ไม่ Cache)go test -count=1 ./...
-parallel Nจำนวน Test ที่รัน Parallelgo test -parallel 4 ./...
-raceเปิด Race detectorgo test -race ./...
-coverแสดง Coveragego test -cover ./...
-coverprofileสร้าง Coverage filego test -coverprofile=c.out ./...
-bench REGEXรัน Benchmarkgo test -bench=. ./...
-benchmemรายงาน Memory ใน Benchmarkgo test -bench=. -benchmem ./...
-fuzz REGEXรัน Fuzz testgo test -fuzz=Fuzz ./...
-timeout DTimeout (default 10m)go test -timeout 30s ./...
-shortSkip Long-running testsgo test -short ./...
-failfastหยุดทันทีเมื่อ Test แรก Failgo test -failfast ./...

Integration Tests

// แยก Integration test ด้วย Build tag หรือ -short flag

// === วิธีที่ 1: ใช้ -short flag ===
func TestDatabaseIntegration(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping integration test in short mode")
    }
    // ... integration test ที่ต้อง Database จริง ...
}

// รัน: go test ./...         (รัน Integration test ด้วย)
// รัน: go test -short ./...  (ข้าม Integration test)

// === วิธีที่ 2: ใช้ Build tag ===
//go:build integration

package mypackage

func TestDatabaseIntegration(t *testing.T) {
    // ... integration test ...
}

// รัน: go test -tags=integration ./...

CI Integration

# GitHub Actions — Go test pipeline
# .github/workflows/test.yml

name: Go 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.22'

      - name: Run tests
        run: go test -v -race -coverprofile=coverage.out ./...

      - name: Check coverage
        run: |
          COVERAGE=$(go tool cover -func=coverage.out | tail -1 | awk '{print $3}' | tr -d '%')
          echo "Coverage: $COVERAGE%"
          if (( $(echo "$COVERAGE < 80" | bc -l) )); then
            echo "Coverage below 80%!"
            exit 1
          fi

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          file: ./coverage.out

Testing Best Practices

  1. ใช้ Table-Driven Tests เป็นหลัก: มาตรฐานของ Go community ง่ายต่อการเพิ่ม Case
  2. Test ชื่อ function ให้สื่อ: TestUser_Create_DuplicateEmail_ReturnsError ดีกว่า TestUser2
  3. ใช้ t.Helper(): สำหรับ Helper function เพื่อให้ Error message แสดง Line ที่ถูกต้อง
  4. ใช้ t.Parallel(): สำหรับ Test ที่ Independent เร่งเวลารัน
  5. ใช้ -race ทุกครั้ง: ตอน Dev และ CI เสมอ จับ Data race ก่อน Production
  6. Coverage ไม่ใช่ทุกอย่าง: 80% coverage ที่ Test สิ่งสำคัญ ดีกว่า 100% coverage ที่ Test แค่ Happy path
  7. Benchmark เมื่อจำเป็น: อย่า Optimize ก่อน Benchmark ถ้าไม่มีตัวเลข ≠ ไม่มี Optimization
  8. Fuzz test สำหรับ Parser/Validator: ฟังก์ชันที่รับ Input จาก User ควร Fuzz

Go Testing ง่ายกว่าภาษาอื่นมาก เพราะทุกอย่าง Built-in ไม่ต้อง Config ไม่ต้องติดตั้ง Framework แค่ go test ./... ก็เริ่มได้ เริ่มเขียน Test วันนี้ แล้วคุณจะรู้สึกมั่นใจทุกครั้งที่ Deploy Code ขึ้น Production


Back to Blog | iCafe Forex | SiamLanCard | Siam2R