ปัญหาชวนปวดหัวอย่างหนึ่งเวลาเขียน Unit Test ใน Golang คือโค้ดที่มีการใช้ time.Sleep หรือไปผูกติดกับ time.Now() เพราะมันทำให้การรันเทสช้าลงอย่างเห็นได้ชัด ลองนึกภาพว่าเรามีเทสเคสสัก 100 ตัวที่ต้องรอตัวละ 3 วินาที CI/CD Pipeline ของเราคงใช้เวลาเป็นชาติกว่าจะรันผ่าน
บทความนี้เราจะมาดูเทคนิคการทำ Time-Travel (ย้อน/ข้ามเวลา) เพื่อแก้ปัญหานี้กัน โดยแบ่งออกเป็น 2 ท่าหลักๆ คือ ท่ามาตรฐานระดับสถาปัตยกรรม (IoC & DI) และท่าใหม่ล่าสุดที่เป็น General Feature แล้วใน Go 1.25 อย่าง testing/synctest
🛠️ 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 แล้ว")
})
}=== 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 ได้เรียบร้อยแล้วครับ!
ทีนี้ลองมาดูตัวอย่างการนำเทคนิคนี้ไปใช้กับโค้ดที่มีการทำ Retry Logic ร่วมกับ context timeout กันบ้าง
ลองนึกภาพว่าเรากำลังทำระบบ Payment Gateway ที่ต้องเชื่อมต่อกับธนาคาร กฎทางธุรกิจ (Business Logic) ระบุไว้ว่า: "หากธนาคารไม่ตอบกลับภายใน 5 วินาที ให้รอ 1 วินาทีแล้วลองใหม่ ทำซ้ำสูงสุด 3 ครั้ง"
ในมุมของ User ประสบการณ์นี้อาจกินเวลาเกือบ 20 วินาที ซึ่งพอรับได้ในกรณีฉุกเฉิน แต่ในมุมของ ทีมพัฒนา (Developer) และระบบ CI/CD นี่คือฝันร้ายครับ
ถ้าเราต้องเขียน 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 ===เราจะใช้สิ่งที่เรียกว่า "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 (ฟองสบู่แห่งเวลา) คือการทำให้ "ผลลัพธ์การรันเทสต์คงที่เสมอ" ไม่ว่าเราจะรันเทสต์นี้วันนี้ พรุ่งนี้ หรืออีก 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 พอดีเป๊ะครับ!
ไฟล์: .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 testResult จากการรัน CI/CD Pipeline บน Github Actions จะเห็นว่าเทสต์ทั้งหมดรันผ่านในเวลาไม่กี่วินาที (3 วินาทีสำหรับ test ที่รันช้า + ไม่ถึงเสี้ยววินาทีสำหรับเทสอื่นๆที่เหลือ เมื่อปัดขึ้นเลยได้ตัวเลข 4 วินาที)
ด้วย testing/synctest จะช่วยให้ชาว Golang สามารถเขียนเทสต์ที่ทำงานกับเวลาได้อย่างแม่นยำ (Deterministic) และรวดเร็วปานสายฟ้าแลบ โค้ด Production สะอาดขึ้นไม่ต้องมี Mock Interface สำหรับ DI อีกต่อไป ลดระยะเวลาของ CI/CD และลดความปวดหัวจาก Flaky Tests ลงไปได้อย่างหมดจด
ใครที่เคยฝัง Mock Clock ไว้ในโปรเจกต์... ถึงเวลาทยอยลบทิ้งและสนุกกับ God Mode ได้เลยครับ!


