บทเรียน 40 · Day 8 · Testing

httptest — ทดสอบ handler โดยไม่ต้องเปิดเซิร์ฟเวอร์

~16 นาที · ต้องมี: บทเรียน 16 (table-driven test), บทเรียน 17 (testify), บทเรียน 38 (Gin) · บทเรียน 28 (mockery — ใช้ในหัวข้อ 8)

ตั้งแต่บท 37 เป็นต้นมาคุณเขียน handler ไปหลายตัวแล้ว แต่วิธี "ตรวจว่ามันทำงาน" ยังเป็นการ go run . แล้วเปิดเบราว์เซอร์กด F5 อยู่เลย
บทนี้จะพิสูจน์ว่า คุณไม่ต้องเปิด port ไม่ต้องมี DB ไม่ต้องมี curl ก็ยิง request เข้า router ของตัวเองได้ — เพราะ ServeHTTP เป็นแค่ ฟังก์ชันธรรมดาที่รับ request แล้วเขียนคำตอบลงกล่อง และ httptest ก็คือ "กล่อง" ใบนั้น

📌 บทนี้ไม่มีในสไลด์ 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.Codeint200
w.Body.String()string{"id":"1","name":"Somchai"}
w.Header().Get("Content-Type")stringapplication/json; charset=utf-8
w.Result().Statusstring200 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]
ไม่ตั้งอะไรเลยdebugtruecode=200 body={"id":"7"}มี
gin.SetMode(gin.TestMode)testfalsecode=200 body={"id":"7"}ไม่มี
GIN_MODE=release go testreleasefalsecode=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
🎯 นี่คือจุดที่ Day 7 กับ Day 8 มาบรรจบกัน

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)
NewRecorderNewServer
เปิดพอร์ตจริงไม่ใช่ (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 เลย + ShouldBindJSON201 ผ่าน
set application/json + ShouldBindJSON201 ผ่าน
set ผิดเป็น text/plain + ShouldBindJSON201 ผ่าน
ไม่ set + ใช้ c.Bind แทน400 ฟ้องว่า field required ไม่ผ่าน
body เป็น nil400 {"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

🛠️ ภารกิจ — เทส handler ของบท 39 ให้ครบสามเคส

ใช้โค้ดที่คุณเขียนไว้เองใน บทเรียน 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 . จากโฟลเดอร์นี้

  1. แยก NewRouter() ออกจาก main() ให้ได้ก่อน — ถ้ายังทำข้อนี้ไม่ได้ ข้ออื่นทำไม่ได้เลย
    เสร็จแล้ว main() ควรเหลือบรรทัดเดียวคือ NewRouter().Run(":8080")
  2. เทสตัวแรกแบบสามบรรทัด: NewRecorder + NewRequest + ServeHTTP ยิง GET /users/1 แล้ว assert w.Code, w.Body (ด้วย JSONEq) และ Content-Type
    รัน go test -v — ถ้าเจอ [setup failed] ให้ go mod tidy ก่อน (กับดัก 1)
  3. ทำให้ครบสามเคสด้วย table-driven: 200 · 404 · 400 (body ที่ไม่ผ่าน binding)
    เคส 400 ให้ assert แค่ชิ้นส่วนของข้อความ error (เช่น "Field validation") ไม่ใช่ทั้งประโยค — ตอบให้ได้ว่าทำไม
  4. พิสูจน์กับดัก recorder: จงใจใช้ w ตัวเดียวยิงสองเคส แล้ว t.Logf ค่า w.Code กับ w.Body ออกมาดูกับตา แล้วค่อยแก้กลับ
  5. ทำให้เทสพังให้ได้ 1 ครั้ง: แก้ handler ให้ตอบ 200 ตอนไม่เจอ user แล้วยืนยันว่าเคส 404 FAIL จริง · ถ้ายังผ่านอยู่ แปลว่าเทสยังไม่ได้เทสอะไร
  6. ใส่ TestMain + gin.SetMode(gin.TestMode) แล้วเทียบ output ก่อน/หลัง — [GIN-debug] หายไปกี่บรรทัด และ w.Code เปลี่ยนไหม
  7. ต่อยอด mock (บทเรียน 28): เปลี่ยน handler ให้รับ UserService เป็น interface แล้วเทสด้วย mock
    เพิ่มเคสที่ mock คืน error ธรรมดา (ไม่ใช่ ErrNotFound) แล้ว assert 500 — นี่คือเคสที่เทสกับ DB จริงแทบทำไม่ได้
  8. ท้าทาย 1: ลบ svc.On(...) ออกหนึ่งบรรทัด แล้วรันสองแบบ: router ที่มี gin.Recovery() กับที่ไม่มี
    ทายก่อนรันว่าแบบไหน PASS แบบไหน FAIL แล้วอธิบายว่าทำไมแบบที่ PASS ถึงน่ากลัวกว่า
  9. ท้าทาย 2: ยิง DELETE /users/1 ใส่ route ที่ลงทะเบียนไว้แค่ GET
    ได้ status อะไร · body หน้าตายังไง · Content-Type คืออะไร — แล้วตอบว่าถ้าอยากได้ 405 ต้องไปเปิดค่าอะไรของ gin
  10. ท้าทาย 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)

อ้างอิง