Skip to content

Napat/go-testtime-fastforward

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

เขียน Golang ให้ข้ามเวลาได้!! เทคนิค Test โค้ดที่ติด time.Sleep (อัปเดต Go 1.25)

ปัญหาชวนปวดหัวอย่างหนึ่งเวลาเขียน Unit Test ใน Golang คือโค้ดที่มีการใช้ time.Sleep หรือไปผูกติดกับ time.Now() เพราะมันทำให้การรันเทสช้าลงอย่างเห็นได้ชัด ลองนึกภาพว่าเรามีเทสเคสสัก 100 ตัวที่ต้องรอตัวละ 3 วินาที CI/CD Pipeline ของเราคงใช้เวลาเป็นชาติกว่าจะรันผ่าน

Golang Time Travel Testing

บทความนี้เราจะมาดูเทคนิคการทำ Time-Travel (ย้อน/ข้ามเวลา) เพื่อแก้ปัญหานี้กัน โดยแบ่งออกเป็น 2 ท่าหลักๆ คือ ท่ามาตรฐานระดับสถาปัตยกรรม (IoC & DI) และท่าใหม่ล่าสุดที่เป็น General Feature แล้วใน Go 1.25 อย่าง testing/synctest


Basic Concept: ทำความเข้าใจปัญหาและแนวทางแก้ไข

🛠️ 1. เตรียมโปรเจกต์ (Project Setup) มาเริ่มสร้างโปรเจกต์ใหม่กันก่อน เปิด Terminal ขึ้นมาแล้วรันคำสั่งพวกนี้ตามลำดับได้เลย

# สร้างโฟลเดอร์และเข้าไปที่โปรเจกต์
mkdir go-testtime-fastforward
cd go-testtime-fastforward

# Init Go Module
go mod init github.com/Napat/go-testtime-fastforward

เพื่อความสะดวกในการรันคำสั่งต่างๆ เราจะสร้างไฟล์ Makefile ไว้ที่ Root ของโปรเจกต์ เอาไว้รันคำสั่งยาวๆ ได้ด้วยการพิมพ์สั้นๆ

ไฟล์: Makefile

.PHONY: generate test test-bad test-di test-sync test-sync-error test-godmode run run-godmode tidy

# ดึง dependencies อัตโนมัติ (รันหลังจากเขียนโค้ดเสร็จ)
tidy:
  go mod tidy

# สร้างไฟล์ Mock ทั้งโปรเจกต์
generate:
  go generate ./...

# รันโค้ด Basic Production
run:
  go run cmd/basic/main.go

# รันโค้ด GodMode
run-godmode:
  go run cmd/godmode/main.go

# รันเทสตัวที่มีปัญหา (จะเห็นว่าช้า)
test-bad:
  go test -v ./internal/service -run TestBadTask

# รันเทสตัวที่แก้ด้วย DI (เร็วปรู๊ด)
test-di:
  go test -v ./internal/service -run TestDITask

# ❌ รันเทส synctest แบบผิดวิธี (รันแล้วจะพัง เพื่อให้เห็นปัญหา)
test-sync-error:
  go test -v ./internal/service -run TestSyncTest_WrongBackgroundWorker

# ✅ รันเทส synctest ตัวที่รันผ่านและใช้งานถูกวิธี
test-sync:
  go test -v ./internal/service -run "TestSyncTest_Process|TestSyncTest_BackgroundWorker"

# ✅ รันเทส GodMode fast-forward
test-godmode:
  go test -v ./cmd/godmode -run TestRetryTimeout_GodMode

# รันเทสที่ถูกต้องทุกอย่างรวดเดียว
test: test-bad test-di test-sync test-godmode

🛑 2. ปัญหาที่แท้จริง: โค้ดที่ผูกติดกับเวลา (Tightly Coupled Code) ลองดูโค้ดตัวอย่างนี้ สมมติว่าเรามี Service ที่ต้องจำลองการรอเวลาประมวลผล 3 วินาที

ไฟล์: internal/service/bad_task.go

package service

import "time"

type BadTaskService struct{}

func NewBadTaskService() *BadTaskService {
  return &BadTaskService{}
}

func (s *BadTaskService) Process() (string, error) {
  // ปัญหา: โค้ดนี้ผูกมัดกับ package time โดยตรง (Tightly Coupled)
  time.Sleep(3 * time.Second)
  return "done", nil
}

