บทที่ 19 · Part 6 — Evidence-Driven Quality

Unit Testing in Go

เขียน table tests, fixtures, builders, harnesses, assertions และ mocks ที่ไม่ผูกกับ implementation

test ที่มี setup 80 บรรทัดก่อน assertion แรกไม่ได้ช่วยให้มั่นใจ แม้มันครอบคลุม code มาก เพราะคนอ่าน ไม่รู้ว่า field ใดสำคัญต่อ scenario และการเปลี่ยน schema เล็กน้อยทำให้ test พังทั้งชุด Unit test ที่ดี เป็น executable contract: จัดฉากเท่าที่จำเป็น เรียก behavior หนึ่ง และอธิบายผลที่สังเกตได้

จบบทนี้คุณจะ

  • เขียน table-driven tests และเลือก assertion tool ตามชนิดข้อมูล
  • แยก fixture, setup helper, test-data builder และ test harness ได้
  • ใช้ stub, fake หรือ mock โดยไม่ผูก test กับ implementation detail

Test Behavior ไม่ใช่เส้นทางภายใน

เริ่มจาก public behavior และกรณีตัดสินใจ: success, invalid input, dependency failure, not found, conflict และ boundary values ไม่ต้อง assert ว่า private helper ถูกเรียกหรือ loop ทำกี่รอบ หาก refactor โดยไม่เปลี่ยน contract แล้ว test พัง แสดงว่า test อาจผูก implementation มากเกินไป

func TestService_Submit(t *testing.T) {
    tests := []struct {
        name    string
        request SubmitRequest
        wantErr error
    }{
        {
            name:    "rejects empty batch id",
            request: SubmitRequest{},
            wantErr: ErrInvalidBatch,
        },
        {
            name:    "accepts a new batch",
            request: SubmitRequest{BatchID: "batch-001"},
        },
    }

    for _, test := range tests {
        t.Run(test.name, func(t *testing.T) {
            service := newTestService(t)
            _, err := service.Submit(t.Context(), test.request)
            if !errors.Is(err, test.wantErr) {
                t.Fatalf("Submit() error = %v, want %v", err, test.wantErr)
            }
        })
    }
}

table-driven test เหมาะเมื่อ cases ใช้ arrange/act/assert shape เดียวกัน ถ้าแต่ละ case มี setup และ assertion คนละโลก การแยกเป็น test functions อาจอ่านง่ายกว่า อย่าสร้าง table เพียงเพราะเป็น Go idiom

Fixture, Setup, Builder และ Harness ต่างกันอย่างไร

คำว่า fixture ใช้ได้ครับ แต่เป็นคำกว้าง หมายถึง state/data/dependency ที่ test ต้องมีก่อนรัน เพื่อให้ทีมสื่อสารแม่นขึ้น คอร์สนี้แบ่งดังนี้:

คำหน้าที่ตัวอย่าง
Fixtureข้อมูลตั้งต้นที่ deterministicBatch สถานะ Pending เวลา fixed
Setup helperทำขั้นตอนซ้ำและคืน resourceเปิด temp directory, seed store
Test-data builderสร้าง valid default แล้ว overrideเฉพาะสิ่งที่ case สนใจaBatch().WithStatus(...)
Test harnessรวม dependency และ control point ของ componentservice + fake store + fake clock
TestMainsetup ระดับ package/process ที่จำเป็นจริงleak checker; ไม่ใช้เพื่อ DB ต่อ test

หลีกเลี่ยง global pointer fixture เพราะ test หนึ่ง mutate แล้วอีก test รับ state ต่อ ให้ factory คืนค่าใหม่ ทุกครั้งและใช้เวลาคงที่:

func aBatch(options ...func(*Batch)) Batch {
    batch := Batch{
        ID:        "batch-001",
        Status:    StatusPending,
        CreatedAt: time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC),
    }
    for _, option := range options {
        option(&batch)
    }
    return batch
}

func withStatus(status Status) func(*Batch) {
    return func(batch *Batch) { batch.Status = status }
}

builder ที่ดีทำให้ valid case สั้นแต่ไม่ซ่อน field ที่ scenario สนใจ ถ้า builder มี business logic ซ้ำ production code test อาจผิดพร้อม implementation ได้ ให้เก็บมันเป็น data construction เท่านั้น

setup helper รับ *testing.T, เรียก t.Helper() และลง cleanup ด้วย t.Cleanup() ณ จุดที่สร้าง resource เพื่อให้ failure ชี้บรรทัด caller และ cleanup ทำงานแม้ Fatal:

func newTestService(t *testing.T) *Service {
    t.Helper()

    store := newMemoryStore()
    t.Cleanup(store.Close)
    return NewService(store, fixedClock)
}

Assertion และ Comparison Toolbox

stdlib if got != want เหมาะกับ scalar และทำให้ failure message ตรง สำหรับ struct/slice/map ที่ซับซ้อน คอร์สแนะนำ google/go-cmp เพราะ diff อ่านง่ายและกำหนด comparer, transformer หรือ ignore field อย่าง explicit ได้

stretchr/testify เหมาะเมื่อทีมชอบ require/assert vocabulary และต้องการลด boilerplate ใช้ require เมื่อขั้นต่อไปทำไม่ได้ถ้า assertion fail และ assert เมื่ออยากเห็น หลาย failure ในครั้งเดียว หากสร้าง assertion object ภายใน subtest ต้องผูกกับ t ของ subtest ไม่ใช่ parent

ใช้ httptest สำหรับ handler และ fake/stub แบบเขียนมือสำหรับ interface เล็ก ถ้า interaction ซับซ้อนหรือ มี interface มาก คอร์สแนะนำ go.uber.org/mock เพื่อ generate mocks แต่อย่า assert ทุก call โดยไม่เกี่ยวกับ behavior ลำดับ call ที่ไม่ใช่ contractทำให้ refactor พังโดยไม่เพิ่มความมั่นใจ

Parallel Test ต้อง Isolate จริง

ใช้ t.Parallel() เมื่อ tests independent และ resource รองรับ อย่าเปิด parallel เพียงเพื่อให้ suite เร็ว ถ้ายังแชร์ env var, port, database row หรือ global singleton ใช้ t.Setenv/t.TempDir และ unique data ตามขอบเขต; บาง helper ห้ามใช้ร่วมกับ parallel ancestor ตาม contract ของ testing package

Production Toolbox

Default: testing + table/subtests + go-cmp สำหรับ rich values ใช้ testify เป็น helper vocabulary, go.uber.org/mock เมื่อ generated interaction mock คุ้ม และ httptest สำหรับ HTTP เริ่ม fixture ด้วย factory/builder ที่คืนค่าใหม่; ยกระดับเป็น harness เมื่อ dependency/control point ซ้ำหลาย test

Checklist ของ Unit Test

  • ชื่อ test/subtest อธิบาย behavior และ failure condition
  • arrange แสดงเฉพาะข้อมูลที่ scenario สนใจ
  • fixture deterministic, fresh และไม่แชร์ mutable state
  • helper เรียก t.Helper() และ resource ใช้ t.Cleanup()
  • compare error ด้วย errors.Is/As และ complex value ด้วย diff ที่อ่านได้
  • mock เฉพาะ consumer interface และไม่ assert implementation detail
  • parallel test มี isolation จริง
  • coverage ใช้หา path ที่ยังไม่คิด ไม่ใช่ quota แทนคุณภาพ assertion

อ่านเพิ่ม: testing package, Testable Examples in Go, httptest และ Go Wiki: TableDrivenTests