📌 บทนี้ไม่มีในสไลด์ Day 8 — ผมเพิ่มเข้ามาเอง
มันเป็นการบ้านที่ค้างมาตั้งแต่ Day 5: วันนั้นเราเรียน
table-driven test กับ
testify แต่เทสได้แค่ฟังก์ชันคำนวณล้วน ๆ
เพราะยังไม่มีใครเขียน HTTP server เป็น
ตอนนี้มี handler แล้ว — หนี้ก้อนนั้นถึงเวลาจ่าย ถ้าข้ามบทนี้ไป
บท 41 (Swagger) กับ Day ถัด ๆ ไปจะกลายเป็น "เขียน API ที่ไม่มีใครตรวจ" ทันที
🧰 เตรียมโมดูลก่อนอ่าน — ทุก output ในบทนี้รันจริงบน
go version go1.26.5 darwin/arm64
github.com/gin-gonic/gin v1.12.0
github.com/stretchr/testify v1.12.1
net/http/httptest อยู่ใน standard library ของ Go ไม่ต้อง go get
· ที่ต้องโหลดคือ gin กับ testify ซึ่งลงไปแล้วตั้งแต่บท 38 และบท 17
1 · ปัญหาก่อน แล้วค่อยดูเครื่องมือ
ลองนึกว่าจะเทส GET /users/:id ด้วยความรู้ที่มีตอนนี้ ทางที่นึกออกคือ:
เปิดเซิร์ฟเวอร์ด้วย r.Run(":8080") แล้วยิง http.Get("http://localhost:8080/users/1")
มันทำงานได้จริง แต่ลองไล่ราคาที่ต้องจ่ายดู:
| สิ่งที่ต้องทำ | ปัญหาที่ตามมา |
|---|---|
| จอง port 8080 | เครื่อง CI (เครื่องกลางที่รันเทสให้อัตโนมัติทุกครั้งที่ push โค้ด) รันเทสหลายชุดพร้อมกัน → ชนกัน · เครื่องคุณมีอย่างอื่นใช้อยู่ → เทสพังทั้งที่โค้ดถูก |
| รอให้ server พร้อมก่อนยิง | ต้อง time.Sleep เดา ๆ ไว้ → เทสช้าลง และกลายเป็น flaky test (เทสที่บางรอบผ่านบางรอบพัง ทั้งที่โค้ดไม่ได้เปลี่ยน) |
| ปิด server ตอนจบ | ลืมปิด = เทสตัวถัดไปเจอ port ไม่ว่าง |
| ต่อ DB จริง | ต้องมี Docker + ข้อมูลตั้งต้น เพียงเพื่อจะเช็คว่า handler ตอบ 404 ถูกไหม |
ทั้งหมดนี้เป็นค่าใช้จ่ายของชั้น network ซึ่งเราไม่ได้อยากเทสมันเลย — เราอยากรู้แค่ว่า "ยิง path นี้เข้าไป แล้ว handler ตอบ status/body อะไร"
ทางออกซ่อนอยู่ใน interface ที่คุณเห็นมาตั้งแต่บท 37 แล้วแต่อาจยังไม่ทันสังเกต —
http.Handler มี method เดียวเท่านั้น:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
อ่านช้า ๆ อีกรอบ: มันคือฟังก์ชันธรรมดาที่รับสองพารามิเตอร์
ตัวหนึ่งคือ "คำถาม" (*http.Request) อีกตัวคือ "สมุดให้เขียนคำตอบ" (http.ResponseWriter)
ในโลกจริง Go เป็นคนสร้างสองตัวนี้จาก TCP socket แล้วเรียก ServeHTTP ให้
แต่ไม่มีกฎข้อไหนบอกว่ามันต้องมาจาก socket — ถ้าเราประกอบสองตัวนี้ขึ้นมาเองแล้วเรียกตรง ๆ
handler ก็ทำงานเหมือนเดิมทุกประการ โดยไม่มี network เข้ามาเกี่ยวเลย
ของจริง
browser ──TCP──▶ net/http ──สร้าง req + w ให้──▶ router.ServeHTTP(w, req) ──▶ handler
ในเทส (เราเป็นคนสร้าง req + w เอง แล้วเรียกบรรทัดเดียวกันนั้นตรง ๆ)
httptest.NewRequest(...) ─▶ req ─┐
├──▶ router.ServeHTTP(w, req) ──▶ handler
httptest.NewRecorder() ─▶ w ──┘
(ไม่มี browser · ไม่มี TCP · ไม่มี port — ทุกอย่างอยู่ใน RAM ล้วน ๆ)
net/http/httptest ให้สองอย่างพอดี:
NewRequest ปั้น *http.Request ปลอมขึ้นมาจาก method + path
และ NewRecorder ให้ ResponseWriter ที่ไม่ส่งอะไรออกไปไหน
แต่จดทุกอย่างที่ handler เขียนลงไป แล้วให้เราอ่านย้อนหลังได้
เวลาสั่งอาหารในร้าน มีคนกลางเยอะมาก — เดินเข้าร้าน หาโต๊ะ เรียกพนักงาน
พนักงานจดใส่กระดาษ เดินไปยื่นให้ครัว แล้วเดินเอาจานกลับมาเสิร์ฟ
แต่ถ้าวันหนึ่งคุณอยากเช็คแค่ว่าครัวทำเมนูนี้ถูกไหม
คุณไม่ต้องเปิดร้านรับลูกค้าจริง — เขียนใบสั่งเองหนึ่งใบ วางถาดเปล่าไว้หนึ่งใบ
ยื่นให้หัวหน้าครัว แล้วดูว่ามีอะไรมาอยู่บนถาด
httptest ทำแบบเดียวกันเป๊ะ ๆ:
· httptest.NewRequest = ใบสั่งที่เราเขียนเอง — จะสั่งเมนูไหน (path ไหน) แบบไหน (method อะไร) ก็เขียนลงไป
· httptest.NewRecorder = ถาดเปล่าที่วางรอ — ไม่ได้เสิร์ฟไปไหน แต่ทุกอย่างที่เชฟวางลงมายังอยู่ให้เราดู
· router.ServeHTTP(w, req) = ยื่นใบสั่งให้หัวหน้าครัวตรง ๆ ข้ามพนักงานเสิร์ฟไปเลย
— แล้วหัวหน้าครัวเป็นคนอ่านใบสั่งว่าเมนูนี้ต้องส่งต่อให้เชฟคนไหนทำ
ที่ไม่เหมือนกัน: เราตัดแค่คนเดินอาหารออก ไม่ได้ตัดครัวออก — ทั้งครัวยังทำงานจริงทุกขั้นตอน (หัวหน้าครัวเลือก route · middleware ทำงาน · แปลง body · แล้วเชฟค่อยลงมือ) ดังนั้นถ้า handler ไปเรียกฐานข้อมูลจริง ตอนเทสมันก็จะเรียกจริง การตัดของฝั่งครัวออกเป็นอีกเรื่อง ซึ่งอยู่ในหัวข้อ 8
คำพวกนี้คือคำที่วงการใช้เรียกสิ่งที่คุณเพิ่งเข้าใจไป — ใช้ให้ตรงคำ แล้วจะได้คำตอบที่ตรงจาก senior หรือจาก AI
- handler
- ฟังก์ชันที่รับ request หนึ่งใบแล้วเขียนคำตอบกลับ = เชฟที่ลงมือทำเมนูนั้นจริง ๆ ส่วนคนที่อ่านใบสั่งแล้วชี้ว่าเมนูนี้เชฟคนไหนทำ คือ router — คนละตัวกัน · ถามว่า “handler ตัวนี้ตอบ 404 ตอนไหน” จะตรงกว่าถามว่า “ฟังก์ชันนี้ทำอะไร”
- ServeHTTP
- ประตูที่ request จากข้างนอกต้องผ่าน ทั้งของจริงและในเทส ·
ตัวที่เราเรียกคือ
ServeHTTPของ router (*gin.Engine) ไม่ใช่ของ handler แต่ละตัว — gin handler เป็นแค่func(*gin.Context)ไม่มีServeHTTPเป็นของตัวเอง
ในเทสจะเรียก handler ตรง ๆ ข้ามประตูนี้ก็ได้ แต่จะไม่มีใครแกะ:idให้ (ดูหัวข้อ 10) · ประโยคที่ใช้ได้เลย: “เทสผมยิงเข้าrouter.ServeHTTPตรง ๆ ไม่ได้เปิดพอร์ต” - ResponseWriter / ResponseRecorder
ResponseWriterคือ "ถาด" ที่ handler เขียนคำตอบลงไป ·ResponseRecorderคือถาดปลอมของhttptestที่จดไว้ให้เราอ่านย้อนหลัง แทนที่จะส่งออกไปหาใคร- in-memory
- "อยู่ใน RAM ล้วน ๆ" ไม่แตะ network ไม่แตะดิสก์ · บอกว่า “เทสชุดนี้เป็น in-memory ทั้งหมด” คนฟังจะรู้ทันทีว่าเทสเร็วและไม่ไปแย่งพอร์ตกับใคร
2 · โค้ดที่เราจะเทสกันทั้งบท
นี่คือของที่ต่อยอดตรงจาก บทเรียน 39 —
มี c.Param, มี ShouldBindJSON + binding tag ครบ
เซฟเป็น app.go ในโฟลเดอร์เปล่า ๆ ที่ go mod init check ไว้แล้ว
(ทุกไฟล์ในบทนี้อยู่โฟลเดอร์เดียวกันหมด · ชื่อ package ต้องตรงกันทุกไฟล์):
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}
type CreateUserReq struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
}
var db = map[string]User{
"1": {ID: "1", Name: "Somchai"},
}
func GetUser(c *gin.Context) {
id := c.Param("id")
u, ok := db[id]
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.Header("X-Course", "go-day8")
c.JSON(http.StatusOK, u)
}
func CreateUser(c *gin.Context) {
var req CreateUserReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"name": req.Name, "email": req.Email})
}
// จุดที่สำคัญที่สุดของไฟล์นี้: แยก "สร้าง router" ออกจาก "รัน server"
func NewRouter() *gin.Engine {
r := gin.New()
r.GET("/users/:id", GetUser)
r.POST("/users", CreateUser)
return r
}
func main() {
NewRouter().Run(":8080") // เทสไม่แตะบรรทัดนี้เลย — ดูกล่องข้างล่าง
}
NewRouter()
ถ้าคุณเขียน r := gin.New() กับ r.Run(":8080") ปนกันอยู่ใน
main() — เทสจะเอื้อมไปหยิบ router ไม่ได้เลย
เพราะการเรียก main() จากเทสแปลว่าเซิร์ฟเวอร์จะบล็อกค้างอยู่ตรงนั้นตลอดไป
กฎที่ติดตัวไปได้ทุกภาษา: แยก "ประกอบของ" ออกจาก "สั่งให้ทำงาน" เสมอ
— main() เหลือแค่บรรทัดเดียวคือ NewRouter().Run(":8080")
ส่วนเทสเรียก NewRouter() เฉย ๆ ได้ router มาถือไว้โดยไม่มีอะไรบล็อก
นี่คือหลักเดียวกับ constructor injection
(ส่งของที่ต้องใช้เข้าไปตอน "สร้าง" แทนที่จะปล่อยให้มันไปหยิบเอง) ที่ใช้ใน
บทเรียน 27
3 · สามบรรทัดที่ต้องจำ
เซฟเป็น basic_test.go ข้าง ๆ app.go — package เดียวกัน จึงเรียก NewRouter() ได้เลยโดยไม่ต้อง import อะไรเพิ่ม:
package main
import (
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetUser_Basic(t *testing.T) {
router := NewRouter()
w := httptest.NewRecorder() // 1) กล่องรับคำตอบ
req := httptest.NewRequest("GET", "/users/1", nil) // 2) คำถามปลอม
router.ServeHTTP(w, req) // 3) ยิงเข้า router ตรง ๆ
assert.Equal(t, 200, w.Code)
assert.JSONEq(t, `{"id":"1","name":"Somchai"}`, w.Body.String())
assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type"))
}
รันจริงแล้วได้แบบนี้ (ผมใส่ t.Logf เพิ่มเพื่อพิมพ์ค่าดิบออกมาให้ดูด้วย):
$ go test -v -run TestGetUser_Basic .
=== RUN TestGetUser_Basic
[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
- using env: export GIN_MODE=release
- using code: gin.SetMode(gin.ReleaseMode)
[GIN-debug] GET /users/:id --> check.GetUser (1 handlers)
[GIN-debug] POST /users --> check.CreateUser (1 handlers)
basic_test.go:17: w.Code = 200
basic_test.go:18: w.Body.String() = "{\"id\":\"1\",\"name\":\"Somchai\"}"
basic_test.go:19: w.Header() = map[Content-Type:[application/json; charset=utf-8] X-Course:[go-day8]]
basic_test.go:20: Content-Type = "application/json; charset=utf-8"
basic_test.go:21: X-Course = "go-day8"
basic_test.go:22: w.Result().Status = "200 OK"
--- PASS: TestGetUser_Basic (0.00s)
PASS
ok check 0.322s
0.322 วินาที รวมเวลาคอมไพล์ — และไม่มี port ไหนถูกจองเลยแม้แต่พอร์ตเดียว นี่คือทั้งหมดของบทนี้จริง ๆ ที่เหลือคือรายละเอียดว่าจะอ่านผลลัพธ์ให้ถูกได้ยังไง
| อ่านอะไร | ได้ชนิดอะไร | ค่าจริงที่รันได้ |
|---|---|---|
w.Code | int | 200 |
w.Body.String() | string | {"id":"1","name":"Somchai"} |
w.Header().Get("Content-Type") | string | application/json; charset=utf-8 |
w.Result().Status | string | 200 OK |
⚠️ Content-Type ของ gin มี ; charset=utf-8 ต่อท้ายเสมอ
c.JSON เป็นคนเติมให้เอง — ถ้าคุณเขียน
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
เทสจะพังทั้งที่โค้ดถูก
อีกจุด: w.Header() อ่านได้ตรง ๆ ไม่ต้องเรียก w.Result() ก่อน
(จาก output ข้างบนจะเห็นทั้ง Content-Type และ X-Course ที่ handler set ไว้ครบ)
4 · กับดักตัวแรก: recorder เป็นของใช้ครั้งเดียว
ทายก่อนรัน — โค้ดนี้ใช้ w ตัวเดิมยิงสองครั้ง
ครั้งแรก /users/1 (มีจริง) ครั้งที่สอง /users/999 (ไม่มี)
// func TestReuseRecorder(t *testing.T) { router := NewRouter()
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest("GET", "/users/1", nil))
t.Logf("ครั้งที่ 1: code=%d body=%s", w.Code, w.Body.String())
router.ServeHTTP(w, httptest.NewRequest("GET", "/users/999", nil))
t.Logf("ครั้งที่ 2 (recorder เดิม): code=%d body=%s", w.Code, w.Body.String())
// แล้วยิงเคสเดิมซ้ำอีกรอบ คราวนี้สร้าง recorder ใหม่ ไว้เทียบกัน
w2 := httptest.NewRecorder()
router.ServeHTTP(w2, httptest.NewRequest("GET", "/users/999", nil))
t.Logf("ครั้งที่ 2 (recorder ใหม่): code=%d body=%s", w2.Code, w2.Body.String())
และคำถามแถมมาจากเทสอีกตัวที่สร้าง recorder ขึ้นมาแล้วไม่ยิงอะไรเลย:
// func TestDefaultCode(t *testing.T) {
w := httptest.NewRecorder()
t.Logf("recorder ที่ยังไม่ถูกเขียน: w.Code = %d", w.Code)
บรรทัด "ครั้งที่ 2 (recorder เดิม)" จะพิมพ์ w.Code เป็นเท่าไหร่
และ w.Body หน้าตายังไง?
คำถามแถม: ถ้ายังไม่เคยยิงอะไรเลยสักครั้ง w.Code เท่ากับอะไร
=== RUN TestReuseRecorder
traps_test.go:14: ครั้งที่ 1: code=200 body={"id":"1","name":"Somchai"}
traps_test.go:18: ครั้งที่ 2 (recorder เดิม): code=200 body={"id":"1","name":"Somchai"}{"error":"user not found"}
traps_test.go:23: ครั้งที่ 2 (recorder ใหม่): code=404 body={"error":"user not found"}
--- PASS: TestReuseRecorder (0.00s)
=== RUN TestDefaultCode
traps_test.go:40: recorder ที่ยังไม่ถูกเขียน: w.Code = 200
--- PASS: TestDefaultCode (0.00s)
1. w.Code ค้างที่ 200 — เพราะ HTTP เขียน status ได้ครั้งเดียว
(บทเรียน 37 เรื่อง WriteHeader เขียนซ้ำไม่ได้)
ครั้งแรกชนะ ครั้งที่สองถูกเมิน
2. body ต่อกันเป็นก้อนเดียว —
{"id":"1",...}{"error":...} ซึ่งไม่ใช่ JSON ที่ถูกต้องแล้วด้วยซ้ำ
3. คำถามแถมคือข้อที่อันตรายที่สุด: w.Code เริ่มต้นที่ 200 ทันทีที่สร้าง
ทั้งที่ยังไม่มีใครเขียนอะไรลงไปเลย
แปลว่าเทสที่ assert แค่ assert.Equal(t, 200, w.Code) อย่างเดียว
พิสูจน์ไม่ได้ว่า handler ทำงาน — แม้ handler จะไม่ถูกเรียกเลยเทสก็ยังผ่าน
→ assert body ด้วยเสมอ และ สร้าง recorder ใหม่ทุกเคส
พนักงานจดออเดอร์โต๊ะ 1 ลงกระดาษหนึ่งแผ่น แล้วไม่ฉีกแผ่นใหม่
พอโต๊ะ 2 สั่ง ก็จดต่อท้ายลงแผ่นเดิม
ครัวเลยได้กระดาษแผ่นเดียวที่มีสองออเดอร์ปนกันจนอ่านไม่ออก
ส่วนช่อง "เลขโต๊ะ" ที่หัวกระดาษก็ยังเป็นของโต๊ะ 1 อยู่ เพราะเขียนไปแล้วเขียนทับไม่ได้
httptest.NewRecorder() คือกระดาษแผ่นนั้น —
body ต่อท้ายไปเรื่อย ๆ และ status เขียนได้ครั้งเดียว ครั้งแรกชนะ
ที่ร้ายกว่านั้น: กระดาษแผ่นนี้ถูกปั๊มเลข 200 มาให้ตั้งแต่ยังไม่มีใครเขียนอะไรเลย
เหมือนแบบฟอร์มที่โรงพิมพ์ติ๊กช่อง "เรียบร้อย" ไว้ให้ล่วงหน้า
ที่ไม่เหมือนกัน: กระดาษจริงยังขีดฆ่าแก้ได้ และคนอ่านเห็นเองว่ามันมั่ว
แต่ recorder ลบของเก่าไม่ได้ และไม่มีใครเตือนคุณ — เทสจะเขียวเงียบ ๆ
ทางที่ปลอดภัยจึงมีทางเดียว: ฉีกแผ่นใหม่ทุกเคส
คือสร้าง recorder ใหม่ข้างใน t.Run ทุกครั้ง
- buffer
- "ที่พักข้อมูลที่ต่อท้ายไปเรื่อย ๆ" ·
w.Bodyเป็น buffer ไม่ใช่ตัวแปรที่ถูกเขียนทับ · พูดว่า “body มันเป็น buffer ไง เลยต่อกันสองก้อน” อธิบายอาการนี้จบในประโยคเดียว - เทสเขียวหลอก (vacuous test)
- เทสที่ผ่าน ทั้งที่ของจริงไม่ได้ทำงาน — assert แค่
w.Codeที่เป็น200มาตั้งแต่แรก คือตัวอย่างชัด ๆ ของกรณีนี้
ระวังคำว่า false positive — คนใช้กันสองความหมายสวนทางกัน ความหมายที่พบบ่อยกว่าคือ "ฟ้องว่ามีปัญหาทั้งที่ไม่มี" (เทสแดงทั้งที่โค้ดถูก · scanner ฟ้องเกิน) ส่วนเคสเขียวหลอกนี้หลายคนเรียกว่า false negative
→ เวลาคุยกับ senior บอกอาการไปเลย: “เทสมันผ่านทั้งที่ handler ไม่ถูกเรียกเลย” จะไม่มีทางสื่อผิด - test isolation
- "แต่ละเคสต้องไม่แชร์ของกัน" · ของที่มีสถานะ (recorder) ต้องสร้างใหม่ทุกเคส ส่วนของที่ไม่มีสถานะ (router) ใช้ร่วมกันได้ · ถามได้ว่า “เคสนี้ isolate จากเคสก่อนหน้าจริงหรือเปล่า”
5 · assert.JSONEq ไม่ใช่ของหรู มันคือของจำเป็น
body ที่ handler ตอบมาเป็นสตริง แต่สิ่งที่เราสนใจจริง ๆ คือข้อมูล
ระหว่าง {"id":"1","name":"Somchai"} กับ { "name": "Somchai", "id": "1" }
— สำหรับ client สองอันนี้เหมือนกันทุกประการ แต่สำหรับ == มันคนละเรื่อง
❌ assert.Equal
got := w.Body.String() // = {"id":"1","name":"Somchai"}
expected := `{ "name": "Somchai", "id": "1" }`
assert.Equal(t, expected, got)
Error: Not equal:
expected: "{ \"name\": \"Somchai\", \"id\": \"1\" }"
actual : "{\"id\":\"1\",\"name\":\"Somchai\"}"
Diff:
--- Expected
+++ Actual
@@ -1 +1 @@
-{ "name": "Somchai", "id": "1" }
+{"id":"1","name":"Somchai"}
--- FAIL: TestCompareJSON (0.00s)
เทียบสตริงตรงตัว — สลับ key หรือเว้นวรรคก็พังแล้ว
✅ assert.JSONEq
got := w.Body.String() // ก้อนเดียวกันเป๊ะกับฝั่งซ้าย
expected := `{ "name": "Somchai", "id": "1" }`
assert.JSONEq(t, expected, got)
(ไม่มี error รายงานออกมาเลย = ผ่าน)
แกะทั้งสองฝั่งเป็นโครงสร้างข้อมูลก่อนแล้วค่อยเทียบ — ลำดับ key และช่องว่างไม่มีผล
ทั้งสองบล็อกข้างบนรันกับ body ก้อนเดียวกันคือ {"id":"1","name":"Somchai"}
ต่างกันแค่ฟังก์ชันที่ใช้เทียบเท่านั้น
ทำไมเรื่องนี้สำคัญกว่าที่คิด: ลำดับ key ใน JSON ไม่ใช่สิ่งที่คุณควบคุมได้
gin.H คือ map ซึ่ง Go ไม่รับประกันลำดับ
วันที่คุณเปลี่ยน handler จากตอบ struct มาเป็นตอบ gin.H (หรือกลับกัน)
ลำดับ key ก็เปลี่ยนตาม — เทสที่ใช้ Equal จะพังทั้งแผงทั้งที่ API ตอบข้อมูลเดิมเป๊ะ
สรุปสั้น ๆ: body ที่เป็น JSON ให้ใช้ JSONEq เสมอ ·
Equal เก็บไว้ใช้กับ w.Code และ header ซึ่งเป็นค่าเดี่ยว ๆ ไม่มีเรื่องลำดับ
6 · table-driven: 200 / 400 / 404 ในลูปเดียว
ตอนนี้ต่อจิ๊กซอว์ได้แล้ว — เอาโครง table-driven จาก บทเรียน 16 มาครอบ: แต่ละแถวคือ "หนึ่ง request หนึ่งคำตอบที่คาดไว้"
// ในไฟล์ table_test.go — package เดียวกับ app.go
// import เพิ่มจากหัวข้อ 3 อีกสองตัว: "net/http" และ "strings"
router := NewRouter() // สร้างครั้งเดียว ใช้ได้ทุกเคส (เหตุผลอยู่ในกล่องถัดไป)
tests := []struct {
name string
method string
path string
body string
wantCode int
wantJSON string
wantSubstr string
}{
{name: "200 เจอ user", method: http.MethodGet, path: "/users/1",
wantCode: http.StatusOK, wantJSON: `{"id":"1","name":"Somchai"}`},
{name: "404 ไม่เจอ user", method: http.MethodGet, path: "/users/999",
wantCode: http.StatusNotFound, wantJSON: `{"error":"user not found"}`},
{name: "400 body ไม่ผ่าน validation", method: http.MethodPost, path: "/users",
body: `{"name":"","email":"ไม่ใช่อีเมล"}`,
wantCode: http.StatusBadRequest, wantSubstr: "Field validation"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var req *http.Request
if tc.body == "" {
req = httptest.NewRequest(tc.method, tc.path, nil)
} else {
req = httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
req.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder() // recorder ใหม่ทุกเคส (หัวข้อ 4)
router.ServeHTTP(w, req)
require.Equal(t, tc.wantCode, w.Code, "body=%s", w.Body.String())
// แถวไหนกรอก wantJSON ไว้ก็เทียบทั้งก้อน แถวไหนกรอก wantSubstr ก็เทียบแค่ชิ้นส่วน
if tc.wantJSON != "" {
assert.JSONEq(t, tc.wantJSON, w.Body.String())
}
if tc.wantSubstr != "" {
assert.Contains(t, w.Body.String(), tc.wantSubstr)
}
t.Logf("code=%d body=%s", w.Code, w.Body.String())
})
}
=== RUN TestUserHandlers_Table
=== RUN TestUserHandlers_Table/200_เจอ_user
table_test.go:69: code=200 body={"id":"1","name":"Somchai"}
=== RUN TestUserHandlers_Table/404_ไม่เจอ_user
table_test.go:69: code=404 body={"error":"user not found"}
=== RUN TestUserHandlers_Table/400_body_ไม่ผ่าน_validation
table_test.go:69: code=400 body={"error":"Key: 'CreateUserReq.Name' Error:Field validation for 'Name' failed on the 'required' tag\nKey: 'CreateUserReq.Email' Error:Field validation for 'Email' failed on the 'email' tag"}
--- PASS: TestUserHandlers_Table (0.00s)
--- PASS: TestUserHandlers_Table/200_เจอ_user (0.00s)
--- PASS: TestUserHandlers_Table/404_ไม่เจอ_user (0.00s)
--- PASS: TestUserHandlers_Table/400_body_ไม่ผ่าน_validation (0.00s)
1. router สร้างครั้งเดียวนอกลูปได้ แต่ w ต้องอยู่ใน t.Run
— router ไม่มีสถานะต่อ request ส่วน recorder มี (หัวข้อ 4)
2. require ไม่ใช่ assert ตรง status —
ถ้า code ไม่ตรงแล้วยัง assert body ต่อ จะได้ error สองบรรทัดที่ทำให้สับสน
require หยุดเคสนั้นทันที (บทเรียน 17)
3. "body=%s" ต่อท้าย require.Equal —
เวลาเทสพัง สิ่งที่คุณอยากเห็นที่สุดคือเซิร์ฟเวอร์ตอบอะไรกลับมา ไม่ใช่แค่ "expected 200 got 400"
4. เคส 400 ใช้ wantSubstr ไม่ใช่ wantJSON —
เพราะ error ของ validator เป็นข้อความยาวที่อาจเปลี่ยนเมื่ออัปเวอร์ชัน
ผูกเทสไว้กับข้อความเป๊ะ ๆ = เทสที่พังเองโดยที่โค้ดไม่ผิด
7 · gin.SetMode(gin.TestMode) — ทำให้ output อ่านออก
สังเกต output ในหัวข้อ 3: มี [GIN-debug] โผล่มาสี่ห้าบรรทัดทุกครั้งที่สร้าง router
ถ้ามี 30 เทสก็คูณ 30 — หา --- FAIL ไม่เจอเลย
ทางแก้คือใส่ TestMain ไว้ไฟล์เดียวใน package (Go จะเรียกมันก่อนเทสทุกตัว):
func TestMain(m *testing.M) {
gin.SetMode(gin.TestMode)
os.Exit(m.Run())
}
ผมรันไฟล์เดียวกัน 3 โหมดเทียบกัน — ไฟล์นี้ใช้ router จิ๋ว ๆ อีกตัวที่ยิง /users/7
แล้วตอบ {"id":"7"} กลับมา (ไม่เกี่ยวกับ db ในหัวข้อ 2)
ที่ต้องดูคือคอลัมน์สุดท้ายสองคอลัมน์ ไม่ใช่ตัวเลข 7:
| ตั้งค่ายังไง | gin.Mode() | gin.IsDebugging() | ผลลัพธ์ของ request | [GIN-debug] |
|---|---|---|---|---|
| ไม่ตั้งอะไรเลย | debug | true | code=200 body={"id":"7"} | มี |
gin.SetMode(gin.TestMode) | test | false | code=200 body={"id":"7"} | ไม่มี |
GIN_MODE=release go test | release | false | code=200 body={"id":"7"} | ไม่มี |
อ่านตารางให้ถูก: คอลัมน์ "ผลลัพธ์ของ request" เหมือนกันทั้งสามแถว
TestMode ไม่ได้เปลี่ยนพฤติกรรมของ handler ไม่ได้ปิด validation
ไม่ได้ทำให้ error ต่างไป — มันแค่ทำให้ log เงียบ
เขียนไว้เพราะเทสที่อ่าน output ไม่ออกคือเทสที่คุณจะเลิกอ่าน เท่านั้นเอง
ไม่ใช่เพราะ "ไม่ใส่แล้วเทสไม่ถูกต้อง"
8 · ต่อยอดจาก mock — เทส handler ที่ต้องใช้ DB โดยไม่มี DB
ตัวอย่างที่ผ่านมาใช้ map เป็นฐานข้อมูล ซึ่งของจริงไม่เป็นแบบนั้น
แต่จำ บทเรียน 28 ได้ไหม —
ถ้า handler ไม่ได้เรียก DB ตรง ๆ แต่เรียกผ่าน interface
เราก็สลับตัวจริงเป็น mock ได้ทั้งดุ้น:
var ErrNotFound = errors.New("user not found") // sentinel error ที่ errors.Is เอาไว้เทียบ
type UserService interface {
GetUser(id string) (User, error)
}
type Handler struct{ svc UserService }
func (h *Handler) GetUser(c *gin.Context) {
u, err := h.svc.GetUser(c.Param("id"))
if errors.Is(err, ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
c.JSON(http.StatusOK, u)
}
สองอย่างที่เปลี่ยนไปจากหัวข้อ 2 — อ่านก่อนไม่งั้นโค้ดข้างล่างจะดูโผล่มาจากไหนไม่รู้
1. NewRouter ตอนนี้รับพารามิเตอร์แล้ว
เพราะ handler ไม่ได้เป็นฟังก์ชันลอย ๆ อีกต่อไป แต่เป็น method ของ Handler ที่ต้องรู้ว่าจะใช้ service ตัวไหน:
func NewRouter(h *Handler) *gin.Engine { r := gin.New(); r.GET("/users/:id", h.GetUser); return r }
2. MockUserService ไม่ใช่ของที่เราเขียนเองในบทนี้ —
มันคือไฟล์ที่ mockery generate ให้จาก interface UserService
(บทเรียน 28) · method .On() / .Return() /
.AssertExpectations() ทั้งหมดมาจาก testify/mock ที่ติดมากับมัน
ฝั่งเทสสั่ง mock ว่า "ถ้าถูกถามด้วย id นี้ ให้ตอบแบบนี้" แล้วยิงผ่าน recorder เหมือนเดิมทุกอย่าง:
svc := new(MockUserService)
svc.On("GetUser", "1").Return(User{ID: "1", Name: "Somchai"}, nil)
svc.On("GetUser", "999").Return(User{}, ErrNotFound)
router := NewRouter(&Handler{svc: svc})
t.Run("200", func(t *testing.T) {
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/users/1", nil))
require.Equal(t, 200, w.Code)
assert.JSONEq(t, `{"id":"1","name":"Somchai"}`, w.Body.String())
})
t.Run("404", func(t *testing.T) {
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/users/999", nil))
require.Equal(t, 404, w.Code)
assert.JSONEq(t, `{"error":"user not found"}`, w.Body.String())
})
svc.AssertExpectations(t)
⚠️ ต้องยิงให้ครบทุก .On() ที่สั่งไว้ —
ถ้าตัดเคส 404 ทิ้งแต่ยังทิ้ง svc.On("GetUser", "999") ไว้
แล้วปิดท้ายด้วย AssertExpectations เทสจะ FAIL ทันที
ทั้งที่ handler ไม่ได้ผิดอะไรเลย:
FAIL: 1 out of 2 expectation(s) were met.
The code you are testing needs to make 1 more call(s).
และของแถมที่ทำได้เฉพาะตอนใช้ mock — สั่งให้ "DB ล่ม" ได้ตามสั่ง ซึ่งกับ DB จริงคุณต้องไปถอดสาย:
svc.On("GetUser", "1").Return(User{},
errors.New("dial tcp 127.0.0.1:5432: connect: connection refused"))
// สร้าง router + recorder แล้วยิง GET /users/1 เหมือนเดิมเป๊ะ ไม่มีอะไรพิเศษ
require.Equal(t, 500, w.Code)
assert.JSONEq(t, `{"error":"internal error"}`, w.Body.String())
$ go test -v -count=1 .
=== RUN TestGetUser_WithMock
=== RUN TestGetUser_WithMock/200
=== RUN TestGetUser_WithMock/404
--- PASS: TestGetUser_WithMock (0.00s)
--- PASS: TestGetUser_WithMock/200 (0.00s)
--- PASS: TestGetUser_WithMock/404 (0.00s)
=== RUN TestGetUser_ServiceDown
--- PASS: TestGetUser_ServiceDown (0.00s)
PASS
ok w40 0.493s
mock ตัดตัวจริงออกจากด้านล่างของ handler · httptest ตัด network ออกจากด้านบน
เหลือแค่ handler ตัวเปล่า ๆ ที่คุณโยนอะไรเข้าไปก็ได้ในเวลา 0.493 วินาที
— รวมเคส "DB ล่ม" ที่ปกติแทบทดสอบไม่ได้เลย
สมมติอยากรู้ว่าร้านคุณรับมือไฟดับได้ไหม คุณไม่ได้นั่งรอให้ไฟดับจริง และไม่ต้องไปตัดสายไฟหน้าบ้าน — แค่สับเบรกเกอร์ลงเอง แล้วดูว่าเกิดอะไรขึ้นต่อ
mock คือเบรกเกอร์ตัวนั้น สั่งได้ว่ารอบนี้ให้ "DB" ตอบว่าเจอ user
รอบนี้ให้ตอบว่าไม่เจอ หรือรอบนี้ให้ล่มไปเลย
ส่วน httptest ตัดฝั่ง network ออกไปตั้งแต่ต้นบทแล้ว
เหลือ handler ตัวเปล่า ๆ ที่เราคุมได้ทั้งสองด้าน —
ด้านบนคุมว่าใครยิงอะไรเข้ามา ด้านล่างคุมว่าของข้างล่างจะตอบอะไรกลับไป
ที่ไม่เหมือนกัน: สับเบรกเกอร์ทำให้ไฟดับจริง
แต่ mock ไม่ได้ทำให้ DB จริงล่ม มันคือของปลอมที่ถูกสั่งให้ตอบว่าล่ม
เทสแบบนี้จึงพิสูจน์ได้แค่ว่า handler รับมือถูกเมื่อได้ error หน้าตาแบบนี้
ไม่ได้พิสูจน์ว่า DB จริงจะพังแบบนี้ หรือ query ที่เขียนไว้ถูกต้อง —
เรื่องนั้นต้องใช้เทสอีกชนิดที่ต่อของจริง
- test double / stub / mock
- test double คือคำร่มของ "ของแสดงแทน" ทุกชนิดที่สวมแทนของจริงตอนเทส
· ถ้าแค่ตอบตามบทเฉย ๆ ไม่ตรวจอะไรเลย คนเรียกว่า stub
· ถ้านอกจากตอบตามบทแล้ว ยังตรวจได้ด้วยว่าถูกเรียกครบตามที่คาดไหม คนเรียกว่า mock — นี่แหละคือที่มาของบรรทัดexpectation(s) were metข้างบน · พูดว่า “ผม mock ตัวUserServiceไว้ ให้มันคืนErrNotFound” จะสื่อกว่าพูดว่า “ผมทำของปลอมขึ้นมา” - dependency injection
- "ยัดของที่ต้องใช้เข้าไปจากข้างนอก" แทนที่จะให้โค้ดไปหยิบเอง ·
การส่ง
Handlerที่ถือ service เข้าไปให้NewRouterคือการ inject · ถามได้ว่า “handler ตัวนี้รับ service เข้ามาทาง constructor ไหม” — ถ้ารับเข้ามาและตัวที่รับเป็น interface แปลว่าสวม mock แทนได้ แต่ถ้ารับเป็นชนิดตายตัว (เช่น*gorm.DB) ก็ยังสวมแทนไม่ได้อยู่ดี — คอมไพเลอร์จะฟ้องตั้งแต่บรรทัดที่ส่งเข้าไป - sentinel error
- ค่า error สำเร็จรูปที่ประกาศไว้ตัวเดียว (
ErrNotFound) แล้วเทียบด้วยerrors.Isแทนการเทียบข้อความ · “ตัวนี้เป็น sentinel error หรือเปล่า” คือคำถามที่ทำให้รู้ทันทีว่าควรเทียบยังไง - unit test / integration test
- unit test = เทสของเราชิ้นเดียวโดยตัดของรอบข้างออก ·
integration test = เทสที่เอาหลายชิ้นมาต่อกันจริง
(ที่เจอบ่อยสุดคือต่อ DB จริงผ่าน Docker แบบ Day 7 — แต่ไม่จำเป็นต้องมี DB ถึงจะเรียกแบบนี้)
ส่วนบทนี้ยิงผ่านrouter.ServeHTTPซึ่งกินทั้ง routing + middleware + handler จึงอยู่กลาง ๆ ระหว่างสองอัน บางทีมเรียกว่า component test — แต่เพราะไม่แตะของนอกกระบวนการเลย ส่วนใหญ่ยังนับเป็น unit test · ประโยคที่ใช้ได้: “ตัวนี้ผม unit test ด้วย mock ไว้แล้ว ส่วน integration test ยังไม่มี”
ทายก่อนรัน — คราวนี้ผมลืมสั่ง svc.On(...)
(สร้าง mock เปล่า ๆ แล้วยิงเลย) และ router ตัวนี้มี gin.Recovery() ติดอยู่
แบบเดียวกับ gin.Default()
svc := new(MockUserService) // ไม่ได้ .On อะไรเลย
r := gin.New()
r.Use(gin.Recovery())
r.GET("/users/:id", (&Handler{svc: svc}).GetUser)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/users/1", nil))
t.Logf("code=%d body=%q", w.Code, w.Body.String())
เทสนี้ FAIL หรือ PASS? · w.Code เป็นเท่าไหร่ · w.Body มีอะไรอยู่?
=== RUN TestPanicUnderRecovery
2026/08/20 22:04:22 [Recovery] 2026/08/20 - 22:04:22 panic recovered:
assert: mock: I don't know what to return because the method call was unexpected.
Either do Mock.On("GetUser").Return(...) first, or remove the GetUser() call.
This method was unexpected:
GetUser(string)
0: "1"
...(stack trace อีกยาว)...
recover_test.go:20: code=500 body=""
--- PASS: TestPanicUnderRecovery (0.00s)
PASS
PASS — และนี่คือคำตอบที่น่ากลัวที่สุดในบทนี้
mock ที่ถูกเรียกด้วยอาร์กิวเมนต์ที่ไม่ได้สั่งไว้จะ panic
แต่ gin.Recovery() รับ panic นั้นไว้แล้วแปลงเป็น 500 body ว่าง
— เทสไม่ crash และถ้าเผอิญคุณเขียน assert.Equal(t, 500, w.Code) ไว้
เทสจะเขียวสวยงามทั้งที่ handler ไม่เคยทำงานสำเร็จเลยสักบรรทัด
สามอย่างที่ป้องกันได้:
1. assert body ด้วยเสมอ ไม่ใช่แค่ code — body ว่างจะโป๊ะทันที
2. ปิดท้ายด้วย svc.AssertExpectations(t)
เพื่อยืนยันว่า mock ถูกเรียกครบตามที่สั่งไว้จริง
3. ถ้า router ในเทสไม่มี Recovery() (คือ gin.New() เปล่า ๆ)
panic จะทะลุขึ้นมาถึง go test ตรง ๆ และเทส FAIL ทันที ซึ่งดีกว่า
— แต่ถ้าอยากเทสให้เหมือน production ที่ใช้ gin.Default() ก็ต้องรู้ว่าตาข่ายนี้มีอยู่
9 · แล้ว httptest.NewServer ล่ะ ใช้เมื่อไหร่
httptest ยังมีอีกตัวที่คนมักสับสน — NewServer ซึ่งเปิด port จริง:
// NewRouter() ตัวเดิมจากหัวข้อ 2 (แบบไม่รับพารามิเตอร์)
srv := httptest.NewServer(NewRouter())
defer srv.Close()
resp, err := http.Get(srv.URL + "/users/1")
require.NoError(t, err) // ตกบรรทัดนี้ = คอมไพล์ไม่ผ่าน "declared and not used: err"
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body) // ไม่มี w.Body.String() ให้ใช้แล้ว ต้องอ่านเอง
require.Equal(t, 200, resp.StatusCode)
assert.JSONEq(t, `{"id":"1","name":"Somchai"}`, string(body))
=== RUN TestNewServer
server_test.go:17: srv.URL = http://127.0.0.1:54791
server_test.go:18: srv.Listener.Addr().Network() = tcp
server_test.go:24: resp.Status="200 OK" body={"id":"1","name":"Somchai"}
server_test.go:25: resp.Proto="HTTP/1.1" Content-Type="application/json; charset=utf-8"
--- PASS: TestNewServer (0.00s)
NewRecorder | NewServer | |
|---|---|---|
| เปิดพอร์ตจริง | ไม่ | ใช่ (tcp · พอร์ตสุ่มทุกครั้ง) |
| เรียกยังไง | router.ServeHTTP(w, req) | http.Get(srv.URL + "/...") |
| ได้อะไรกลับ | *httptest.ResponseRecorder — อ่าน w.Code, w.Body ได้เลย | *http.Response — status อยู่ที่ resp.StatusCode ส่วน body ต้อง io.ReadAll(resp.Body) + defer resp.Body.Close() |
| ต้อง cleanup | ไม่ต้อง | ต้อง defer srv.Close() |
| ความเร็ว | เร็วกว่า ไม่แตะ network | ช้ากว่า |
เลือกยังไง — ถามว่า "สิ่งที่ผมกำลังเทสคือฝั่งไหนของสาย"
เทส handler ของเราเอง (ราว 95% ของเคส) → NewRecorder
เพราะเร็วกว่าและ assert ง่ายกว่า
ใช้ NewServer เมื่อ: อยากเทส client ที่เราเขียนเอง ·
ทำ fake external API ให้โค้ดเราเรียก (พอร์ตสุ่ม แต่ srv.URL บอกให้เอง
จึงเอาไปยัดใส่ config ของ client ได้) ·
หรือเทสเรื่องที่ http.Client เป็นคนจัดการอย่าง redirect กับ cookie
10 · กับดักที่เหลือ (ทุกข้อรันเจอมาแล้วจริง)
กับดัก 1 — เทสคอมไพล์ไม่ผ่านทั้งที่ยังไม่ได้เขียนอะไรผิด
$ go test -v -run TestGetUser_Basic ./...
# check
.../testify@v1.12.1/assert/yaml/yaml_default.go:31:8: missing go.sum entry for module
providing package go.yaml.in/yaml/v3 (imported by github.com/stretchr/testify/assert/yaml);
to add:
go get github.com/stretchr/testify/assert/yaml@v1.12.1
FAIL check [setup failed]
testify v1.12.1 พึ่ง go.yaml.in/yaml/v3 — go get testify เฉย ๆ ไม่ได้ดึงมันมาให้
แก้ด้วย go mod tidy (รันในโฟลเดอร์ที่มี go.mod)
จะได้ go.yaml.in/yaml/v3 v3.0.5 เข้ามาใน go.mod พร้อมคำว่า
// indirect ต่อท้าย — แปลว่า "เราไม่ได้ import เอง แต่ของที่เรา import ต้องใช้"
สังเกตคำว่า [setup failed] — แปลว่ายังไม่ได้รันเทสสักตัว
เป็นปัญหาโมดูล ไม่ใช่ปัญหาโค้ดของคุณ
กับดัก 2 — พิมพ์ path ผิดแล้ว JSONEq พังงง ๆ
traps_test.go:30: code=404 body="404 page not found" Content-Type="text/plain"
traps_test.go:34: method ผิด: code=404 body="404 page not found"
route ที่ไม่ได้ลงทะเบียน gin ตอบ 404 page not found เป็น text/plain
ไม่ใช่ JSON → assert.JSONEq จะพังแบบอ่านไม่รู้เรื่อง
เห็น body เป็น 404 page not found เมื่อไหร่ ให้สงสัย path ในเทสก่อนเลย
ไม่ใช่สงสัย handler
และบรรทัดที่สอง: ยิง DELETE /users/1 ทั้งที่ลงทะเบียนไว้แค่ GET
ก็ได้ 404 ไม่ใช่ 405 เพราะ HandleMethodNotAllowed ของ gin default เป็น false
กับดัก 3 — เรียก handler ตรง ๆ แล้ว c.Param("id") ว่างเปล่า
=== RUN TestCallHandlerDirectly/ไม่ผ่าน_router,_ไม่ใส่_Params
direct_test.go:16: c.Param("id") = "" (len=0)
direct_test.go:18: code=404 body={"error":"user not found"}
=== RUN TestCallHandlerDirectly/ไม่ผ่าน_router_แต่ยัด_c.Params_เอง
direct_test.go:26: c.Param("id") = "1"
direct_test.go:28: code=200 body={"id":"1","name":"Somchai"}
ถึง c.Request จะมี URL เป็น /users/1 ครบถ้วน c.Param("id") ก็ยังว่าง
เพราะ :id ถูกแกะโดยตัว router ไม่ใช่แกะจาก URL — ไม่มี router ก็ไม่มีใครเติม c.Params ให้
ที่ร้ายคือมันไม่ panic แต่เงียบ ๆ ตอบ 404 → เทสอาจ "ผ่าน" แบบผิด ๆ ถ้าเผลอ assert 404 ไว้
ถ้าจำเป็นต้องเรียกตรง ๆ ต้องยัดเอง:
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) // ตัวที่ 2 ที่ทิ้งไปคือ *gin.Engine เปล่า ๆ
c.Request = httptest.NewRequest("GET", "/users/1", nil)
c.Params = gin.Params{{Key: "id", Value: "1"}} // <- บรรทัดนี้ขาดไม่ได้
GetUser(c)
ค่าเริ่มต้นที่แนะนำคือยิงผ่าน router.ServeHTTP เสมอ —
ได้ทดสอบครบในนัดเดียวทั้ง routing (เลือก handler ถูกตัวไหม) ·
middleware (โค้ดที่คั่นกลางก่อนถึง handler เช่น log หรือตรวจสิทธิ์) ·
binding (แปลง body เป็น struct)
เก็บการเรียกตรงไว้ใช้ตอนอยาก unit test logic ล้วน ๆ ของ handler ตัวเดียวจริง ๆ
กับดัก 4 — Content-Type ในเทส POST
ผมยิง POST /users ด้วย body เดียวกัน 5 แบบ แล้วดูว่า header มีผลแค่ไหน:
| ยิงแบบไหน | ผลจริง |
|---|---|
ไม่ set Content-Type เลย + ShouldBindJSON | 201 ผ่าน |
set application/json + ShouldBindJSON | 201 ผ่าน |
set ผิดเป็น text/plain + ShouldBindJSON | 201 ผ่าน |
ไม่ set + ใช้ c.Bind แทน | 400 ฟ้องว่า field required ไม่ผ่าน |
body เป็น nil | 400 {"error":"EOF"} |
("binder" ในที่นี้คือตัวแปลง body ดิบ ๆ ให้กลายเป็น struct — gin มีหลายตัว เช่น JSON binder, form binder, XML binder)
ShouldBindJSON ไม่ดู header เลย มันบังคับใช้ JSON binder ตรง ๆ เสมอ
แต่ c.Bind / c.ShouldBind เลือก binder จาก Content-Type —
ไม่ set ก็ตกไปที่ form binding อ่าน JSON ไม่เจอ แล้วฟ้องว่า
Field validation for 'Name' failed on the 'required' tag
ซึ่งชี้ไปผิดที่จนหาสาเหตุไม่เจอ (คุณจะนึกว่า body ผิด ทั้งที่ header ต่างหาก)
→ ในเทส set Content-Type ให้ตรงความจริงเสมอ
ไม่ใช่เพราะ ShouldBindJSON ต้องการ แต่เพราะเทสควรเลียนแบบ client จริง
และวันที่คุณเปลี่ยนไปใช้ ShouldBind เทสจะได้ไม่พังทั้งแผง
💡 httptest.NewRequest กับ http.NewRequest ต่างกันตรงไหน
ที่ต้องรู้ข้อเดียว: httptest.NewRequest คืนค่าตัวเดียว
ส่วน http.NewRequest คืน (*http.Request, error) ต้องรับ err ทุกครั้ง —
ในเทสที่มี 20 request นั่นคือ 20 บรรทัดที่ไม่ได้ให้อะไร
และเมื่ออาร์กิวเมนต์พัง httptest.NewRequest เลือก panic แทนที่จะคืน error
(เช่นใส่ URL "://bad-url" ได้
invalid NewRequest arguments; parse "://bad-url": missing protocol scheme)
ซึ่งในเทสถือว่าดีกว่า เพราะพังทันทีตรงจุดที่พิมพ์ผิด
ที่หลายคนเข้าใจผิด: http.NewRequest("GET", "/users/1", nil)
ไม่ error และเอาไปยิง router.ServeHTTP ก็ได้ 200 ถูกต้อง —
เพราะ ServeHTTP ใช้แค่ req.URL.Path ไม่ได้ต้องการ Host
ที่แนะนำ httptest.NewRequest เป็นเรื่องความสั้น ไม่ใช่เพราะอีกตัวใช้ไม่ได้
ทดสอบความเข้าใจ
1. ใช้ w := httptest.NewRecorder() ตัวเดิมยิง /users/1 แล้วยิง /users/999 ต่อ จะเกิดอะไร?
ผลรันจริง: ครั้งที่ 2 (recorder เดิม): code=200 body={"id":"1","name":"Somchai"}{"error":"user not found"}
status เขียนได้ครั้งเดียว (ครั้งแรกชนะ) ส่วน body เป็น buffer ที่ต่อท้ายไปเรื่อย ๆ
ผลคือได้สตริงที่ไม่ใช่ JSON ที่ถูกต้องอีกต่อไป
ตัวเลือกที่ 1 ผิดสองชั้น: body ไม่ได้ถูกทับ และ code ก็ไม่ได้เปลี่ยน ·
ตัวเลือกที่ 3 ผิดเพราะไม่มี panic เลย เทสยัง PASS สบาย ๆ ซึ่งนั่นแหละคือปัญหา ·
ตัวเลือกที่ 4 ผิด — ไม่มีการรีเซ็ตใด ๆ recorder เป็นของใช้ครั้งเดียว
ทางแก้: w := httptest.NewRecorder() ต้องอยู่ใน t.Run ของทุกเคส
2. ทำไมถึงควรเทียบ body ที่เป็น JSON ด้วย assert.JSONEq แทน assert.Equal?
ผมรันเทียบให้ดูแล้ว: expected := `{ "name": "Somchai", "id": "1" }` กับ body จริง
{"id":"1","name":"Somchai"} — JSONEq ผ่าน ส่วน
Equal FAIL พร้อม diff ที่ต่างกันแค่ลำดับ key กับช่องว่าง
สำคัญเพราะลำดับ key ไม่ใช่สิ่งที่เราควบคุม (gin.H คือ map)
เทสที่ผูกกับลำดับจะพังเองวันที่ refactor ทั้งที่ API ไม่เปลี่ยน
ตัวเลือกที่ 2 ผิด Equal ใช้กับ string ได้ปกติ ·
ตัวเลือกที่ 3 สลับเรื่องกัน — เรื่อง "หยุดหรือไปต่อ" คือ require กับ assert ไม่ใช่ JSONEq ·
ตัวเลือกที่ 4 ผิด ทั้งคู่เห็นสตริงชุดเดียวกัน ไม่เกี่ยวกับภาษา
3. เทสยิง path ที่ไม่ได้ลงทะเบียนไว้ แล้ว assert.JSONEq พังแปลก ๆ — เพราะอะไร?
ผลรันจริง: code=404 body="404 page not found" Content-Type="text/plain"
JSONEq ต้องแกะทั้งสองฝั่งเป็น JSON ก่อน พอฝั่ง actual เป็นข้อความเปล่า ๆ จึงพังที่ขั้นตอนแกะ
อาการนี้แทบทุกครั้งแปลว่าพิมพ์ path ในเทสผิด ไม่ใช่ handler ผิด
ตัวเลือกที่ 1 ผิด — ผมยิง DELETE ใส่ route ที่มีแค่ GET ก็ยังได้ 404 ไม่ใช่ 405
เพราะ HandleMethodNotAllowed default เป็น false ·
ตัวเลือกที่ 2 ผิด — r.Run() คือการเปิด port ไม่เกี่ยวกับการลงทะเบียน route ·
ตัวเลือกที่ 4 ผิด — NewRequest panic เฉพาะตอน method หรือ URL ผิดรูป path ที่ไม่มี route ถือว่าปกติดี
4. เทส handler ที่ต่อกับ mock แล้วลืม svc.On(...) โดย router มี gin.Recovery() ติดอยู่ ผลคือ?
mock ที่ถูกเรียกด้วยอาร์กิวเมนต์ที่ไม่ได้สั่งไว้จะ panic ว่า
mock: I don't know what to return because the method call was unexpected
แต่ Recovery รับไว้แล้วตอบ code=500 body="" — ผลรันจริงคือ PASS
ป้องกันด้วยการ assert body ด้วยเสมอ (body ว่างจะโป๊ะทันที) และปิดท้ายด้วย
svc.AssertExpectations(t) (บทเรียน 28)
ตัวเลือกที่ 1 ผิด — Recovery ไม่ retry อะไรทั้งนั้น มันแค่กัน panic ไม่ให้ล้มทั้งโปรเซส ·
ตัวเลือกที่ 2 ผิด — ServeHTTP ไม่คืนค่าอะไรเลยตาม signature ของ http.Handler ·
ตัวเลือกที่ 3 ผิด — mock บันทึกการเรียกตามปกติ ไม่เกี่ยวกับ middleware
ใช้โค้ดที่คุณเขียนไว้เองใน บทเรียน 39 (ถ้าไม่มีแล้ว ก๊อปโค้ดหัวข้อ 2 ของบทนี้มาใช้ได้เลย) — เป้าหมายไม่ใช่ "เทสผ่าน" แต่คือ เทสที่จะ FAIL เมื่อโค้ดพัง และ FAIL ตรงจุดที่พังจริง
เริ่มจากศูนย์แบบนี้ — โฟลเดอร์เปล่า ๆ หนึ่งอัน แล้วรันสามคำสั่งนี้ในโฟลเดอร์นั้น:
go mod init check
go get github.com/gin-gonic/gin github.com/stretchr/testify
go mod tidy
จากนั้นสร้าง app.go (โค้ดหัวข้อ 2) กับ app_test.go ไว้ในโฟลเดอร์เดียวกัน
ใช้ package ชื่อเดียวกันทั้งสองไฟล์ เทสจึงเรียก NewRouter() ได้โดยไม่ต้อง import
ทุกข้อข้างล่างรันด้วย go test -v -count=1 . จากโฟลเดอร์นี้
- แยก
NewRouter()ออกจากmain()ให้ได้ก่อน — ถ้ายังทำข้อนี้ไม่ได้ ข้ออื่นทำไม่ได้เลย
เสร็จแล้วmain()ควรเหลือบรรทัดเดียวคือNewRouter().Run(":8080") - เทสตัวแรกแบบสามบรรทัด:
NewRecorder+NewRequest+ServeHTTPยิงGET /users/1แล้ว assertw.Code,w.Body(ด้วยJSONEq) และContent-Type
รันgo test -v— ถ้าเจอ[setup failed]ให้go mod tidyก่อน (กับดัก 1) - ทำให้ครบสามเคสด้วย table-driven:
200·404·400(body ที่ไม่ผ่านbinding)
เคส 400 ให้ assert แค่ชิ้นส่วนของข้อความ error (เช่น"Field validation") ไม่ใช่ทั้งประโยค — ตอบให้ได้ว่าทำไม - พิสูจน์กับดัก recorder: จงใจใช้
wตัวเดียวยิงสองเคส แล้วt.Logfค่าw.Codeกับw.Bodyออกมาดูกับตา แล้วค่อยแก้กลับ - ทำให้เทสพังให้ได้ 1 ครั้ง: แก้ handler ให้ตอบ
200ตอนไม่เจอ user แล้วยืนยันว่าเคส 404 FAIL จริง · ถ้ายังผ่านอยู่ แปลว่าเทสยังไม่ได้เทสอะไร - ใส่
TestMain+gin.SetMode(gin.TestMode)แล้วเทียบ output ก่อน/หลัง —[GIN-debug]หายไปกี่บรรทัด และw.Codeเปลี่ยนไหม - ต่อยอด mock (บทเรียน 28):
เปลี่ยน handler ให้รับ
UserServiceเป็น interface แล้วเทสด้วย mock
เพิ่มเคสที่ mock คืน error ธรรมดา (ไม่ใช่ErrNotFound) แล้ว assert500— นี่คือเคสที่เทสกับ DB จริงแทบทำไม่ได้ - ท้าทาย 1: ลบ
svc.On(...)ออกหนึ่งบรรทัด แล้วรันสองแบบ: router ที่มีgin.Recovery()กับที่ไม่มี
ทายก่อนรันว่าแบบไหน PASS แบบไหน FAIL แล้วอธิบายว่าทำไมแบบที่ PASS ถึงน่ากลัวกว่า - ท้าทาย 2: ยิง
DELETE /users/1ใส่ route ที่ลงทะเบียนไว้แค่GET
ได้ status อะไร · body หน้าตายังไง ·Content-Typeคืออะไร — แล้วตอบว่าถ้าอยากได้405ต้องไปเปิดค่าอะไรของ gin - ท้าทาย 3: เขียนเคสเดียวกันซ้ำด้วย
httptest.NewServer+http.Getแล้วเทียบว่าต้องเขียนเพิ่มกี่บรรทัด (อย่าลืมdefer srv.Close()และdefer resp.Body.Close()) — ตอบให้ได้ว่าเคสนี้คุ้มจะใช้ตัวไหน
ข้อความจริงที่ validator ตอบกลับมาในเคส 400 คือก้อนนี้:
{"error":"Key: 'CreateUserReq.Name' Error:Field validation for 'Name' failed on the 'required' tag\nKey: 'CreateUserReq.Email' Error:Field validation for 'Email' failed on the 'email' tag"}
สามเหตุผล:
1. มันมีชื่อ struct กับชื่อ field ของ Go ปนอยู่
(CreateUserReq.Name) — วันที่คุณ rename struct เทสจะพังทั้งที่ API เหมือนเดิมเป๊ะ
2. มันเป็นข้อความของ library ภายนอก ที่เราไม่ได้คุมเวอร์ชัน —
อัป validator ทีเดียวข้อความอาจเปลี่ยน
3. ลำดับของสอง error ในก้อนเดียวไม่มีใครรับประกันให้
สิ่งที่เป็นสัญญากับ client จริง ๆ มีแค่ status 400 —
เทสจึงควรผูกกับ 400 เป็นหลัก แล้วเช็คชิ้นส่วนอย่าง "Field validation"
เพียงเพื่อยืนยันว่าพังเพราะ validation จริง ๆ ไม่ใช่พังเพราะ JSON เสีย
นี่คือหลักเดียวกับที่ใช้ตลอด Day 5: assert สิ่งที่เป็นสัญญา ไม่ใช่สิ่งที่บังเอิญเป็นแบบนั้นวันนี้
💬 ข้อ 5 กับท้าทาย 1 คือสองข้อที่ผมอยากเห็นผลที่สุด —
เพราะทั้งคู่ตอบคำถามเดียวกันว่า "เทสของคุณจะจับได้ไหมถ้าโค้ดพัง"
รันแล้วได้ output ไม่ตรงกับในบทนี้ตรงไหน ส่งมาได้เลย
บอกเวอร์ชัน gin/testify กับ OS มาด้วย ผมอยากรู้ว่าต่างกันตรงไหน
(ทั้งบทนี้รันบน Go 1.26.5 darwin/arm64 · gin v1.12.0 · testify v1.12.1)
อ้างอิง
- pkg.go.dev —
net/http/httptest—NewRecorder,NewRequest,NewServerอยู่ครบในหน้าเดียว - pkg.go.dev —
ResponseRecorder— เอกสารระบุเองว่าCodeเริ่มต้นที่ 200 - pkg.go.dev —
http.Handler— interface method เดียวที่ทำให้ทั้งบทนี้เป็นไปได้ - Gin — Testing · pkg.go.dev —
gin.CreateTestContext - testify —
assert.JSONEq· testify —mock - บทเรียน 16 — table-driven test · บทเรียน 28 — mockery — สองบทที่บทนี้เอามาต่อกัน