ถ้าเราเขียนเทสให้ฟังก์ชันนี้ แล้วจับเวลาดู จะพบว่าระบบต้องค้างไป 3 วินาทีเต็มๆ กว่าเทสจะรันจบ นี่แหละคือสิ่งที่เรายอมให้เกิดขึ้นในโปรเจกต์ระดับโปรดักชันไม่ได้ cicd pipeline ของเราจะช้ามาก


🏛️ 3. ทางแก้ที่ 1: วิถี Architect ด้วย IoC และ DI (The Classic Way) การจะแก้ปัญหานี้แบบยั่งยืน เราต้องเข้าใจแนวคิดพื้นฐาน 2 อย่างนี้ก่อน

IoC (Inversion of Control): คือหลักการออกแบบที่บอกว่า "โครงสร้างข้อมูล (Struct) ของเรา ไม่ควรไปสร้างหรือจัดการของระดับล่างๆ ด้วยตัวเอง (เช่น การจัดการเวลา หรือการต่อ Database) แต่ควรให้ระบบภายนอกเป็นคนจัดการ แล้วโยนเข้ามาให้เราใช้แทน" พูดง่ายๆ คือการกลับทิศทาง(Inversion)จากการควบคุมภายในในแบบดั้งเดิมเป็นให้ภายนอกเป็นคนทำนั่นเอง

DI (Dependency Injection): เป็น "เทคนิค" ในการทำ IoC ให้เกิดขึ้นจริง โดยเราจะ "ฉีด" สิ่งที่คลาสต้องการ (ในที่นี้คือตัวจัดการเวลา) เข้าไปผ่านทาง Constructor (ฟังก์ชัน New...)

แทนที่จะเรียกใช้ time ตรงๆ เราจะสร้าง Interface มาคั่นไว้

ไฟล์: pkg/clock/clock.go

package clock

import "time"

//go:generate mockgen -source=./clock.go -destination=./mock_clock/clock_mock.go -package=mock_clock

// IClock คือ Interface สำหรับจัดการเวลา เอาไว้ครอบ time ปกติ
type IClock interface {
  Now() time.Time
  Sleep(d time.Duration)
}

// RealClock คือตัวจัดการเวลาที่จะใช้รันจริงบน Production
type RealClock struct{}

func NewRealClock() *RealClock             { return &RealClock{} }
func (c *RealClock) Now() time.Time        { return time.Now() }
func (c *RealClock) Sleep(d time.Duration) { time.Sleep(d) }

จากนั้นปรับ Service ให้รับ IClock เข้าไปแทนการเรียก time เปล่าๆ

ไฟล์: internal/service/di_task.go

package service

import (
  "time"

  "github.com/Napat/go-testtime-fastforward/pkg/clock"
)

type DITaskService struct {
  clock clock.IClock // เก็บ Dependency ไว้ใน Struct
}

// Inject Dependency เข้ามาตอนสร้าง Struct
func NewDITaskService(clock clock.IClock) *DITaskService {
  return &DITaskService{clock: clock}
}

func (s *DITaskService) Process() (string, error) {
  // เรียกใช้ Sleep ผ่าน Interface แทน time.Sleep() ตรงๆ
  s.clock.Sleep(3 * time.Second)
  return "done", nil
}

คราวนี้มาถึงหัวใจสำคัญของการเขียนเทสสำหรับท่านี้

ไฟล์: internal/service/di_task_test.go

package service_test

import (
  "testing"
  "time"

  "github.com/stretchr/testify/assert"
  "go.uber.org/mock/gomock"

  "github.com/Napat/go-testtime-fastforward/internal/service"
  "github.com/Napat/go-testtime-fastforward/pkg/clock/mock_clock"
)

func TestDITask_Process(t *testing.T) {
  ctrl := gomock.NewController(t)
  defer ctrl.Finish()

  mockClock := mock_clock.NewMockIClock(ctrl)

  // ตั้งค่าการทำงานของ Mock ให้คาดหวังว่าจะถูกเรียกด้วย Sleep(3 * time.Second) และจะถูกเรียกแค่ 1 ครั้ง
  mockClock.EXPECT().Sleep(3 * time.Second).Times(1)

  // Inject Mock เข้าไปใน Service
  svc := service.NewDITaskService(mockClock)

  start := time.Now()
  res, err := svc.Process()
  duration := time.Since(start)

  assert.NoError(t, err)
  assert.Equal(t, "done", res)

  // log ให้เห็นว่ามันเทสผ่านไจริงๆ และใช้เวลาไปแค่ไหน
  assert.True(t, duration < 100*time.Millisecond)
  t.Logf("DI task ใช้เวลาไปแค่: %v (นี่แหละพลังของ Fast-Forward!)", duration)
}

