Workshop Go 1.27: ทดสอบ HTTP ช้าในเวลาจำลอง ไม่ต้องรอจริง
test ของ HTTP client มักต้องเลือกระหว่างสองอย่างที่ไม่น่าพอใจครับ: เปิด server จริงบน port ซึ่งอาจชนกันใน CI หรือ mock transport จนพฤติกรรมไม่เหมือน network ส่วน production ยิ่งมี retry และ timeout test ก็ยิ่งช้า เพราะต้องรอเวลาจริงทีละวินาที
Go 1.27 ต่อชิ้นส่วนสองชิ้นเข้าด้วยกันได้พอดี httptest.NewTestServer สร้าง server บน network จำลองใน memory เมื่ออยู่ใน synctest และ synctest.Sleep เลื่อนนาฬิกาจำลองพร้อมรอให้ goroutine ที่พร้อมทำงานเดินต่อ เราจึงทดสอบเส้นทาง HTTP จริงโดยไม่ยึด port และไม่เสียเวลาเท่ากับ timeout จริง
WHY: Mock Function ไม่ได้ทดสอบขอบของ Protocol
สมมติ service ต้องตอบหลังทำงาน 10 วินาที ถ้าเราแทน HTTP client ด้วย function ที่คืน string ทันที test จะไม่แตะการสร้าง request, status code, body, concurrent handler หรือการปิด response เลย เหมือนซ้อมส่งพัสดุด้วยการยื่นของข้ามโต๊ะ จึงไม่รู้ว่าระบบคัดแยกจริงรับกล่องได้หรือไม่
อีกทางคือ httptest.NewServer แบบเดิม ซึ่งดีสำหรับ integration test แต่ใช้ socket ของระบบและเวลาจริง หาก test มีหลายกรณีที่รอ 10 วินาที suite จะช้าจนทีมเริ่มเลี่ยงการรัน นวัตกรรมใน Go 1.27 ไม่ได้เปลี่ยน API ฝั่ง caller มาก แต่เปลี่ยน “สนาม” ใต้เท้าให้ network และ clock อยู่ใน bubble ที่ test ควบคุมได้
HOW: สร้าง Server ช้า แล้วเลื่อนเวลา 10 วินาที
ตัวอย่างนี้ตั้ง handler ให้ส่งสัญญาณว่าเริ่มทำงานแล้ว จากนั้นหลับ 10 วินาทีในเวลาจำลอง Client ขอ URL คนละ host ได้ เพราะ in-memory server จะ route request มาที่ handler โดยไม่ออกไปหา DNS จริง
package delayedhttp
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"testing/synctest"
"time"
)
func TestDelayedHTTPWithoutWallClockWait(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
started := make(chan struct{})
server := httptest.NewTestServer(t, http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
close(started)
synctest.Sleep(10 * time.Second)
_, _ = io.WriteString(w, "ready")
},
))
done := make(chan string, 1)
go func() {
response, err := server.Client().Get(
"https://service.example/status",
)
if err != nil {
done <- "request error: " + err.Error()
return
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
done <- "read error: " + err.Error()
return
}
done <- string(body)
}()
<-started
if got := <-done; got != "ready" {
t.Fatalf("response = %q, want ready", got)
}
})
}
หลัง handler เริ่ม main goroutine จะรอผล เมื่อ goroutine ใน bubble ไม่มีงานอื่นที่ทำได้ นาฬิกาจำลองจึงเดินไปถึงเวลาที่ handler ตื่นเอง assertion สุดท้ายพิสูจน์ว่าทั้ง request, handler, virtual sleep และ response body เดินครบ ไม่ใช่เพียงตรวจว่า API compile ได้ บนเครื่องที่ใช้ตรวจบทความ test นี้ผ่านในประมาณศูนย์วินาที แม้ scenario ภายในกินเวลาจำลอง 10 วินาที
ขอบเขตของ Bubble
เวลาจำลองครอบคลุม goroutine ที่สร้างภายใน synctest.Test และ operation ที่ runtime รู้จัก อย่าเอา database ภายนอก, process ลูก หรือ network จริงมาปน แล้วคาดว่าทุกอย่างจะเร่งตามนาฬิกาเดียวกัน หาก dependency ต้องออกนอก process ควรแยกเป็น integration test อีกชั้น
NewTestServer รับ testing.TB จึงปิด server ให้อัตโนมัติเมื่อ test จบ แต่ client ที่ได้จาก server.Client() ถูกปรับให้คุยกับสนามจำลอง อย่าเผลอใช้ http.DefaultClient เพราะจะออกนอกเส้นทางที่ test เตรียมไว้
กรณี retry ให้ assert จำนวน attempt และจังหวะเวลาแยกกัน เช่น handler ตอบ error สองครั้งแล้วสำเร็จครั้งที่สาม จากนั้นเลื่อนเวลาเท่ากับ backoff ทีละช่วง วิธีนี้เห็น policy จริงโดยไม่ทำให้ suite ช้า
Recap
Go 1.27 ทำให้ HTTP test อยู่ใกล้ production กว่า mock function แต่เร็วและนิ่งกว่าเปิด port จริง: วาง server กับ client ใน synctest.Test, ใช้ server.Client() และเลื่อนเวลาเฉพาะจุดด้วย synctest.Sleep
พรุ่งนี้ลองเลือก test ที่มี time.Sleep ยาวที่สุดหนึ่งตัว ย้ายเข้า bubble และเพิ่ม assertion ที่ตรวจผลลัพธ์ปลายทาง หากเวลารันลดลงโดยความหมายของ test ยังครบ เราได้ทั้งความเร็วและความมั่นใจกลับมาครับ