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

Testing Concurrent Code

ทดสอบ cancellation, ordering และ time-based behavior แบบ deterministic ด้วย synctest และ race detector

test concurrency ที่ใช้ time.Sleep(100 * time.Millisecond) มักมี 2 โหมด: ช้ากว่าที่จำเป็นเมื่อเครื่องว่าง และ flaky เมื่อ CI ช้า การเพิ่ม sleep เป็น 1 วินาทีเพียงเลื่อนปัญหา เราต้อง synchronize กับ event ที่ สนใจและควบคุมเวลา แทนการเดาว่า goroutine “น่าจะทำเสร็จแล้ว”

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

  • ทดสอบ ordering, cancellation, backpressure และ shutdown โดยไม่พึ่ง wall-clock delay
  • ใช้ testing/synctest, race detector และ goleak คนละหน้าที่
  • ออกแบบ seam ที่ทำให้ concurrency observable โดยไม่ expose implementation เกินไป

Test Protocol ไม่ใช่ Scheduler

สิ่งที่ test ควร assert คือ protocol เช่น worker ไม่เริ่มเกิน limit, cancellation ทำให้ Wait คืน, channel ปิดหลังผลลัพธ์สุดท้าย หรือ blocked producer ถูกปล่อยเมื่อ consumer รับงาน อย่า assert ว่า goroutine A จะรันก่อน B ถ้า contract ไม่ได้กำหนด

ใช้ channel ใน fake dependency เป็น rendezvous point:

started := make(chan struct{})
release := make(chan struct{})

process := func(ctx context.Context, job Job) error {
    close(started)
    select {
    case <-release:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

worker.Start(t.Context(), process)
<-started
close(release)

test รู้ว่า job เริ่มเพราะรับ signal ไม่ใช่เพราะผ่านไป 20 ms Channel นี้เป็น test control point ที่สะท้อน protocol จริง แต่ไม่ควรเพิ่ม hook เข้า production API ถ้า fake dependency ให้ observation ได้แล้ว

testing/synctest ควบคุมเวลาและ Blocking

Go 1.25 ทำ testing/synctest เป็น package ปกติ synctest.Test สร้าง bubble ที่เวลาจำลองเดินหน้า เมื่อ goroutines ใน bubble durable-blocked เหมาะกับ timer, context deadline และ retry backoff:

func TestRetryStopsAtDeadline(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
        defer cancel()

        done := make(chan error, 1)
        go func() {
            done <- retry(ctx, failingOperation)
        }()

        time.Sleep(5 * time.Second)
        synctest.Wait()

        if err := <-done; !errors.Is(err, context.DeadlineExceeded) {
            t.Fatalf("retry() error = %v, want deadline exceeded", err)
        }
    })
}

เวลา 5 วินาทีผ่านแบบรวดเร็วและ deterministic synctest.Wait รอให้ goroutines ถึงจุด blocked/stable ก่อน assert แต่ package นี้ไม่ใช่คำตอบสำหรับ external network/process หรือทุก custom synchronization primitive อ่าน package contract และแยก integration test เมื่อ dependency อยู่นอก bubble

ถ้า module ยังต่ำกว่า Go 1.25 ใช้ fake clock ที่ inject ผ่าน interface หรือ clock library แทนการนำ experimental API เก่ามาใช้ สำหรับคอร์สนี้ baseline 1.25 จึงใช้ public synctest.Test

เครื่องมือ 3 ตัวตอบคนละคำถาม

race detector ต้อง execute path จึงจะเห็น race; เพิ่ม stress/repeat บน package สำคัญได้ แต่ไม่ใช้ ความถี่แทน synchronization go.uber.org/goleak เหมาะกับ package ที่สร้าง long-lived goroutines และควร allowlist เฉพาะ goroutine ที่รู้จักจริง ไม่ ignore กว้างจนซ่อน leak ใหม่

go test -race ./...
go test -race -count=50 ./internal/worker

test timeout ระดับ go test -timeout เป็น safety net ให้ stack dump เมื่อ deadlock ไม่ใช่ synchronization mechanism ของ assertion

Scenario ที่ Worker ต้องผ่าน

สำหรับ bounded worker จากบทก่อน ให้มี tests อย่างน้อย:

  1. ทำครบทุก job และคืน error ที่ contract กำหนด
  2. active jobs ไม่เกิน concurrency limit โดยวัด max in-flight แบบ synchronized
  3. queue เต็มแล้ว block/reject/drop ตาม policy
  4. cancel ก่อนเริ่ม, ระหว่าง I/O และระหว่างส่งผลลัพธ์แล้วหยุดได้
  5. error แรก cancel sibling ถ้าใช้ errgroup
  6. close/shutdown เรียกซ้ำตาม contract และไม่มี send-on-closed-channel
  7. ไม่มี goroutine leak เมื่อ dependency fail หรือ caller เลิกรับผลลัพธ์

Production Toolbox

Default: channel rendezvous สำหรับ event, testing/synctest สำหรับเวลา/context, -race สำหรับ memory access และ go.uber.org/goleak สำหรับ lifetime ใช้ fake clock เมื่อ code ต้องรองรับ Go เก่า หรือ dependency อยู่นอก synctest model ไม่ใช้ sleep เป็น assertion

Checklist ของ Concurrent Test

  • test synchronize กับ event ที่ต้องการ ไม่เดาเวลา
  • ordering ถูก assert เฉพาะเมื่อเป็น public contract
  • success/error/cancel/full/shutdown paths ถูกเรียกจริง
  • race detector และ leak detector รันแยกจาก behavior assertion
  • fake/control channel มี buffer/owner ที่ไม่ทำให้ test เป็นฝ่าย deadlock
  • test timeout ให้ diagnostic แต่ไม่ได้ทำให้ behavior ผ่าน
  • flake ถูกหาต้นเหตุ ไม่แก้ด้วย retry test หรือเพิ่ม sleep

อ่านเพิ่ม: testing/synctest, Data Race Detector, Testing Time และ go.uber.org/goleak