💡 แวะอธิบายความหมายของ mockClock.EXPECT().Sleep(3 * time.Second).Times(1) เวลาเพิ่งเริ่มเขียน Mock หลายคนมักจะงงกับบรรทัดนี้ มันไม่ได้แปลแค่ว่า "ถ้าเรียก Sleep ให้คืนค่ากลับนะ" แต่มันคือการ Assert (ตรวจสอบพฤติกรรม) อย่างเข้มงวด

.EXPECT(): คือการบอกว่า "จงคาดหวังว่าพฤติกรรมนี้จะต้องเกิดขึ้น"

.Sleep(3 * time.Second): ระบุเลยว่าฟังก์ชันไหนต้องโดนเรียก และ พารามิเตอร์ที่ส่งเข้ามาต้องเป็น 3 * time.Second เป๊ะๆ ด้วย! ถ้าโค้ดจริงเผลอส่งไป 2 วินาที เทสตัวนี้จะพังทันที

.Times(1): เลข 1 ตัวนี้มีความหมายมาก! มันคือการบังคับว่า "ฟังก์ชันนี้ต้องถูกเรียกใช้งาน 1 ครั้งถ้วน" ถ้าเรียก 0 ครั้ง หรือเรียกซ้ำ 2 ครั้ง เทสจะพังทันที การใส่ .Times(1) จึงเป็นการการันตีว่า โลจิกของเราทำงานได้ถูกต้องเป๊ะๆ ไม่ขาดไม่เกินนั่นเอง


🪄 4. ทางแก้ที่ 2: เวทมนตร์จาก Go 1.25 ด้วย testing/synctest ใน Go 1.25 มีฟีเจอร์อย่าง testing/synctest เข้ามา มันจะสร้าง "Bubble" (ฟองสบู่แห่งเวลา) ขึ้นมาครอบเทส ภายในนี้แพ็กเกจ time ทั้งหมดจะเปลี่ยนไปใช้นาฬิกาจำลอง และจะ Fast-forward ข้ามเวลาทันทีที่ระบบมองว่า Goroutine โดน Blocked

มาดูโค้ดไฟล์เทสตัวสุดท้าย ที่ผมใส่มาให้ครบทั้งเคสพื้นฐาน, เคสที่เขียนผิดจนพัง (Gotcha!), และเคสที่แก้ไขแล้ว

ไฟล์: internal/service/synctest_task_test.go

package service_test

import (
  "testing"
  "testing/synctest"
  "time"

  "github.com/Napat/go-testtime-fastforward/internal/service"
  "github.com/stretchr/testify/assert"
)

// ---------------------------------------------------
// 1. เคสการใช้งานจริง
// ---------------------------------------------------
func TestSyncTest_Process(t *testing.T) {
  synctest.Test(t, func(t *testing.T) {
    svc := service.NewBadTaskService()
    start := time.Now()

    // 3 วินาทีที่รออยู่ใน Process() จะถูกสคิปข้ามไปทันที!
    res, err := svc.Process()
    duration := time.Since(start)

    assert.NoError(t, err)
    assert.Equal(t, "done", res)
    assert.Equal(t, 3*time.Second, duration)
    t.Logf("Synctest ข้ามเวลาไป 3 วินาทีแบบไม่ต้องรอ")
  })
}

// ---------------------------------------------------
// ❌ 2. หลุมพราง: ใช้ synctest.Wait() ผิดวิธี
// ---------------------------------------------------
func TestSyncTest_WrongBackgroundWorker(t *testing.T) {
  synctest.Test(t, func(t *testing.T) {
    done := false

    go func() {
      time.Sleep(5 * time.Second)
      done = true
    }()

    // Wait() ไม่ได้รอให้ Goroutine ทำงานจนเสร็จ
    // มันแค่รอให้ Goroutine "เข้าสู่สถานะ Blocked"
    // ซึ่งพอ Sleep ปุ๊บ มันก็ถือว่า Blocked แล้ว และจะทำให้ไม่ได้การันตีว่า done จะเป็น true หรือไม่
    synctest.Wait()

    // จุดนี้ done ยังเป็น false อยู่ เทสจะพัง
    assert.True(t, done)
  })
}

