1 · integration test อยู่ตรงไหนของ test pyramid
▲ แพงขึ้น · ช้าลง · เปราะขึ้น
│
┌─────────────┐
│ E2E test │ ยิงทั้งระบบผ่าน HTTP จริง ของจริงทุกชิ้น
└─────────────┘
┌───────────────────┐
│ Integration test │ ← ชั้นที่ 2 · บทนี้อยู่ตรงนี้
│ ทดสอบ "รอยต่อ" │ code ของเรา ↔ ของจริงอีก 1 ระบบ
│ ของ 2 system │ (postgres · redis · message queue)
└───────────────────┘
┌─────────────────────────┐
│ Unit test │ ฟังก์ชันเดียว · ของรอบข้าง mock หมด
└─────────────────────────┘
│
▼ ถูกลง · เร็วขึ้น · เขียนง่ายขึ้น
จุดที่คนสับสนบ่อยคือคิดว่า integration test = "เทสที่ช้า"
ซึ่งไม่ใช่นิยามของมัน นิยามคือ "เทสที่ไม่ mock รอยต่อ"
บทที่ 28 คุณ mock UserRepository เพื่อเทส service
— รอยต่อ service ↔ repository ถูกปิดด้วยของปลอม
บทนี้เราจะเปิดมันออก แล้วให้ repository คุยกับ PostgreSQL ตัวจริง
ไม่แพงสุด ไม่ถูกสุด — นี่คือเหตุผลที่มันอยู่ชั้นกลาง
ตัวเลขจริงจากเครื่องผม (อยู่ในหัวข้อ 8): unit test ที่ mock ไว้ทำงานใน
1.251ms ส่วน integration test ต้องรอ container ขึ้นก่อน
~1.03s — ต่างกันประมาณ 820 เท่า
ราคานี้ทำให้จ่ายครั้งเดียวได้ — แต่ยังไม่ใช่ในบทนี้
บทนี้เราจะเปิด container ใหม่ทุกเทสก่อน เพื่อให้เห็นราคาเต็ม ๆ ด้วยตาตัวเอง (หัวข้อ 8)
ส่วนวิธีแชร์ container ตัวเดียวให้ทั้ง package จนเหลือจ่ายครั้งเดียว เป็นของบทเรียน 30
2 · หัวใจของบทนี้ — mock ที่ผ่านฉลุยทั้งที่ SQL ผิด
สมมติคุณเขียน method นี้ต่อจาก repository ในบทเรียน 27
โดยลืมไปว่าตาราง users ของคุณไม่มี column ชื่อ city:
// internal/repository/user_repository.go
func (r *UserRepository) SearchByCity(ctx context.Context, city string) ([]model.User, error) {
var users []model.User
err := r.db.WithContext(ctx).
Raw("SELECT * FROM users WHERE city = ?", city).
Scan(&users).Error
if err != nil {
return nil, err
}
return users, nil
}
ก่อนไปต่อ ขอแนะนำเครื่องมืออีกตัวก่อน — go-sqlmock
บท 28 คุณใช้ mockery ปลอม interface ของ repository (ชั้นบนของ repository)
แต่คราวนี้เราอยากดูว่า repository ตัวจริงที่ยิง SQL ออกไปนั้นถูกหรือผิด
จึงต้องปลอมตัว database เอง ที่อยู่ใต้ repository
นั่นคือหน้าที่ของ go-sqlmock: มันสร้าง *sql.DB ปลอมขึ้นมารอรับ SQL ที่โค้ดของเรายิงออกไป
แล้วเอาไปเทียบกับ regex ที่เราเขียนดักไว้
(regex = รูปแบบข้อความที่บอกว่า "ข้อความหน้าตาแบบไหนถือว่าตรง" เช่น .* แปลว่าอะไรก็ได้)
ถ้าตรงกับที่ดักไว้ มันก็คืนแถวข้อมูลที่เราเป็นคนป้อนให้มันเอง
# ติดตั้งเพิ่ม 1 บรรทัด
go get github.com/DATA-DOG/go-sqlmock
// setup: เอา *sql.DB ปลอม ยัดเข้า GORM แทน postgres จริง
sqlDB, mock, err := sqlmock.New()
require.NoError(t, err)
defer sqlDB.Close()
gdb, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{})
require.NoError(t, err)
repo := repository.NewUserRepository(gdb) // repository ตัวเดิมจากบท 27
// unit test ด้วย go-sqlmock — mock database ทั้งตัว
rows := sqlmock.NewRows([]string{"id", "name", "email", "age", "created_at"}).
AddRow(7, "Ploy", "ploy@example.com", 28, time.Now())
mock.ExpectQuery(`SELECT \* FROM users WHERE city = .*`).WillReturnRows(rows)
users, err := repo.SearchByCity(context.Background(), "Bangkok")
t.Logf("MOCK RESULT: len=%d first=%s", len(users), users[0].Name)
ตาราง users ไม่มี column city จริง ๆ
— go test ตัวนี้ PASS หรือ FAIL?
PASS — และผ่านแบบไม่มีคำเตือนอะไรเลย:
=== RUN TestMockedSearchByCity_PASSES_WITH_A_BUG
unitmock_test.go:72: MOCK RESULT: len=1 first=Ploy (took 166.042µs)
unitmock_test.go:73: => mocked unit test is GREEN even though column `city` does not exist
--- PASS: TestMockedSearchByCity_PASSES_WITH_A_BUG (0.00s)
go-sqlmock จับคู่ SQL ด้วย regex แล้วคืน rows ที่เราป้อนให้
มันไม่มี SQL parser และไม่รู้จัก schema ของคุณเลยแม้แต่นิดเดียว
คุณเขียน regex ว่า SELECT \* FROM users WHERE city = .*
มันก็แค่ match แล้วคืนของที่คุณเตรียมไว้ —
คุณกำลังเทสว่า "regex ที่ผมเขียน ตรงกับ string ที่ผมเขียน"
ทีนี้เอา ฟังก์ชันตัวเดียวกันเป๊ะ ไปยิง PostgreSQL ของจริง
ระวังอ่านผิด: เทสตัวล่างก็ขึ้น PASS เหมือนกัน
แต่คนละความหมายกัน — ตัวล่างผมเขียนว่า require.Error(t, err)
คือ คาดหวังว่าต้องได้ error มันจึง PASS ก็ต่อเมื่อ postgres ด่ากลับมาจริง
สรุปสั้น ๆ: mock PASS = บั๊กหลุด · postgres จริง PASS = จับบั๊กได้
=== RUN TestSearchByCity_CaughtByRealPostgres
badsql_test.go:24: VERBATIM real-postgres error: ERROR: column "city" does not exist (SQLSTATE 42703)
--- PASS: TestSearchByCity_CaughtByRealPostgres (1.07s)
❌ สิ่งที่ mock ตรวจได้
"logic ของเราเรียกถูกไหม" — เรียกกี่ครั้ง · ส่ง argument อะไร ·
แปลงผลลัพธ์ถูกไหม · จัดการ error ที่คืนมาถูกไหม
ทั้งหมดนี้อยู่ในหัวของเราฝ่ายเดียว
✅ สิ่งที่มีแต่ DB จริงตรวจได้
"SQL ใช้ได้กับ schema จริงไหม" — column มีอยู่จริงไหม ·
ตารางชื่อถูกไหม · constraint ทำงานไหม · type ตรงไหม · migration ครบไหม
ทั้งหมดนี้เป็นสัญญากับระบบอื่น
สมมติคุณจะไปสั่งอาหารร้านหนึ่ง แล้วอยากซ้อมก่อน จึงชวนเพื่อนมาเล่นเป็นพนักงาน
แต่เมนูที่เพื่อนถืออยู่ คือเมนูที่คุณเขียนขึ้นเองเมื่อกี้
และคุณบอกบทเพื่อนไว้ด้วยว่า “ถ้าฉันสั่งข้าวผัดปู ให้ตอบว่ามีนะ”
คุณสั่ง เพื่อนตอบว่า “มีครับ” แล้วยกจานสมมติมาให้ — ซ้อมผ่านฉลุย
ทั้งที่ร้านจริงไม่เคยมีเมนูนี้อยู่เลย
พอไปที่ร้านจริง พนักงานเปิดเมนูจริงแล้วบอกว่า “เมนูนี้ไม่มีค่ะ” — จบ รู้ทันที
· ซ้อมกับเพื่อน = เทสที่ mock ตัว database ไว้ (go-sqlmock)
· ไปสั่งที่ร้านจริง = เทสที่ยิง PostgreSQL ตัวจริง
· ประโยค column "city" does not exist ก็คือ “เมนูนี้ไม่มีค่ะ” นั่นเอง
ที่ไม่เหมือนกัน: เพื่อนคุณยังพอรู้ว่าร้านนั้นขายอะไรอยู่บ้าง
แต่ go-sqlmock ไม่รู้จักตารางของคุณเลยสักนิด —
มันเทียบแค่ข้อความ SQL กับ regex ที่คุณเขียนเอง
อย่าเผลอคิดว่ามัน “พอจะเช็ค SQL ให้ได้บ้าง” เพราะมันเช็คไม่ได้เลยจริง ๆ
ส่วนการซ้อมก็ไม่ใช่เรื่องเสียเปล่า
มันยังตรวจได้ว่าคุณสั่งครบไหม นับจานถูกไหม ทอนเงินถูกไหม
ซึ่งเป็นเรื่องของฝั่งคุณล้วน ๆ
และร้านจริงต้องรอคิว (~1.03s เทียบกับ 1.251ms ตอนซ้อม)
คุณจึงซ้อมเป็นร้อยรอบได้ แต่เดินเข้าร้านจริงเป็นร้อยรอบไม่ไหว
error ตรงตัวอีก 3 แบบที่ DB จริงจับได้
badsql_test.go:24: VERBATIM real-postgres error: ERROR: column "city" does not exist (SQLSTATE 42703)
badsql_test.go:38: VERBATIM typo error: ERROR: column "naem" does not exist (SQLSTATE 42703)
badsql_test.go:52: VERBATIM missing-table error: ERROR: relation "userz" does not exist (SQLSTATE 42P01)
และอันนี้คือ unique index ที่ AutoMigrate สร้างจาก tag
gorm:"uniqueIndex" บน field Email (บทเรียน 27) —
ลอง insert email ซ้ำแล้วได้:
user_suite_test.go:85: VERBATIM duplicate-key error: ERROR: duplicate key value violates unique constraint "idx_users_email" (SQLSTATE 23505)
SQLSTATE คืออะไร — คือรหัส error 5 ตัวอักษร ที่เป็นมาตรฐานของภาษา SQL
ไม่ใช่ของ postgres ไม่ใช่ของ GORM และไม่ใช่ของ testcontainers
ข้อความภาษาอังกฤษข้างหน้าอาจเปลี่ยนตามเวอร์ชันหรือภาษาที่ตั้งไว้ แต่รหัสนี้คงที่
เวลาเจอ error จาก DB ให้อ่านรหัสก่อนข้อความ แล้วเอาไปค้นได้ตรง ๆ
(รายชื่อทั้งหมดอยู่ในลิงก์ PostgreSQL — Error Codes ท้ายบท)
สามตัวที่คุณจะเจอบ่อยที่สุดคือ:
| SQLSTATE | แปลว่า | เจอเมื่อ |
|---|---|---|
42703 | undefined_column | column ไม่มี · พิมพ์ชื่อผิด · ลืม migration |
42P01 | undefined_table | ตารางไม่มี · พิมพ์ชื่อตารางผิด |
23505 | unique_violation | ค่าซ้ำในคอลัมน์ที่มี unique index |
⚠️ อย่าอ่านบทนี้เป็น "mock ไม่ดี ทิ้งไปเลย"
ใช้ทั้งคู่ คนละหน้าที่: mock สำหรับ business logic
(1.25ms ต่อเทส เอาไปวนหลายร้อยเคสได้)
· container สำหรับ SQL/schema/constraint
กฎง่าย ๆ: ทุกอย่างที่เป็น "สัญญากับ database" ต้องมี integration test
— column, ตาราง, constraint, type, migration
คำพวกนี้คือชื่อที่วงการใช้เรียกสิ่งที่คุณเพิ่งเข้าใจไป — ใช้คำให้ตรง แล้วคำตอบจาก senior หรือจาก AI จะตรงตาม
- unit test
- เทส “ชิ้นเดียว” — ของรอบข้างปลอมหมด = การซ้อมกับเพื่อน · เวลาคุยให้พูดว่า “ตัวนี้เป็น unit test นะ repository ถูก mock ไว้” อีกฝ่ายจะรู้ทันทีว่าเทสนี้ไม่ได้แตะ database
- integration test
- เทส “รอยต่อ” ระหว่างโค้ดเรากับของจริงอีกระบบหนึ่ง = การไปสั่งที่ร้านจริง
นิยามของมันคือ “ไม่ mock รอยต่อ” ไม่ใช่ “เทสที่ช้า” — ที่ช้าเป็นแค่ผลพลอยได้ - mock
- ของปลอมที่เราเขียนบทให้มันตอบ และตรวจด้วยว่าถูกเรียกตามบทหรือเปล่า
(นั่นคือหน้าที่ของ
ExpectQueryข้างบน) · ถ้าแค่คืนค่าที่เตรียมไว้เฉย ๆ ไม่ตรวจการเรียก วงการเรียกว่า stubgo-sqlmockคือ mock ของ ตัว database ส่วนmockeryในบท 28 คือ mock ของ interface
เวลาถามให้ระบุชั้นให้ชัด: “เทสนี้ mock ที่ชั้นไหน — interface หรือ database” - schema
- “แปลนของฐานข้อมูล” — ว่ามีตารางอะไร แต่ละตารางมี column ชื่ออะไร ชนิดไหน
mock ไม่รู้จัก schema ของคุณเลย ฐานข้อมูลจริงเท่านั้นที่รู้ - constraint
- “กฎที่ database บังคับไว้เอง” เช่น ห้าม email ซ้ำ —
ในบทนี้คือ unique index ที่
AutoMigrateสร้างจาก taggorm:"uniqueIndex"
(ฝั่ง postgres ตัว unique index กับ unique constraint เป็นคนละของกันในทางเทคนิค แต่บังคับกฎเหมือนกัน และข้อความ error ก็ขึ้นว่าviolates unique constraintเหมือนกัน) · โค้ดฝั่งเราลืมเช็คได้ แต่ database ไม่ลืม - migration
- ขั้นตอนแก้โครงตารางให้ตรงกับโค้ดรุ่นใหม่ (เพิ่ม column, สร้างตาราง) · ประโยคที่ได้ยินบ่อยคือ “ลืมรัน migration” = โค้ดใหม่แล้ว แต่ตารางยังเป็นของเก่า
- SQLSTATE
- รหัส error 5 ตัวอักษรมาตรฐานของ SQL เช่น
42703· เวลาไปถามใครให้ยกรหัสไปด้วย: “เจอ SQLSTATE 42703 ตอน query users” จะสื่อกว่าพูดว่า “DB error”
3 · nil slice ที่คืนจากหลังบ้าน — [] หรือ null
คำถามคลาสสิก: repository ที่ query แล้วไม่เจอแถวเลย
คืนอะไรออกมา แล้วพอ json.Marshal ต่อ frontend ได้อะไร
คำตอบไม่ใช่ขาวดำ — ขึ้นกับว่าคุณใช้ทางไหนอ่านข้อมูล
ผมรันทั้ง 5 กรณีบน postgres จริง:
// 1. GORM Find เข้า nil slice
var a []model.User
db.WithContext(ctx).Where("age >= ?", 18).Find(&a)
// 2. GORM Find เข้า make([]T,0)
b := make([]model.User, 0)
db.WithContext(ctx).Where("age >= ?", 18).Find(&b)
// 3. GORM Raw + Scan เข้า nil slice
var c []model.User
db.WithContext(ctx).Raw("SELECT * FROM users WHERE age >= ?", 18).Scan(&c)
// 4. database/sql เขียน loop เอง
var users []model.User // nil slice
for rows.Next() { ... users = append(users, u) }
ผลจริงที่รันบน container (isNil คือ s == nil):
nilslice_test.go:24: GORM Find, var []T, 0 rows len=0 isNil=false json=[]
nilslice_test.go:24: GORM Find, make([]T,0), 0 rows len=0 isNil=false json=[]
nilslice_test.go:24: GORM Raw+Scan, var []T, 0 rows len=0 isNil=true json=null
nilslice_test.go:24: database/sql rows.Next loop, 0 rows len=0 isNil=true json=null
nilslice_test.go:24: GORM Find, var []T, 1 row len=1 isNil=false json=[{"id":1,"name":"X",...}]
| ทางที่ใช้อ่าน | len() | == nil | JSON |
|---|---|---|---|
GORM Find (ทั้ง var และ make) | 0 | false | [] ✅ |
GORM Raw().Scan() | 0 | true | null ⚠️ |
database/sql + rows.Next() | 0 | true | null ⚠️ |
อ่านตารางนี้ยังไง
len(s) == 0 ตรวจไม่ได้ว่า slice เป็น nil หรือเปล่า —
nil slice กับ empty slice มี len เท่ากันคือ 0
ต่างกันที่ s == nil และต่างกันที่ encoding/json มองคนละอย่าง
ที่น่าสนใจคือ GORM Find ไม่คืน nil เลย
ต่อให้คุณส่ง var users []T ที่เป็น nil เข้าไป —
มัน reflect (ส่องดูโครงสร้างตัวแปรตอนโปรแกรมกำลังรัน)
แล้ว allocate (จองที่ในหน่วยความจำ) slice ให้เอง
ปัญหา null จึงไม่เกิดบนเส้นทางนี้
ปัญหาเกิดจริงกับ Raw().Scan() และ repository ที่เขียน
rows.Next() เอง — ซึ่งเป็นสองทางที่คุณจะใช้เมื่อ query
ซับซ้อนเกิน GORM
คุณเดินออกจากซูเปอร์มาแบบไม่ได้ซื้ออะไรเลย ซึ่งเป็นไปได้ 2 แบบ:
· ถือตะกร้าเปล่ามา — มีตะกร้าอยู่ แต่ในนั้นไม่มีของ
· ไม่ได้หยิบตะกร้ามาเลย — ไม่มีแม้แต่ตะกร้า
ถ้านับจำนวนของ ทั้งสองแบบได้ 0 ชิ้น เท่ากันเป๊ะ —
นี่คือเหตุผลที่ len(s) == 0 แยกสองกรณีนี้ไม่ออก
แต่พอต้องบอกคนอื่นว่าเป็นยังไง มันคนละประโยคกันชัดเจน
· ตะกร้าเปล่า → JSON ออกมาเป็น [] (มีลิสต์อยู่ แค่ยังไม่มีของในลิสต์)
· ไม่มีตะกร้า → JSON ออกมาเป็น null (ไม่มีลิสต์เลย)
ฝั่ง frontend ที่เตรียมโค้ดไว้วนลูปในลิสต์ พอเจอ null ก็จะพัง
เพราะมันไม่มีอะไรให้วน
ที่ไม่เหมือนกัน: ในชีวิตจริง ไม่มีตะกร้าก็ใส่ของไม่ได้ —
แต่ใน Go nil slice ยัง append ใส่ของได้ตามปกติ
ไม่ต้อง make ก่อนก็ได้ ไม่พังแน่นอน
ความต่างของสองแบบนี้มีผลจริง 3 ที่: ตอนเช็ค s == nil ·
ตอนแปลงเป็น JSON · และตอนเทียบใน test —
reflect.DeepEqual (ตัวที่ require.Equal ในบท 17 ใช้ข้างใน)
มองว่า nil slice กับ empty slice ไม่เท่ากัน
ทั้งที่ len เท่ากันและหน้าตาเหมือนกัน
— require.Equal(t, []model.User{}, users) จะ FAIL ถ้า users เป็น nil slice
วิธีแก้เหมือนกันทั้งสองกรณี คือประกาศด้วย
users := make([]model.User, 0) แทน var users []model.User
แล้ว append ต่อ — append ทำงานกับ empty slice ได้ปกติ
ผลคือ frontend ได้ [] เสมอ ไม่ต้องเขียน
if (data === null) ทุกหน้า
⚠️ นี่คือเหตุผลว่าทำไมเรื่องนี้ต้องอยู่ในบท integration test
ถ้าคุณ mock ไว้ คุณเลือกเองว่าจะให้ mock คืน nil หรือ empty slice
— เทสจึงยืนยันแค่สิ่งที่คุณสมมติไว้ ไม่ได้ยืนยันว่า GORM ทำอะไรจริง
คำตอบข้างบนทุกบรรทัดมาจากการรันกับ postgres จริง
- nil slice
- “ไม่มีตะกร้า” — slice ที่ประกาศด้วย
var users []Userเฉย ๆ ยังไม่มีตัวลิสต์จริง ๆ
เวลาถามให้พูดว่า “ตรงนี้เป็น nil slice หรือ empty slice” จะสื่อกว่าถามว่า “ทำไมมันว่าง” - empty slice
- “ตะกร้าเปล่า” — ได้จาก
make([]User, 0)· มีลิสต์อยู่จริง แค่ยังไม่มีสมาชิก - marshal / unmarshal
- marshal = แปลงค่าใน Go ให้เป็น JSON เพื่อส่งออกไป ·
unmarshal = แปลงกลับเข้ามา
ประโยคที่ใช้ได้จริง: “พอ marshal nil slice จะได้ null ไม่ใช่ []” - zero value
- ค่าเริ่มต้นที่ Go ใส่ให้อัตโนมัติเมื่อประกาศตัวแปรแล้วยังไม่กำหนดค่า ·
ของ slice คือ
nil— นี่คือเหตุผลที่var users []Userออกมาเป็น nil
4 · Testcontainers คืออะไร และทำไมไม่ใช้ DB ที่ลงไว้ในเครื่อง
Testcontainers คือ library ที่สั่ง Docker เปิด container ให้จากในโค้ดเทส
ไม่ใช่จาก docker-compose up ที่คุณต้องจำไปรันเอง
เทสเริ่ม → container ขึ้น · เทสจบ → container ถูกลบทิ้ง
❌ postgres ที่ลงไว้ในเครื่อง
- เพื่อนในทีมต้องลงเองให้ version ตรงกัน
- ข้อมูลจากเทสรอบก่อนค้างอยู่ → เทสรอบนี้ผลเพี้ยน
- เทสเผลอลบข้อมูลที่คุณใช้ dev อยู่
- CI ต้องมี service ที่ตั้งค่าแยกอีกชุด
✅ Testcontainers
- version เขียนอยู่ในโค้ด (
postgres:16-alpine) - ทุกรอบเริ่มจาก DB เปล่า ไม่มีของค้าง
- แยกขาดจาก DB ที่คุณใช้ dev
- CI ที่มี Docker ก็รันได้เลย ไม่ต้องตั้งอะไรเพิ่ม
สมมติคุณต้องทำงานเลอะ ๆ สักอย่าง เช่น ผสมสี
ถ้าทำในห้องนอนตัวเอง: ของจากรอบที่แล้วยังกองอยู่ ·
เลอะแล้วต้องเก็บเอง · เผลอทำของสำคัญพัง · แล้วเพื่อนที่จะมาทำแบบเดียวกันก็ต้องจัดห้องตัวเองให้เหมือนคุณก่อน
ถ้าเช็คอินโรงแรม: ได้ห้องใหม่สะอาดเหมือนกันทุกครั้ง ·
ทำอะไรในนั้นก็ไม่กระทบบ้าน · เช็คเอาท์เสร็จมีคนเก็บให้
· ห้องนอนตัวเอง = postgres ที่ลงไว้ในเครื่องคุณ
· เช็คอินโรงแรม = เปิด container ใหม่ตอนเทสเริ่ม
· Testcontainers = คนที่จองห้องให้อัตโนมัติตอนเทสเริ่ม — คุณไม่ต้องไปกดจองเอง
แต่การเช็คเอาท์ยังต้องกดเอง คือคุณต้องเขียน cleanup ไว้ในโค้ดเทส
มีแค่แม่บ้าน (Ryuk ในย่อหน้าถัดไป) คอยตามเก็บให้เผื่อคุณลืม
ที่ไม่เหมือนกัน: โรงแรมเก็บของที่คุณลืมไว้ให้ แต่ container
ถูกลบทิ้งทั้งตัว ข้อมูลข้างในหายหมด เรียกคืนไม่ได้ —
ซึ่งเป็นสิ่งที่เราต้องการ ไม่ใช่ผลข้างเคียง
และเลขห้องเปลี่ยนทุกครั้ง จำเลขเดิมไปใช้ไม่ได้ —
เรื่องนี้จะกลายเป็นกับดักตัวใหญ่ในหัวข้อ 7
ตัว library ยังเปิด container ผู้ช่วยชื่อ Ryuk ให้อัตโนมัติด้วย
(testcontainers/ryuk:0.14.0) หนึ่งตัวต่อหนึ่ง test process
หน้าที่ของมันคือตามลบ container ที่คุณลืมลบ —
เป็นตาข่ายกันตก ไม่ใช่แผนหลัก คุณยังต้องเขียน cleanup เองอยู่ดี
- image
- “แบบแปลนห้อง” ที่ใช้สร้าง container ขึ้นมา — เช่น
postgres:16-alpine· ตัวมันเองไม่ได้รันอยู่ เป็นแค่ไฟล์ที่โหลดเก็บไว้ - tag
- ป้ายชื่อที่ต่อท้าย image หลัง
:ใช้ระบุรุ่นหรือสายพันธุ์ของ image —16-alpineคือ tag,latestก็เป็น tag ตัวหนึ่ง (ไม่จำเป็นต้องเป็นเลขรุ่น) · ประโยคที่ควรพูด: “ล็อก tag ไว้ที่ 16-alpine นะ อย่าใช้ latest” เพราะlatestเปลี่ยนได้ตลอดโดยที่โค้ดเราไม่เปลี่ยนเลย - container
- “ห้องที่เช็คอินแล้ว” — image ที่ถูกสั่งให้รันจริงหนึ่งตัว ·
docker psคือคำสั่งดูว่าตอนนี้มีห้องไหนเปิดอยู่บ้าง - reaper (Ryuk)
- “แม่บ้านตามเก็บ” — container ผู้ช่วยที่ testcontainers เปิดให้เอง
ไว้ตามลบ container ที่เราลืมลบ
ถ้าเห็นชื่อryukโผล่ในdocker psอย่าตกใจ ไม่ใช่ของแปลกปลอม - test isolation
- “เทสแต่ละตัวไม่กวนกัน” — ของที่เทสก่อนหน้าสร้างไว้ ต้องไม่ทำให้เทสถัดไปผลเพี้ยน
เวลาเทสผ่านบ้างไม่ผ่านบ้าง คำถามแรกที่ senior จะถามคือ “เทสมัน isolate กันดีหรือเปล่า”
5 · ติดตั้ง — จุดที่คนติดกันหมด
คำสั่งที่ใช้รันตลอดบทนี้ — go test -v -count=1 ./...
-v จำเป็น เพราะ output ทุกอันในบทนี้มาจาก t.Logf และ log ของ testcontainers
ถ้ารัน go test เฉย ๆ คุณจะเห็นแค่ ok บรรทัดเดียว
-count=1 จำเป็น เพราะ Go จะ cache ผลเทสไว้ ถ้าโค้ดไม่เปลี่ยน มันจะไม่รันซ้ำจริง
(คุณจะเห็นคำว่า (cached) แทนเวลาที่ใช้) — ซึ่งทำให้เทียบ port หรือจับเวลาไม่ได้เลย
สไลด์บอกให้ go get บรรทัดเดียว แต่ไม่พอ
เพราะ module ของ postgres เป็น คนละ Go module กับตัวหลัก
ผม go get แค่ตัวหลักแล้ว build ได้แบบนี้:
main.go:7:2: no required module provides package github.com/testcontainers/testcontainers-go/modules/postgres; to add it:
go get github.com/testcontainers/testcontainers-go/modules/postgres
(exit 1)
go mod init tcdemo
go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/modules/postgres # ← บรรทัดที่สไลด์ลืม
go get gorm.io/gorm gorm.io/driver/postgres
go get github.com/stretchr/testify
go get github.com/DATA-DOG/go-sqlmock # ← ตัวที่ใช้ mock database ในหัวข้อ 2
ได้ v0.44.0 ทั้งคู่ · หลัง go mod tidy ใน go.mod จะเห็น:
require (
github.com/stretchr/testify v1.12.0
github.com/testcontainers/testcontainers-go v0.44.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
gorm.io/driver/postgres v1.6.2
gorm.io/gorm v1.31.2
)
ถ้าไม่ได้ใช้ Docker Desktop ต้องตั้ง env อีก 2 ตัว
testcontainers-go v0.44.0 ไม่อ่าน docker context ของ Docker CLI เลย
— แปลว่า docker ps ของคุณใช้ได้ปกติ แต่เทสพังตั้งแต่บรรทัดแรก
ถ้าคุณใช้ colima / Rancher Desktop / Podman บน macOS
shared_test.go:17: shared container failed: run postgres: generic container: get provider: rootless Docker not found, failed to create Docker provider
--- FAIL: TestSharedA (0.00s)
# แก้ด้วย 2 บรรทัดนี้ (รันก่อน go test)
export DOCKER_HOST=$(docker context inspect --format '{{.Endpoints.docker.Host}}')
export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock
ตัวที่สองจำเป็นเพราะ Ryuk ต้อง bind-mount (เอาไฟล์ของเครื่องเรา ไปโผล่อยู่ข้างในตัว container) docker socket เข้า container แต่ path ฝั่ง macOS ไม่มีอยู่ใน VM — ถ้าตั้งแค่ตัวแรกจะได้ error นี้:
shared_test.go:17: shared container failed: run postgres: generic container: create container: reaper: new reaper: run container: container start: Error response from daemon: error while creating mount source path '/Users/tuwanon/.colima/default/docker.sock': mkdir /Users/tuwanon/.colima/default/docker.sock: operation not supported: could not start container
วิธีเช็คว่าตั้งถูกแล้ว — ดู log 2 บรรทัดนี้ตอนเทสเริ่ม สองค่านี้ตั้งใจให้ต่างกันได้ (อันแรกคือ path ฝั่งเครื่องคุณ อันหลังคือ path ฝั่ง VM):
Resolved Docker Host: unix:///Users/tuwanon/.colima/default/docker.sock
Resolved Docker Socket Path: /var/run/docker.sock
ถ้าคุณใช้ Docker Desktop มักไม่ต้องตั้งอะไรเลย (library มีทางลัดเช็คจาก daemon ให้เอง) — แต่ผมทดสอบกรณีนั้นไม่ได้ เพราะเครื่องที่ตรวจใช้ colima
6 · เปิด postgres container จากในเทสจริง
import (
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
)
func StartPostgres(ctx context.Context) (*tcpostgres.PostgresContainer, time.Duration, error) {
start := time.Now()
ctr, err := tcpostgres.Run(ctx,
"postgres:16-alpine",
tcpostgres.WithDatabase("testdb"),
tcpostgres.WithUsername("testuser"),
tcpostgres.WithPassword("testpass"),
tcpostgres.BasicWaitStrategies(),
)
return ctr, time.Since(start), err
}
ต่อ GORM แล้ว AutoMigrate ตามที่ทำในบทเรียน 27 ได้เลย
ต่างกันแค่ DSN มาจาก container ไม่ใช่จาก config file
(DSN = ข้อความบรรทัดเดียวที่บอกว่าจะไปต่อ database เครื่องไหน port อะไร ด้วยชื่อผู้ใช้อะไร):
func OpenGorm(ctx context.Context, ctr *tcpostgres.PostgresContainer) (*gorm.DB, string, error) {
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable") // ← port สุ่มอยู่ในนี้
if err != nil {
return nil, "", err
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return nil, dsn, err
}
if err := db.AutoMigrate(&model.User{}); err != nil {
return nil, dsn, err
}
return db, dsn, nil
}
BasicWaitStrategies() — บรรทัดที่ห้ามลืม
บรรทัดนี้แหละคือความต่างระหว่าง "เทสผ่าน" กับ "เทสผ่านบ้างไม่ผ่านบ้าง" ผมเปิด source ของ v0.44.0 มาดูว่ามันทำอะไร:
func BasicWaitStrategies() testcontainers.CustomizeRequestOption {
return testcontainers.WithAdditionalWaitStrategy(
// First, we wait for the container to log readiness twice.
// This is because it will restart itself after the first startup.
wait.ForLog("database system is ready to accept connections").WithOccurrence(2),
// Then, we wait for docker to actually serve the port on localhost.
// For non-linux OSes like Mac and Windows, Docker or Rancher Desktop will have to start a separate proxy.
// Without this, the tests will be flaky on those OSes!
wait.ForListeningPort("5432/tcp"),
)
}
| เงื่อนไข | ทำไมต้องมี |
|---|---|
| รอ log ครบ 2 ครั้ง | postgres พิมพ์บรรทัดนี้ครั้งแรกตอน initdb เสร็จ แล้วปิดตัวเองแล้วเปิดใหม่ — ครั้งที่ 2 ถึงจะพร้อมจริง |
รอ port 5432/tcp listening | บน macOS/Windows ตัว Docker ต้องเปิด proxy แยกอีกชั้น — คอมเมนต์ใน source เขียนตรง ๆ ว่าถ้าไม่รอ "tests will be flaky on those OSes" |
ถ้ารอแค่ log ครั้งเดียว (ไม่ใส่ BasicWaitStrategies) ผมต่อ DB แล้วได้:
nowait_test.go:53: VERBATIM early-connect error after 3.590916ms: failed to connect to `user=testuser database=testdb`:
[::1]:32800 (localhost): dial error: dial tcp [::1]:32800: connect: connection refused
127.0.0.1:32800 (localhost): failed to receive message: unexpected EOF
สังเกตสองบรรทัดล่างให้ดี เพราะมันบอกอาการคนละอย่าง:
[::1] ได้ connection refused = ไม่มีอะไรฟังอยู่
ส่วน 127.0.0.1 ได้ unexpected EOF =
ต่อ TCP ติดแล้ว แต่ postgres ตัดสายกลางคัน
— นั่นคือลายเซ็นของ "ต่อเร็วเกินไป" ไม่ใช่ "ยังไม่มีอะไรฟัง"
คุณมีเบอร์ร้านอยู่ 2 เบอร์ เลยโทรไล่ทีละเบอร์ แล้วไม่สำเร็จทั้งคู่ — แต่คนละอาการ
· “ไม่มีสัญญาณ / ไม่มีใครรับสาย” — เบอร์นั้นไม่มีใครอยู่ปลายสาย
· “มีคนรับ พูดฮัลโหลแล้ววางสายทันที” — เบอร์นี้มีคนอยู่จริง
แต่ยังไม่พร้อมคุย
error 2 บรรทัดข้างบนคืออาการ 2 แบบนี้ แต่ต้องอ่านให้ครบทั้งคู่ก่อนสรุป
เพราะ localhost มีได้หลายที่อยู่ และ pgx ไล่ลองทีละอัน:
· [::1] (ฝั่ง IPv6) ได้ connection refused =
ไม่มีใครฟังอยู่ตรงนั้นจริง ๆ — ที่อยู่ IPv6 ของ Docker บน macOS/Windows
มักต่อไม่ติดแบบนี้อยู่แล้ว ต่อให้ container พร้อมสนิทแล้วก็ตาม
· 127.0.0.1 (ฝั่ง IPv4) ได้ unexpected EOF =
ต่อติดแล้ว แต่ถูกตัดสายกลางคัน — บรรทัดนี้ต่างหากคือปัญหาจริง
แปลว่ามีของอยู่จริง แต่คุณไปเร็วเกินไป
สังเกตว่า port เป็น 32800 เหมือนกันทั้งสองบรรทัด —
ไม่ใช่ “กดเบอร์ผิด” และไม่ใช่ container ยังไม่ขึ้น
ทั้งสองบรรทัดจึงหายไปพร้อมกันด้วยการแก้ทางเดียวกัน คือ “รอให้พร้อมก่อน”
(พอฝั่ง IPv4 ต่อติด pgx ก็ใช้อันนั้นต่อได้เลย ไม่ฟ้องอะไรออกมาอีก)
ที่ไม่เหมือนกัน: คนวางสายเพราะไม่อยากคุย
แต่ postgres ตัดสายเพราะมันปิดตัวเองเพื่อเปิดใหม่หลังเตรียมฐานข้อมูลเสร็จ
ซึ่งเป็นขั้นตอนปกติของมัน ไม่ใช่การปฏิเสธและไม่ใช่ความผิดพลาด
เราจึงไม่ได้แก้ด้วยการ “โทรซ้ำ” แต่แก้ด้วยการรอ log บรรทัดพร้อมให้ครบ 2 ครั้งตามที่
BasicWaitStrategies() ทำให้
- wait strategy
- “เงื่อนไขว่าถือว่าพร้อมเมื่อไหร่” ก่อนปล่อยให้เทสเริ่มต่อ · เวลาเทสพังตอน container เพิ่งขึ้น ให้ถามตรง ๆ ว่า “wait strategy ของ container ตัวนี้รออะไรอยู่”
- flaky test
- เทสที่ “ผ่านบ้างไม่ผ่านบ้าง” ทั้งที่โค้ดไม่ได้เปลี่ยน · เป็นคำที่ทีมใช้บ่อยมาก — พูดว่า “เทสตัวนี้ flaky” จะสื่อกว่าพูดว่า “มันพังแบบมั่ว ๆ”
- connection refused / unexpected EOF
- สองอาการที่ต้องแยกให้ออก · connection refused = ไม่มีใครฟังที่ปลายทางนั้น
(ต่อผิด port · container ยังไม่ขึ้น · หรือที่อยู่นั้นไม่ได้ถูกเปิดไว้ เช่นฝั่ง IPv6 ของ Docker)
· unexpected EOF = ต่อติดแล้วถูกตัดกลางคัน
(ลายเซ็นของการต่อเร็วเกินไป)
เวลาเจอทั้งคู่พร้อมกันจากlocalhostให้ดู port ก่อน — ถ้าเลข port เท่ากัน แปลว่าไม่ได้ต่อผิดที่ - DSN / mapped port
- DSN = ที่อยู่เต็ม ๆ ของ database ในบรรทัดเดียว ·
mapped port = port บนเครื่องคุณที่ Docker จับคู่ให้กับ port ในตัว container
ประโยคที่ใช้ได้จริง: “เอา DSN จาก ConnectionString() นะ อย่า hardcode port”
cleanup ให้ถูกรูปแบบ
❌ แบบที่สไลด์เขียน
ctr, err := tcpostgres.Run(ctx, ...)
if err != nil {
t.Fatal(err) // ออกก่อนถึง defer
}
defer ctr.Terminate(ctx)
พังได้ 2 ทาง — และทั้งสองทางผมรันเจอจริง
✅ แบบที่ปลอดภัย
ctr, err := tcpostgres.Run(ctx, ...)
defer func() {
_ = testcontainers.TerminateContainer(ctr)
}()
if err != nil {
t.Fatal(err)
}
defer วางก่อนเช็ค err · ใช้ตัวที่ nil-safe
เมื่อ Run ล้มเพราะ | ctr เป็น nil ไหม | แบบซ้ายพังยังไง |
|---|---|---|
| image tag ไม่มีจริง | nil | ctr.Terminate บน nil |
| wait strategy timeout | ไม่ nil (container start ไปแล้ว) | t.Fatal ออกก่อน → container ค้าง |
waittimeout_test.go:28: VERBATIM wait-timeout error: run postgres: generic container: start container: started hook: wait until ready: "this log line will never appear" matched 0 times, expected 1
waittimeout_test.go:29: returned container is nil? false
waittimeout_test.go:31: container id = 6116d6c24184482b076d1c70a39046ad846920da81381698a84b52b9070ccca9
waittimeout_test.go:37: TerminateContainer: <nil> (cleaned up)
badimage_test.go:23: VERBATIM bad-image error: run postgres: generic container: create container: Error response from daemon: failed to resolve reference "docker.io/library/postgres:999-doesnotexist": docker.io/library/postgres:999-doesnotexist: not found
badimage_test.go:24: returned container is nil? true
badimage_test.go:38: TerminateContainer(typed nil) = <nil>
testcontainers.TerminateContainer(ctr) รับ nil ได้โดยไม่ panic
คืน <nil> เฉย ๆ — เหตุผลเดียวกับที่บทเรียน 08 สอนว่า
defer ต้องวางให้ครอบทุกทางออกของฟังก์ชัน
7 · dynamic connection string — ห้าม hardcode port
Testcontainers map port 5432 ในตัว container
ไปยัง port ว่างสุ่มบนเครื่องคุณ เพื่อให้เปิดหลายตัวพร้อมกันได้
โดยไม่ชนกัน ผมเขียนโปรแกรมสั้น ๆ เปิด-ปิด container 2 รอบติดกัน:
run #1 boot=1.294s host=localhost mappedPort=32803
run #1 dsn=postgres://testuser:testpass@localhost:32803/testdb?sslmode=disable
run #2 boot=756ms host=localhost mappedPort=32804
run #2 dsn=postgres://testuser:testpass@localhost:32804/testdb?sslmode=disable
ตลอดการตรวจครั้งนี้ผมเห็น port ที่ถูกสุ่มมาแบบนี้:
32769 · 32778 · 32779 · 32780 · 32781 · 32783 · 32792 · 32795 · 32797 · 32800 · 32803 · 32804 · 32808 · 32810 · 32812
— ไม่เคยซ้ำเลยสักครั้ง
ctr, _, _ := testutil.StartPostgres(ctx)
// เห็น dsn จริงแล้ว แต่ขอเขียนเองให้อ่านง่าย
dsn := "postgres://testuser:testpass@localhost:5432/testdb?sslmode=disable"
db, err := sql.Open("pgx", dsn)
err = db.PingContext(ctx)
container ขึ้นสำเร็จเรียบร้อยแล้ว แต่ DSN เขียน port 5432 ตายตัว
— Ping ได้ผลอะไร และอะไรคือกรณีที่อันตรายกว่านั้น?
ผมรันจริงแล้วได้:
hardcoded_test.go:27: actual dsn = postgres://testuser:testpass@localhost:32810/testdb?sslmode=disable
hardcoded_test.go:30: hardcoded dsn = postgres://testuser:testpass@localhost:5432/testdb?sslmode=disable
hardcoded_test.go:39: VERBATIM hardcoded-port error: failed to connect to `user=testuser database=testdb`:
[::1]:5432 (localhost): dial error: dial tcp [::1]:5432: connect: connection refused
127.0.0.1:5432 (localhost): dial error: dial tcp 127.0.0.1:5432: connect: connection refused
คราวนี้ทั้งสองบรรทัดได้ connection refused เหมือนกัน
(ต่างจากเคส "ต่อเร็วเกินไป" ในหัวข้อ 6 ที่ได้ unexpected EOF)
= ไม่มีอะไรฟังอยู่ที่ 5432 จริง ๆ
กรณีที่อันตรายกว่า: ถ้าเครื่อง dev ของคุณมี postgres ของตัวเองรันอยู่ที่ 5432
เทสจะผ่าน — โดยไปสร้างตาราง ลบข้อมูล และ TRUNCATE
ทับ database จริงที่คุณใช้ทำงานอยู่
ใช้ ctr.ConnectionString(ctx, "sslmode=disable")
หรือ ctr.MappedPort(ctx, "5432/tcp") เสมอ
8 · ราคาที่จ่าย — วัดจริงกี่วินาที
ทุกอย่างข้างบนมีราคา และเราต้องรู้ราคาก่อนตัดสินใจ
นี่คือตัวเลขจริงบน Go 1.26.5 darwin/arm64 + colima
โดย image postgres:16-alpine ถูก cache ไว้แล้ว:
| ทำอะไร | ใช้เวลา | เทียบกับ mock |
|---|---|---|
| unit test ที่ mock ด้วย go-sqlmock | 1.251ms | ฐาน |
| boot container ตัวแรกของ process (รวม start Ryuk) | ~1.01 – 1.29s | ช้ากว่า ~820 เท่า |
| boot container ตัวถัด ๆ ไป (Ryuk ขึ้นแล้ว) | ~0.75 – 0.80s | ช้ากว่า ~600 เท่า |
และนี่คือสิ่งที่เกิดขึ้นเมื่อเปิด container ใหม่ทุกเทส — 4 เทสธรรมดา ๆ ที่แต่ละตัวแค่ต่อ DB แล้วเช็คว่าต่อติด:
=== RUN TestFreshA
percontainer_test.go:42: fresh container boot=1.015561625s dsn=postgres://testuser:testpass@localhost:32778/testdb?sslmode=disable
--- PASS: TestFreshA (1.16s)
=== RUN TestFreshB
percontainer_test.go:52: fresh container boot=755.348ms dsn=postgres://testuser:testpass@localhost:32779/testdb?sslmode=disable
--- PASS: TestFreshB (0.90s)
=== RUN TestFreshC
percontainer_test.go:62: fresh container boot=752.309917ms dsn=postgres://testuser:testpass@localhost:32780/testdb?sslmode=disable
--- PASS: TestFreshC (0.88s)
=== RUN TestFreshD
percontainer_test.go:72: fresh container boot=761.385084ms dsn=postgres://testuser:testpass@localhost:32781/testdb?sslmode=disable
--- PASS: TestFreshD (0.91s)
PASS
ok tcdemo/test/percontainer 4.264s
port ต่างกันทุกตัว (32778 · 32779 · 32780 · 32781) —
นี่คือหลักฐานซ้ำอีกทางว่าเป็น container คนละตัวจริง ๆ
ตัวแรกแพงกว่าเพื่อน (1.015s vs ~0.75s) เพราะรวมเวลา start Ryuk ไว้ด้วย
4 เทส = 4.264 วินาที · แล้วถ้าคุณมี 40 เทสล่ะ?
⚠️ ตัวเลขทั้งหมดนี้คือกรณีที่ image ถูก cache แล้ว
รอบแรกสุดบน CI ที่ยังไม่มี image ต้องบวกเวลา pull
postgres:16-alpine ขนาด 411MB เข้าไปด้วย —
ตัวเลขนั้นผมยังไม่ได้วัด อย่าเอาไปอ้าง
ทดสอบความเข้าใจ
1. unit test ที่ mock ด้วย go-sqlmock ตรวจอะไรไม่ได้?
go-sqlmock จับคู่ SQL ด้วย regex แล้วคืน rows ที่เราป้อนให้
— ไม่มี parser ไม่รู้จัก schema
ผมรันฟังก์ชันที่ query WHERE city = ? ทั้งที่ตารางไม่มี column นี้:
mock บอก len=1 first=Ploy PASS
ส่วน postgres จริงบอก ERROR: column "city" does not exist (SQLSTATE 42703)
อีกสามข้อ mock ตรวจได้หมด เพราะเป็นเรื่องของ logic ฝั่งเราล้วน ๆ
ไม่ใช่สัญญากับ database
2. GORM Find() เข้า var users []User แล้วไม่เจอแถวเลย JSON ออกมาเป็นอะไร?
ผลจริงจาก container: GORM Find, var []T, 0 rows → len=0 isNil=false json=[]
และ make([]T,0) ก็ได้เหมือนกันเป๊ะ
— GORM reflect แล้ว allocate ให้ ไม่ว่าคุณส่งอะไรเข้าไป
ตัวเลือกที่ 3 ("[] เพราะ encoding/json แปลง nil slice เป็น array ว่าง")
เดาคำตอบถูกแต่เหตุผลผิด — encoding/json แปลง nil slice เป็น null ต่างหาก
พิสูจน์ได้จากบรรทัด GORM Raw+Scan → isNil=true json=null ในหัวข้อ 3
ตัวเลือกที่ 1 และ 2 ผิดทั้งคู่เพราะเชื่อว่า slice ยังเป็น nil อยู่หลัง Find
— ผลจริงบอกว่า isNil=false ทั้งกรณี var และ make
ตัวเลือกที่ 2 ยังผิดซ้ำตรงที่คิดว่าวิธีประกาศตัวแปรเป็นตัวชี้ขาด
ทั้งที่ตัวชี้ขาดจริงคือทางที่ใช้อ่าน (Find เทียบกับ Raw().Scan())
ปัญหา null จึงเกิดเฉพาะกับ Raw().Scan()
และ database/sql ที่เขียน rows.Next() เอง
3. ทำไม BasicWaitStrategies() ถึงรอ log บรรทัดเดิม 2 ครั้ง?
คอมเมนต์ใน source ของ v0.44.0 เขียนไว้ตรง ๆ:
"we wait for the container to log readiness twice. This is because it will restart itself after the first startup."
log ครั้งแรกออกตอน initdb เสร็จ ตอนนั้น postgres ยังฟังแค่ unix socket
ยังไม่เปิด TCP แล้วมันก็ shutdown แล้ว start ใหม่
ถ้ารอครั้งเดียวแล้วรีบต่อ จะได้ unexpected EOF ใน ~3.6ms
ส่วนเงื่อนไขที่สอง (ForListeningPort) ต่างหากที่แก้เรื่อง
docker proxy บน macOS/Windows
4. เขียน DSN ด้วย port 5432 ตายตัว อันตรายที่สุดตรงไหน?
ถ้าไม่มีอะไรฟังที่ 5432 คุณจะได้ connection refused ทั้ง
[::1] และ 127.0.0.1 — เทสแดง ซึ่งยังโชคดี
กรณีเลวร้ายคือเครื่อง dev มี postgres ของตัวเองรันอยู่ที่ 5432:
เทสผ่าน แต่ไป AutoMigrate และ TRUNCATE
ทับ database ที่คุณใช้ทำงานจริง
ผมเห็น port ที่ถูกสุ่มมา 15 ค่า (32769 – 32812) ไม่ซ้ำเลย
— ใช้ ConnectionString() หรือ MappedPort() เสมอ
ต่อยอดจาก project layout ในบทเรียน 27 —
ใช้ UserRepository ตัวเดิม ไม่ต้องเขียนใหม่
- ติดตั้งให้ครบ 2 module:
go getทั้งtestcontainers-goและ.../modules/postgres
ลองข้ามบรรทัดที่สองดูก่อน แล้วสั่งgo vet ./...(หรือgo test ./...) — ได้ error ว่าอะไร?
หมายเหตุ: อย่าใช้go buildตรวจข้อนี้ ถ้า import อยู่ในไฟล์_test.goเพราะgo buildข้ามไฟล์เทสทั้งหมด มันจะขึ้นสำเร็จเงียบ ๆ ทั้งที่ยังขาด module - เช็ค Docker ก่อน: รัน
docker psให้ได้ ถ้าใช้ colima/Rancher/Podman ให้ตั้งDOCKER_HOST+TESTCONTAINERS_DOCKER_SOCKET_OVERRIDEแล้วยืนยันจาก log บรรทัดResolved Docker Host: - เขียน
StartPostgres+OpenGormตามหัวข้อ 6 แล้วเขียนเทสตัวแรก:Createuser 1 คน แล้วGetByIDกลับมา เช็คว่าIDไม่เป็นศูนย์ (GORM เติม serial PK ให้) - พิสูจน์ dynamic port:
t.Logfค่าctr.MappedPort(ctx, "5432/tcp")ออกมา แล้วรันgo test -count=1สองรอบ — ได้ port เดียวกันไหม? - พิสูจน์ว่ามันจับบั๊กได้จริง: เพิ่ม method
SearchByCityที่ query columncityซึ่งไม่มีในตาราง
เขียนสองเทส: ตัวหนึ่ง mock ตัวหนึ่งยิง container — ทั้งคู่ให้ผลต่างกันไหม เอา error ตรงตัวมาอ่าน - ท้าทาย 1 — nil slice: เขียน method ที่ไม่เจอแถวเลย
สองเวอร์ชัน (
FindกับRaw().Scan()) แล้วt.Logfทั้งlen(),s == nilและjson.Marshalของทั้งคู่ - ท้าทาย 2 — cleanup ที่พัง: เปลี่ยน image tag เป็น
postgres:999-doesnotexistแล้วใช้รูปแบบdefer ctr.Terminate(ctx)ที่วางไว้หลังif err != nil { t.Fatal(err) }→ รันแล้วเกิดอะไร แล้วเปลี่ยนเป็นtestcontainers.TerminateContainerที่วางไว้ก่อน — ต่างกันยังไง - ท้าทาย 3 — จับเวลา: ทำ 4 เทสที่เปิด container ใหม่ทุกตัว
แล้วดู
ok ... Nsท้ายผลลัพธ์ เก็บตัวเลขนี้ไว้ เดี๋ยวบทหน้าเอามาเทียบ
Go ตั้งแต่ 1.16 ใช้ -mod=readonly เป็นค่า default
มันจึงไม่แอบเติม dependency ให้ แต่บอกคุณตรง ๆ ว่าต้องพิมพ์อะไร:
main.go:7:2: no required module provides package github.com/testcontainers/testcontainers-go/modules/postgres; to add it:
go get github.com/testcontainers/testcontainers-go/modules/postgres
(exit 1)
สาเหตุคือ modules/postgres เป็น Go module แยกต่างหาก
มี go.mod ของตัวเอง (เห็นได้จาก go.mod ของคุณ
ที่มีทั้งสองบรรทัด ที่ v0.44.0 เท่ากัน)
— โครงแบบนี้ทำให้คุณลงเฉพาะ module ของ database ที่ใช้จริง ไม่ต้องลากทั้งหมดมา
💬 บทหน้าแก้ปัญหาที่คุณเพิ่งวัดได้จากท้าทาย 3
ตอนนี้เทสของคุณเปิด container ใหม่ทุกตัว — 4 เทส = 4.264 วินาที
และต้นทุนของ "เพิ่มอีก 1 เทส" คือ ~760ms คงที่
ซึ่งแปลว่า 40 เทสก็ราว ๆ ครึ่งนาที
(4.264s + อีก 36 ตัว × ~760ms ≈ 32 วินาที) ทุกครั้งที่คุณกด save
บทเรียน 30 จะเอา container ตัวเดียวมาใช้ร่วมกันทั้ง package ด้วย
sync.Once + TestMain + testify suite
แล้ววัดผลเทียบให้ดูด้วยเทสชุดเดิมเป๊ะ ๆ:
4.264s เหลือ 1.727s และต้นทุนต่อเทสที่เพิ่มเหลือ ~2ms
พร้อมกับวิธีสั่ง go test -short ให้ข้าม Docker ไปเลยตอนที่คุณรีบ
อ้างอิง
- Testcontainers for Go — เอกสารทางการ · บทนี้ใช้
v0.44.0 - Testcontainers — PostgreSQL module —
Run,WithDatabase,ConnectionString,BasicWaitStrategies - PostgreSQL — Error Codes — ที่มาของ
42703,42P01,23505 - pkg.go.dev — encoding/json.Marshal — nil slice ออกมาเป็น
nullตรงนี้ - GORM — Query · DATA-DOG/go-sqlmock