// ---------------------------------------------------
// ✅ 3. วิธีแก้: ใช้ Channel รอให้งานเสร็จอย่างถูกต้อง
// ---------------------------------------------------
func TestSyncTest_ExampleWithBackgroundWorker(t *testing.T) {
  synctest.Test(t, func(t *testing.T) {
    done := false

    // 1. สร้าง Channel เพื่อใช้ส่งสัญญาณบอกตัวหลักว่างานเสร็จแล้ว
    ch := make(chan struct{})

    go func() {
      time.Sleep(5 * time.Second)
      done = true
      close(ch) // ปิด Channel เพื่อส่งสัญญาณว่างานเสร็จแล้ว
    }()

    // 3. สั่งให้ตัวหลัก "รอ" จนกว่า Channel จะถูกปิด
    // จังหวะนี้แหละที่ตัวย่อยก็รอ (Sleep) ตัวหลักก็รอ (Channel)
    // synctest จะเห็นว่า Blocked ทั้งคู่ มันเลยเร่งเวลาข้าม 5 วินาทีให้ทันที!
    <-ch

    assert.True(t, done)
    t.Logf("Goroutine ทำงานเสร็จสมบูรณ์ ไม่มีอาการค้างใน Bubble แล้ว")
  })
}

⚠️ มาลองรันตัวที่พังกันดู ลองรันคำสั่ง make test-sync-error จะพบกับ Error หน้าตาแบบนี้

=== RUN   TestSyncTest_WrongBackgroundWorker
    synctest_task_test.go:49: 
                Error Trace:    /go-testtime-fastforward/internal/service/synctest_task_test.go:49
                Error:          Should be true
                Test:           TestSyncTest_WrongBackgroundWorker
--- FAIL: TestSyncTest_WrongBackgroundWorker (0.00s)
panic: deadlock: main bubble goroutine has exited but blocked goroutines remain [recovered, repanicked]
...

เกิดอะไรขึ้น?

พอ Goroutine ย่อยสั่ง time.Sleep มันเลยเข้าสู่โหมด Blocked

ตัวหลักที่เรียก synctest.Wait() เห็นว่าตัวย่อย Blocked แล้ว มันเลยเลิกรอ และรันบรรทัดต่อไปทันที ทำให้ assert.True เฟล

ปรากฏว่าเทสหลักจบแล้ว แต่ Goroutine ย่อยยังหลับค้างอยู่ใน Bubble ระบบก็เลยส่ง panic: deadlock ออกมาเพื่อป้องกัน Goroutine Leak

✅ รันตัวที่ถูกต้อง ทีนี้ลองรันคำสั่ง make test-sync (ซึ่งจะรันเฉพาะฟังก์ชันที่ถูกต้อง) จะพบว่ามันรันผ่านฉลุยในเวลา 0.00s ทะลวงกำแพง 5 วินาทีไปอย่างสวยงาม นี่คือความทรงพลังของการใช้ Channel ควบคู่กับ synctest


🚀 5. รันจริงบน Production เราเขียนเทสกันมาเยอะแล้ว ทีนี้มาดูตอนเอาโค้ด DI ไปใช้งานจริงกันบ้าง

ไฟล์: cmd/basic/main.go

package main

import (
  "fmt"
  "log"

  "github.com/Napat/go-testtime-fastforward/internal/service"
  "github.com/Napat/go-testtime-fastforward/pkg/clock"
)

func main() {
  // 1. สร้างของจริง (Real Implementation) ที่เรียกใช้เวลาของระบบปฏิบัติการจริงๆ
  realClock := clock.NewRealClock()

  // 2. ฉีดของจริงเข้าไปใน Service
  svc := service.NewDITaskService(realClock)

  fmt.Println("🚀 Starting process... (It will take 3 seconds in real life)")

  // 3. เรียกใช้งาน
  res, err := svc.Process()
  if err != nil {
    log.Fatalf("Process failed: %v", err)
  }

  fmt.Printf("✅ Process result: %s\n", res)
}

⚙️ ขั้นตอนการรัน step ทั้งหมดดู

# 1. ให้ Go จัดการดาวน์โหลดแพ็กเกจทุกอย่างที่เราเรียกใช้ในโค้ดให้แบบอัตโนมัติ
make tidy 

# 2. ให้ Go สร้างไฟล์ Mock เตรียมไว้สำหรับการเทส
make generate 

# 3. ลองรันเทสตัวที่มีปัญหา (ดูสิว่ามันค้างไป 3 วิ)
make test-bad

# 4. ลองรันเทสด้วยท่า DI (เร็วปรู๊ด)
make test-di 

# 5. ลองรันท่า synctest แบบผิดๆ (ดูหน้าตาตอนมัน Panic)
make test-sync-error

# 6. ลองรันท่า synctest แบบถูกวิธี (เร็วปรู๊ดแถมโค้ดคลีน)
make test-sync 

# 7. ลองรันของจริง (จะเห็นว่าบนโปรดักชันมันก็ยังหน่วง 3 วินาทีจริงๆ ตามสเปก)
make run

จบ part ของ basic concept เท่านี้ก็สามารถลองเอาเทคนิคนี้ไปใช้ในโปรเจกต์ของเราดูเวลาที่ใช้รัน CI/CD จะได้ไม่ต้องกรีดร้องจากการต้องรอ test ที่ติด time.Sleep ได้เรียบร้อยแล้วครับ!


GodMode: ตัวอย่างการใช้ synctest กับโค้ดที่มี Retry Logic

ทีนี้ลองมาดูตัวอย่างการนำเทคนิคนี้ไปใช้กับโค้ดที่มีการทำ Retry Logic ร่วมกับ context timeout กันบ้าง

ลองนึกภาพว่าเรากำลังทำระบบ Payment Gateway ที่ต้องเชื่อมต่อกับธนาคาร กฎทางธุรกิจ (Business Logic) ระบุไว้ว่า: "หากธนาคารไม่ตอบกลับภายใน 5 วินาที ให้รอ 1 วินาทีแล้วลองใหม่ ทำซ้ำสูงสุด 3 ครั้ง"

ในมุมของ User ประสบการณ์นี้อาจกินเวลาเกือบ 20 วินาที ซึ่งพอรับได้ในกรณีฉุกเฉิน แต่ในมุมของ ทีมพัฒนา (Developer) และระบบ CI/CD นี่คือฝันร้ายครับ

God Mode Fast Forwarding

💸 Business Pain Points: เมื่อการรอ ทำลายความเร็วของทีม

ถ้าเราต้องเขียน Automated Test เพื่อทดสอบเคส "ธนาคารล่ม" ด้วยเวลาจริง (Real-time) แปลว่าการรันเทสต์แค่ 1 เคส ต้องใช้เวลา 18-20 วินาที

ถ้าโปรเจกต์มีระบบที่ต้องทำ Timeout & Retry แบบนี้สัก 100 จุด?

การนำโค้ดขึ้นระบบ (Deploy) แต่ละครั้ง ทีมอาจต้องนั่งรอ CI/CD รันเทสต์นานเป็นชั่วโมง!

ผลกระทบ: ค่าใช้จ่ายเซิร์ฟเวอร์พุ่งสูงขึ้น (CI Minutes), นักพัฒนาเสียโฟกัส (Context Switching), และที่แย่ที่สุดคือเกิดอาการ "เทสต์หลอน (Flaky Tests)" ที่บางครั้งรันผ่าน บางครั้งรันไม่ผ่านเพราะเซิร์ฟเวอร์ CI กระตุก ทำให้สูญเสียความเชื่อมั่นในระบบเทสต์

ไฟล์: cmd/godmode/main.go

package main

import (
  "context"
  "errors"
  "fmt"
  "log"
  "time"
)

// 1. กำหนด Interface สำหรับ HTTP Client
type HTTPClient interface {
  Get(ctx context.Context, url string) error
}

// 2. สร้างตัวประมวลผล (Processor)
type RssProcessor struct {
  client HTTPClient
}

func NewRssProcessor(client HTTPClient) *RssProcessor {
  return &RssProcessor{client: client}
}

// 3. ลอจิกหลัก: โหลดข้อมูล ถ้าเกิน 5 วิให้ตัดทิ้ง และรอ 1 วิเพื่อลองใหม่ (ทำสูงสุด 3 ครั้ง)
func (p *RssProcessor) Process(url, outFile string) error {
  const maxRetries = 3

  for attempt := 1; attempt <= maxRetries; attempt++ {
    log.Printf("Attempt %d: เริ่มดึงข้อมูลจาก %s", attempt, url)

    // ตั้งเวลา Timeout ที่ 5 วินาที
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    err := p.client.Get(ctx, url)
    cancel()

    if err == nil {
      log.Printf("Attempt %d: สำเร็จ!", attempt)
      return nil // สำเร็จ!
    }

    if errors.Is(err, context.DeadlineExceeded) {
      log.Printf("Attempt %d: หมดเวลา (Timeout)! รอ 1 วินาทีก่อนลองใหม่...", attempt)
      if attempt < maxRetries {
        time.Sleep(1 * time.Second) // รอ 1 วิ ก่อนลองใหม่
      }
      continue
    }

    log.Printf("Attempt %d: พังด้วยสาเหตุอื่น: %v", attempt, err)
    return err
  }
  return fmt.Errorf("ล้มเหลวหลังจากการพยายาม %d ครั้ง", maxRetries)
}

// =====================================================================

// 4. สร้าง Client จำลองสำหรับใช้งานจริง (สมมติว่าเน็ตช้ามาก ใช้เวลาโหลด 10 วิ)
type RealSlowClient struct{}

func (c *RealSlowClient) Get(ctx context.Context, url string) error {
  log.Printf("  [Client] กำลังพยายามเชื่อมต่อ... (สมมติว่าปกติต้องใช้เวลา 10 วินาทีถึงจะเสร็จ)")

  select {
  case <-time.After(10 * time.Second):
    log.Printf("  [Client] โหลดเสร็จแล้ว!")
    return nil
  case <-ctx.Done():
    log.Printf("  [Client] ถูกยกเลิกกลางคัน เพราะ: %v", ctx.Err())
    return ctx.Err()
  }
  }

  // 5. ฟังก์ชัน Main สำหรับรันทดสอบบนโลกจริง
  func main() {
  log.Println("=== เริ่มการทำงานแบบเวลาจริง (Real Time) ===")
  startTime := time.Now()

  // สร้าง Client และ Processor
  client := &RealSlowClient{}
  processor := NewRssProcessor(client)

  // สั่งรันโปรแกรม
  err := processor.Process("http://slow.com", "out.txt")

  // สรุปผลการทำงาน
  duration := time.Since(startTime)
  fmt.Println("--------------------------------------------------")
  if err != nil {
    log.Printf("จบการทำงานแบบมี Error: %v", err)
  } else {
    log.Println("จบการทำงานสำเร็จ!")
  }
  log.Printf("=== ใช้เวลาไปทั้งหมด: %v ===", duration)
}

ทดสอบการทำงานแบบเวลาจริง (Real Time) จะเห็นว่ามันต้องรอเกือบ 20 วินาทีจริงๆ เพราะมันต้องรอให้ Client จำลองโหลดเสร็จ (10 วิ) แล้วโดน Timeout (5 วิ) แล้วรอ Retry (1 วิ) แล้วโดน Timeout อีก (5 วิ) แล้วรอ Retry อีก (1 วิ) แล้วโดน Timeout อีก (5 วิ) รวมเป็น 22 วินาที

make run-godmode
go run cmd/godmode/main.go
2026/02/23 00:22:01 === เริ่มการทำงานแบบเวลาจริง (Real Time) ===
2026/02/23 00:22:01 Attempt 1: เริ่มดึงข้อมูลจาก http://slow.com
2026/02/23 00:22:01   [Client] กำลังพยายามเชื่อมต่อ... (สมมติว่าปกติต้องใช้เวลา 10 วินาทีถึงจะเสร็จ)
2026/02/23 00:22:06   [Client] ถูกยกเลิกกลางคัน เพราะ: context deadline exceeded
2026/02/23 00:22:06 Attempt 1: หมดเวลา (Timeout)! รอ 1 วินาทีก่อนลองใหม่...
2026/02/23 00:22:07 Attempt 2: เริ่มดึงข้อมูลจาก http://slow.com
2026/02/23 00:22:07   [Client] กำลังพยายามเชื่อมต่อ... (สมมติว่าปกติต้องใช้เวลา 10 วินาทีถึงจะเสร็จ)
2026/02/23 00:22:12   [Client] ถูกยกเลิกกลางคัน เพราะ: context deadline exceeded
2026/02/23 00:22:12 Attempt 2: หมดเวลา (Timeout)! รอ 1 วินาทีก่อนลองใหม่...
2026/02/23 00:22:13 Attempt 3: เริ่มดึงข้อมูลจาก http://slow.com
2026/02/23 00:22:13   [Client] กำลังพยายามเชื่อมต่อ... (สมมติว่าปกติต้องใช้เวลา 10 วินาทีถึงจะเสร็จ)
2026/02/23 00:22:18   [Client] ถูกยกเลิกกลางคัน เพราะ: context deadline exceeded
2026/02/23 00:22:18 Attempt 3: หมดเวลา (Timeout)! รอ 1 วินาทีก่อนลองใหม่...
--------------------------------------------------
2026/02/23 00:22:18 จบการทำงานแบบมี Error: ล้มเหลวหลังจากการพยายาม 3 ครั้ง
2026/02/23 00:22:18 === ใช้เวลาไปทั้งหมด: 17.006133917s ===

⚡ Technical Solution: ควบคุมเวลาโดยฟองสบู่แห่งพระเจ้าด้วย testing/synctest

เราจะใช้สิ่งที่เรียกว่า "Time Bubble (ฟองสบู่แห่งเวลา)" ตามที่เล่าไปใน part ของ basic concept มาทำ God mode "เร่งเวลา" ไปข้างหน้า (Fast-forward) เมื่อระบบกำลังหยุดรอ (time.Sleep หรือติด Timeout)

สิ่งที่จะได้คือ เทสต์ที่เคยต้องรอ 20 วินาที จะรันเสร็จในเวลา 0.00 วินาที โดยที่โค้ด Production ของเรายัง "สะอาดเอี่ยม" ไม่ต้องมี Mock Interface อีกต่อไป!

ไฟล์: cmd/godmode/main_test.go

package main_test

import (
  "context"
  "testing"
  "testing/synctest"
  "time"

  main "github.com/Napat/go-testtime-fastforward/cmd/godmode"

  "github.com/stretchr/testify/assert"
)

// ตัวจำลองเน็ตช้า (โหลด 10 วินาที)
type SlowClient struct {
  callCount int
}

func (c *SlowClient) Get(ctx context.Context, url string) error {
  c.callCount++
  // รอ 10 วิ (จำลองเน็ตช้า) หรือรอจนกว่า Context จะถูกยกเลิก (Timeout)
  select {
  case <-time.After(10 * time.Second):
    return nil
  case <-ctx.Done():
    return ctx.Err()
  }
}

func TestRetryTimeout_GodMode(t *testing.T) {
  // 1. สร้าง "ไทม์แมชชีน" (Time Bubble) ด้วย synctest
  synctest.Test(t, func(t *testing.T) {
    client := &SlowClient{}
    processor := main.NewRssProcessor(client)

    done := make(chan struct{})

    // 2. รันโปรแกรมใน Goroutine
    go func() {
      _ = processor.Process("http://slow.com", "out.txt")
      close(done)
    }()

    // 3. รอให้ Goroutine เข้าสู่สถานะ "รอ/Sleep"
    synctest.Wait()
    assert.Equal(t, 1, client.callCount) // เพิ่งเริ่มทำครั้งแรก

    // ⏩ 4. Fast-forward 6 วินาที!
    // สิ่งที่เกิดขึ้นในช่วง 6 วิ
    // - วิที่ 5: Context Timeout -> ยกเลิกการโหลดครั้งที่ 1
    // - วิที่ 5-6: โปรแกรมหลัก time.Sleep(1s) เพื่อรอ Retry
    // - วิที่ 6: เริ่มการโหลดครั้งที่ 2 (callCount จะขยับเป็น 2)
    time.Sleep(6 * time.Second)
    synctest.Wait()
    assert.Equal(t, 2, client.callCount)

    // ⏩ Fast-forward 6 วินาที (รวมเป็น 12s)
    time.Sleep(6 * time.Second)
    synctest.Wait()
    assert.Equal(t, 3, client.callCount)

    // ⏩ Fast-forward 6 วินาที (รวมเป็น 18s) เพื่อให้จบโควต้า 3 ครั้ง
    time.Sleep(6 * time.Second)
    synctest.Wait()

    // 5. ตรวจสอบว่าโปรแกรมจบการทำงานจริงๆ ไม่ค้าง (No Deadlock)
    select {
    case <-done:
      t.Log("✅ โปรแกรมทำงานเสร็จสิ้นตามที่คาดหวัง")
    default:
      t.Errorf("Program should have finished by now")
    }
  })
}

ทดสอบเทส God Mode Fast Forwarding ด้วยคำสั่ง make test-godmode ดังนี้

make test-godmode 
go test -v ./cmd/godmode -run TestRetryTimeout_GodMode
=== RUN   TestRetryTimeout_GodMode
2000/01/01 07:00:00 Attempt 1: เริ่มดึงข้อมูลจาก http://slow.com
2000/01/01 07:00:05 Attempt 1: หมดเวลา (Timeout)! รอ 1 วินาทีก่อนลองใหม่...
2000/01/01 07:00:06 Attempt 2: เริ่มดึงข้อมูลจาก http://slow.com
2000/01/01 07:00:11 Attempt 2: หมดเวลา (Timeout)! รอ 1 วินาทีก่อนลองใหม่...
2000/01/01 07:00:12 Attempt 3: เริ่มดึงข้อมูลจาก http://slow.com
2000/01/01 07:00:17 Attempt 3: หมดเวลา (Timeout)! รอ 1 วินาทีก่อนลองใหม่...
    main_test.go:69: ✅ โปรแกรมทำงานเสร็จสิ้นตามที่คาดหวัง
--- PASS: TestRetryTimeout_GodMode (0.00s)
PASS
ok      github.com/Napat/go-testtime-fastforward/cmd/godmode    0.721s

จะเห็นได้ว่าเทสนี้รันผ่านในเวลาประมาณ 0.0017s ทั้งที่ในโลกจริงมันต้องใช้เวลาเกือบ 20 วินาที! นี่คือพลังของการใช้ synctest ในการควบคุมเวลาและเร่งเวลาข้ามไปข้างหน้าได้อย่างอัศจรรย์

แต่เอ๊ะ เห็นเวลานั่นมั้ย ทำไมถึงเป็นวันที่ 2000/01/01 07:00:00 ล่ะ? เหตุผลที่เวลาใน Log โผล่มาเป็นปี 2000 มีที่มาแบบนี้ครับ

⏱️ เป้าหมายของการทำ Time Bubble คือ "เวลาต้องควบคุมได้ 100%" (Determinism)

เป้าหมายหลักของการทำ Time Bubble (ฟองสบู่แห่งเวลา) คือการทำให้ "ผลลัพธ์การรันเทสต์คงที่เสมอ" ไม่ว่าเราจะรันเทสต์นี้วันนี้ พรุ่งนี้ หรืออีก 10 ปีข้างหน้า ผลลัพธ์ต้องออกมาเหมือนเดิมเป๊ะๆ เพื่อป้องกันอาการ "เทสต์หลอน" (Flaky Tests)

ด้วยเหตุนี้ ทันทีที่เราเรียกใช้ synctest.Run() ตัว Runtime ของ Go จะทำการ "แช่แข็งนาฬิกาโลกจริง" และเริ่มนับนาฬิกาจำลองใหม่ทั้งหมด โดยกำหนดจุดเริ่มต้น (Epoch) ไว้ที่: 👉 วันที่ 1 มกราคม ปี 2000 เวลา 00:00:00 UTC เสมอ

🌏 ทำไมถึงเป็น 07:00:00 ? เนื่องจากคอมพิวเตอร์ของผมตั้งค่า Timezone ไว้ที่เวลาประเทศไทย (UTC+7) พอบวกเวลาเข้าไป 7 ชั่วโมงจาก 00:00:00 UTC เวลาใน Log จึงเริ่มต้นสตาร์ทที่ 2000/01/01 07:00:00 พอดีเป๊ะครับ!

สร้าง CI/CD Pipeline ด้วย Github Actions เพื่อความสมบูรณ์

ไฟล์: .github/workflows/ci-runtest.yml

name: CI run tests
run-name: ${{ github.actor }} is testing out GitHub Actions 🚀

on:
  push:
    branches: ["main", "master"]
  pull_request:
    branches: ["main", "master"]

jobs:
  test:
    name: Run Tests
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: "1.26"
          check-latest: true

      - name: Run Tests
        run: make test

Result จากการรัน CI/CD Pipeline บน Github Actions จะเห็นว่าเทสต์ทั้งหมดรันผ่านในเวลาไม่กี่วินาที (3 วินาทีสำหรับ test ที่รันช้า + ไม่ถึงเสี้ยววินาทีสำหรับเทสอื่นๆที่เหลือ เมื่อปัดขึ้นเลยได้ตัวเลข 4 วินาที)

Github Actions Result

🎯 สรุป

ด้วย testing/synctest จะช่วยให้ชาว Golang สามารถเขียนเทสต์ที่ทำงานกับเวลาได้อย่างแม่นยำ (Deterministic) และรวดเร็วปานสายฟ้าแลบ โค้ด Production สะอาดขึ้นไม่ต้องมี Mock Interface สำหรับ DI อีกต่อไป ลดระยะเวลาของ CI/CD และลดความปวดหัวจาก Flaky Tests ลงไปได้อย่างหมดจด

ใครที่เคยฝัง Mock Clock ไว้ในโปรเจกต์... ถึงเวลาทยอยลบทิ้งและสนุกกับ God Mode ได้เลยครับ!

About

เขียน Golang ให้ข้ามเวลาได้!! เทคนิค Test โค้ดที่ติด time.Sleep (อัปเดต Go 1.25)

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors