บทเรียน 30 · Day 7 · Testcontainers

Testcontainers ใช้จริง — TestMain, suite และ sync.Once

~18 นาที · ต้องมี: บทเรียน 29, บทเรียน 24 (sync), บทเรียน 17 (testify)

บทที่แล้วคุณเปิด container ของจริงได้แล้ว และเห็นว่ามันจับบั๊กที่ mock จับไม่ได้
ปัญหาคือ ถ้าเขียนตามตรง ๆ ทุกเทสจะเปิด container ของตัวเอง — ผมวัดจริง: 4 เทสใช้เวลา 4.264 วินาที ทั้งที่งานจริงในเทสใช้เวลาไม่ถึงเสี้ยววินาที
บทนี้คือวิธีทำให้ 4 เทสเดียวกันเหลือ 1.727 วินาที และทำให้ go test -short ไม่ต้องมี Docker เลย

⚠️ เตรียมของก่อนเริ่ม — เช็ค 4 ข้อนี้ก่อน ไม่งั้นจะไปติดกลางภารกิจ
1. Docker ต้องรันอยู่ เช็คด้วย docker info ถ้าขึ้น Cannot connect to the Docker daemon ให้เปิด Docker Desktop ก่อน
2. รอบแรกต้องต่อเน็ต เพราะต้องดึง image postgres:16-alpine ลงมาก่อน รอบถัด ๆ ไปใช้ของที่ cache ไว้แล้ว
3. เพิ่ม dependency ในโปรเจกต์บทเรียน 27 ของคุณ (โปรเจกต์นั้นยังไม่มี):
go get github.com/testcontainers/testcontainers-go/modules/postgres@v0.44.0
go get github.com/stretchr/testify — package suite อยู่ในโมดูลเดียวกับ assert ที่คุณใช้ในบทเรียน 17 ไม่ต้องลงเพิ่ม
ปิดท้ายด้วย go mod tidy
4. ระหว่างรัน testcontainers จะแอบเปิด container ผู้ช่วยอีกตัวชื่อ Ryuk ขึ้นมาเอง (หัวข้อ 7 อธิบายว่ามันคืออะไร) เห็นใน docker ps ได้ ไม่ต้องตกใจ

1 · ต้นทุนที่แท้จริงของ "container ต่อหนึ่งเทส"

ผมเขียนเทส 4 ตัวที่เหมือนกันเป๊ะ ต่างกันแค่วิธีจัดการ container แบบแรกคือแบบที่ทุกคนเขียนโดยธรรมชาติ — Run ต้นเทส Terminate ท้ายเทส:

=== 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

อ่านตัวเลขให้ออก: ตัวแรกแพงกว่าเพื่อน (1.01s) เพราะครั้งแรกของการรันหนึ่งครั้ง testcontainers จะแอบเปิด container ผู้ช่วยอีกตัวชื่อ Ryuk ขึ้นมาด้วย (Ryuk คือ container ตัวจิ๋วที่คอยตามลบ container ที่เราลืมปิด — หัวข้อ 7 จะพิสูจน์ให้ดูว่ามันทำงานยังไง ตอนนี้รู้แค่ว่ามันกินเวลาของเทสตัวแรกไปราว 0.25 วินาทีก็พอ)
ตัวถัด ๆ ไปไม่ต้องเปิด Ryuk ซ้ำ จึงเหลือประมาณ 0.75 วินาทีเท่ากันหมด
แปลว่าต้นทุนของการเพิ่มเทสอีก 1 ตัว คือ ~760 มิลลิวินาที — โปรเจกต์ที่มี 60 เทสก็คือรอ 45 วินาทีทุกครั้งที่กด save

สังเกต port: 32778 / 32779 / 32780 / 32781 ไม่ซ้ำกันเลยสักตัว — นี่คือ dynamic port (เลข port ที่ Docker สุ่มให้ใหม่ทุกครั้งที่เปิด container) ที่คุณเจอในบทที่แล้ว โผล่มาให้เห็นอีกรอบ
ตลอดการตรวจครั้งนี้ผมเจอ port ที่ถูกสุ่มมา 32769 ถึง 32812 ไม่เคยซ้ำ

2 · sync.Once — เปิดครั้งเดียว ใช้ทั้ง package

ใน บทเรียน 24 คุณเรียนว่า package sync มีไว้ประสานงานระหว่าง goroutine
sync.Once คืออีกตัวในตระกูลเดียวกัน สัญญาของมันสั้นมาก: โค้ดใน Do(f) จะรันแค่ครั้งเดียวตลอดอายุของ process ต่อให้ถูกเรียกพร้อมกันจากหลาย goroutine ก็ตาม — คนที่มาทีหลังจะรอจนตัวแรกทำเสร็จ

🫖 เทียบให้เห็นภาพ — กาต้มน้ำร้อนกลางออฟฟิศ

ออฟฟิศมีกาต้มน้ำร้อนตั้งไว้ตัวเดียว คนแรกที่อยากชงกาแฟตอนเช้าคือคนที่กดต้ม
คนที่สอง สาม สี่ ที่เดินมาทีหลังไม่ต้องต้มใหม่ กดน้ำใส่แก้วแล้วเดินกลับโต๊ะได้เลย
ถ้าบังเอิญเดินมาถึงพร้อมกันตอนน้ำยังไม่เดือด ก็แค่ยืนรอข้าง ๆ ไม่มีใครไปกดต้มซ้ำอีกกา

container ในเทสก็คือกาน้ำใบนี้ · เทสตัวแรกที่ขอใช้ DB เป็นคนจ่ายค่าต้ม (~1 วินาที) ส่วนเทสตัวที่ 2 เป็นต้นไปมารับของที่ร้อนอยู่แล้ว (~2 มิลลิวินาที)

ที่ไม่เหมือนกัน: ถ้ากาน้ำเสีย คนที่สองเดินไปกดต้มเองใหม่ได้ แต่ sync.Once ไม่ยอมรันซ้ำให้เด็ดขาด ต่อให้ครั้งแรกพัง — ตรงกับย่อหน้าใต้โค้ดข้างล่างที่บอกว่าต้องเก็บ sharedErr ไว้เอง

var (
    sharedOnce sync.Once
    sharedCtr  *tcpostgres.PostgresContainer
    sharedDB   *gorm.DB
    sharedDSN  string
    sharedErr  error
    SharedBoot time.Duration
)

// SharedDB คืน DB ที่ใช้ร่วมกันทั้ง process โดย boot container แค่ครั้งเดียว
func SharedDB(t *testing.T) (*gorm.DB, string) {
    t.Helper()  // ทำให้ error ชี้ไปที่เทสที่เรียก ไม่ใช่ชี้มาที่ฟังก์ชันนี้ — หัวข้อ 6 จะพิสูจน์ให้ดู
    sharedOnce.Do(func() {
        ctx := context.Background()
        var d time.Duration
        sharedCtr, d, sharedErr = StartPostgres(ctx)
        SharedBoot = d
        if sharedErr != nil {
            return
        }
        sharedDB, sharedDSN, sharedErr = OpenGorm(ctx, sharedCtr)
        log.Printf("[sync.Once] container booted in %v dsn=%s", d, sharedDSN)
    })
    if sharedErr != nil {
        t.Fatalf("shared container failed: %v", sharedErr)
    }
    return sharedDB, sharedDSN
}

จุดที่คนมักพลาด: ต้องเก็บ sharedErr ไว้นอก Do
เพราะถ้า container พังในการเรียกครั้งแรก Do จะไม่รันซ้ำให้เทสตัวที่สอง — เทสตัวที่สองต้องรู้ว่า "ตัวแรกพังไปแล้ว" จาก error ที่เก็บไว้ ไม่งั้นมันจะได้ nil แล้ว panic แทน

เอาโค้ดเทส 4 ตัวเดิมเป๊ะ ๆ มาเปลี่ยนแค่บรรทัดที่ขอ DB:

=== RUN   TestSharedA
2026/08/18 19:55:05 [sync.Once] container booted in 1.03324475s dsn=postgres://testuser:testpass@localhost:32783/testdb?sslmode=disable
    sharedcontainer_test.go:41: A total=1.056020208s
--- PASS: TestSharedA (1.06s)
=== RUN   TestSharedB
    sharedcontainer_test.go:36: shared dsn=postgres://testuser:testpass@localhost:32783/testdb?sslmode=disable
    sharedcontainer_test.go:41: B total=2.067417ms
--- PASS: TestSharedB (0.00s)
=== RUN   TestSharedC
    sharedcontainer_test.go:41: C total=1.95025ms
--- PASS: TestSharedC (0.00s)
=== RUN   TestSharedD
    sharedcontainer_test.go:41: D total=1.747666ms
--- PASS: TestSharedD (0.00s)
PASS
[TestMain] shared container boot time = 1.03324475s
ok  	tcdemo/test/sharedcontainer	1.727s

หมายเหตุ: เทสทั้งสี่เรียก helper ตัวเดียวกัน (สังเกตว่า total= ชี้ที่ :41 เหมือนกันหมด) ดังนั้นบรรทัด shared dsn= ที่ :36 จริง ๆ พิมพ์ออกมาครบทั้ง A B C D — ในบล็อกนี้ตัดของ A, C, D ออก เพราะซ้ำกับของ B ทุกตัวอักษร (dsn เดียวกัน port เดียวกัน)

📊 หลักฐานว่าเป็น container ตัวเดียวจริง

เทสที่พิมพ์ dsn ออกมา (A กับ B) ได้ port 32783 ตัวเดียวกัน — ถ้ามันเปิด container ใหม่ port ต้องเปลี่ยนทุกตัวเหมือนหัวข้อ 1
ส่วน C กับ D ให้ดูที่เวลาแทน: 1.95ms / 1.75ms — เร็วขนาดนี้แปลว่าไม่ได้ boot อะไรใหม่แน่นอน

container ใหม่ทุกเทส : 4.264s   (boot 1.015 / 0.755 / 0.752 / 0.761)
sync.Once ตัวเดียว   : 1.727s   (boot 1.033 ครั้งเดียว · เทสถัดไป 2.07 / 1.95 / 1.75 ms)

                       เร็วขึ้น 2.47 เท่า ที่ 4 เทส
ต้นทุนต่อเทสที่เพิ่มมา : 760 ms  →  2 ms   (ต่างกัน ~380 เท่า)

ตัวเลข 2.47 เท่ายังดูไม่หวือหวาเพราะมีแค่ 4 เทส — ที่ต้องมองคือคอลัมน์ขวา ยิ่งเพิ่มเทส ความต่างยิ่งถ่างออก เพราะฝั่งซ้ายจ่าย 760ms ทุกครั้ง ฝั่งขวาจ่าย 2ms

เทียบกับ mock ให้เห็นภาพ: unit test ที่ mock ด้วย go-sqlmock ทำงานใน 1.251167ms
นั่นแปลว่า เทสตัวที่ 2 เป็นต้นไปของ container ที่ share กัน (~2ms) เร็วพอ ๆ กับ mock แล้ว — ราคาที่คุณจ่ายจริง ๆ คือ 1 วินาทีแรกครั้งเดียวต่อการรัน go test หนึ่งครั้ง

🗣 ศัพท์ที่เพิ่งเรียนไป

คำพวกนี้คือคำที่วงการใช้เรียกสิ่งที่คุณเพิ่งเข้าใจไป — ใช้ให้ตรงคำ แล้วคำตอบที่ได้จาก senior หรือจาก AI จะตรงขึ้นมาก

sync.Once
“กาน้ำใบเดียวของออฟฟิศ” — โค้ดใน Do ของ Once ตัวนั้น ๆ รันครั้งเดียวจบ · ขอบเขตคือต่อหนึ่งตัวแปร ไม่ใช่ต่อทั้ง process — ประกาศ sync.Once สองตัว ก็รันได้สองครั้งใน process เดียว (คนละตัวแปร = คนละโควตา) · เวลาถามให้พูดว่า “ห่อ boot ไว้ใน sync.Once แล้ว แต่มันยัง boot ซ้ำอยู่” จะสื่อกว่าถามว่า “ทำไม container เปิดหลายตัว”
process
“โปรแกรม 1 ตัวที่กำลังรันอยู่” · สั่ง go test ./test/integration หนึ่งครั้ง = 1 process · พอ process ตาย ตัวแปรที่ sync.Once เก็บไว้ก็หายไปทั้งหมด รอบหน้าเริ่มนับหนึ่งใหม่
ระวัง — go test ./... คือ 1 process ต่อ 1 package ดังนั้น container ที่ share ด้วย sync.Once จะแยกกันคนละใบต่อ package ไม่ใช่ใบเดียวทั้ง repo
lazy / eager
lazy = “ขี้เกียจ” สร้างของตอนมีคนขอใช้จริง · eager = “ใจร้อน” สร้างทิ้งไว้ก่อนเผื่อมีคนใช้ · ประโยคที่เอาไปใช้ได้เลย: “ตรงนี้ boot แบบ eager หรือ lazy” (หัวข้อ 4 จะใช้สองคำนี้เต็ม ๆ)
shared fixture
“ของกลางที่เทสหลายตัวใช้ร่วมกัน” เช่น container ตัวนี้ · ถามว่า “fixture ตัวนี้ share กันทั้ง package หรือแยกต่อเทส” senior จะเข้าใจทันทีว่าคุณถามอะไร
overhead
“ค่าโสหุ้ย” — เวลาที่หมดไปกับการเตรียมของ ไม่ใช่เนื้องานจริง · 760 มิลลิวินาทีต่อเทสในหัวข้อ 1 คือ overhead ล้วน ๆ

3 · TestMain — ประตูเข้าออกของทั้ง package

sync.Once ตอบเรื่อง "เปิดครั้งเดียว" ได้ แต่ยังเหลือคำถามว่า แล้วใครปิด? — defer ในเทสตัวไหนก็ผิดหมด เพราะเทสตัวอื่นยังใช้ container นั้นอยู่
Go มีฟังก์ชันพิเศษชื่อ TestMain ให้ครอบการรันเทสทั้ง package:

🔑 เทียบให้เห็นภาพ — ยามที่ถือกุญแจตึก

ตึกออฟฟิศมีคนถือกุญแจอยู่คนเดียว เช้ามาเปิดประตู เย็นรอจนคนสุดท้ายเดินออกแล้วค่อยล็อก
ระหว่างวันพนักงานเข้าออกกี่คนก็ได้ แต่ไม่มีใครมีสิทธิ์ล็อกประตูตอนบ่ายสอง เพราะยังมีคนอื่นทำงานอยู่ข้างใน

TestMain คือยามคนนั้น · m.Run() คือ “ทั้งวันทำงาน” ที่เทสทุกตัวรันอยู่ข้างใน
นี่คือเหตุผลที่ Terminate ไปแปะไว้ในเทสตัวใดตัวหนึ่งไม่ได้ — เท่ากับให้พนักงานคนหนึ่งล็อกประตูทิ้งเพื่อนที่เหลือไว้ข้างใน

ที่ไม่เหมือนกัน: ยามเดินไล่ปิดไฟทีละห้องก่อนกลับบ้านได้ แต่ os.Exit คือการสับเบรกเกอร์ลงทันที — งานที่ฝากไว้ด้วย defer จะไม่ได้ทำเลยสักอย่าง ตามที่กล่องเตือนข้างล่างจะบอก

func TestMain(m *testing.M) {
    code := m.Run()          // ← เทสทุกตัวใน package รันตรงบรรทัดนี้
    testutil.TerminateShared()  // ← cleanup หลังเทสจบครบแล้ว
    fmt.Printf("[TestMain] shared container boot time = %v\n", testutil.SharedBoot)
    os.Exit(code)             // ← ส่งผลแพ้ชนะออกไปเป็น exit code
}
บรรทัดเกิดอะไรขึ้น
m.Run()รันเทสทุกตัวใน package นี้จนจบ แล้วคืนค่า exit code (0 = ผ่านหมด)
cleanupต้องอยู่หลัง m.Run() เพราะก่อนหน้านั้นยังมีเทสใช้ของอยู่
os.Exit(code)ทำให้ go test และ CI (เครื่องกลางที่ build + รันเทสให้อัตโนมัติเมื่อโค้ดเข้า repo) รู้ว่าผ่านหรือไม่ผ่าน

⚠️ ทำไม cleanup ต้องอยู่ก่อน os.Exit — ห้ามใช้ defer
os.Exit ฆ่า process ทันทีโดยไม่รัน defer ที่ค้างอยู่ (กฎเดียวกับที่คุณเรียนใน บทเรียน 08)
ถ้าเขียน defer testutil.TerminateShared() ไว้บนสุดแล้วปิดท้ายด้วย os.Exit(code) → ฟังก์ชัน cleanup จะไม่ถูกเรียกเลย
นี่คือที่เดียวใน Go ที่ "เขียน defer ไว้ก่อนเพื่อความปลอดภัย" กลายเป็นคำแนะนำที่ผิด

🗣 ศัพท์ที่เพิ่งเรียนไป
TestMain
“ยามถือกุญแจ” ของ test package หนึ่ง ๆ · มีได้แค่ตัวเดียวต่อ package · พูดสั้น ๆ ว่า “ย้าย cleanup ไปไว้ใน TestMain” ได้เลย ไม่ต้องอธิบายยาว
setup / teardown
“เตรียมของก่อนเริ่ม” กับ “เก็บของหลังเลิก” · เป็นคู่คำมาตรฐานที่ใช้กันทุกภาษา ไม่ใช่แค่ Go · ถามได้ว่า “teardown ของเทสชุดนี้อยู่ตรงไหน”
exit code
ตัวเลขที่โปรแกรมทิ้งไว้ตอนจบ บอกว่าสำเร็จหรือพัง (0 = ผ่าน) · เป็นสัญญาณหลักที่ CI ใช้ตัดสินว่าจะขึ้นไฟเขียวหรือไฟแดง (ยังมีเกณฑ์อื่นซ้อนได้อีก เช่น coverage ไม่ถึงเกณฑ์ หรือขั้นตอนอื่นใน pipeline พัง — CI แดงทั้งที่ exit code เป็น 0 จึงเกิดขึ้นได้)
CI (Continuous Integration)
ธรรมเนียมที่ให้เครื่องกลางช่วย build + รันเทสอัตโนมัติเมื่อโค้ดเข้า repo (ตั้งค่าได้ว่าให้รันตอน push หรือตอนเปิด PR) · ตัวเครื่องที่รันจริงเรียกว่า runner เช่น GitHub Actions · ประโยคที่จะได้ยินบ่อยคือ “CI แดง” = เทสไม่ผ่านบนเครื่องกลาง แม้จะผ่านบนเครื่องเราก็ตาม

4 · -short ที่ได้ผลจริง ๆ กับที่ได้ผลแค่ในสไลด์

testing.Short() คืนค่า true เมื่อรันด้วย go test -short ใช้คู่กับ t.Skip เพื่อบอกว่า "เทสนี้หนัก ข้ามไปก่อน":

func TestWrongTable(t *testing.T) {
    if testing.Short() {
        t.Skip("short mode: no docker")
    }
    ...
}

แต่ก่อนจะเชื่อว่ามันช่วยจริง ลองอ่านโค้ดนี้ให้ดี:

// main_test.go — คนเขียนอยากประหยัด จึงเช็ค -short ตั้งแต่ TestMain
func TestMain(m *testing.M) {
    fmt.Println("about to call testing.Short() before m.Run()...")

    if !testing.Short() {          // ← บรรทัดนี้
        bootContainer()
    }

    code := m.Run()
    os.Exit(code)
}

ตรรกะดูสมเหตุสมผลมาก — ถ้าเป็น -short ก็ไม่ต้องเปิด container
รัน go test แล้วได้อะไร?

โปรแกรมตายทันที ยังไม่ทันได้รันเทสสักตัว:

about to call testing.Short() before m.Run()...
panic: testing: Short called before Parse

goroutine 1 [running]:
testing.Short(...)
	/Users/tuwanon/.local/share/mise/installs/go/1.26.5/src/testing/testing.go:693
tcdemo/test/eager.TestMain(0x6e0a31b128c0)
	/private/tmp/.../test/eager/eager_test.go:23 +0x27c
main.main()
	_testmain.go:48 +0x88
FAIL	tcdemo/test/eager	0.304s

flag ของ testing ถูก parse ตอน m.Run() ไม่ใช่ก่อนหน้านั้น
testing.Short() จึงยังอ่านค่าไม่ได้ และทีมงาน Go ตั้งใจให้ panic แทนที่จะคืนค่าผิด ๆ ให้คุณเข้าใจผิดเงียบ ๆ

ทางแก้ตรงไปตรงมาคือเรียก flag.Parse() เองก่อน แล้วค่อยเรียก testing.Short() — และมันใช้ได้จริง

func TestMain(m *testing.M) {
    flag.Parse()
    fmt.Printf("ใน TestMain หลัง flag.Parse(): testing.Short() = %v\n", testing.Short())
    os.Exit(m.Run())
}
$ go test -v
ใน TestMain หลัง flag.Parse(): testing.Short() = false

$ go test -short -v
ใน TestMain หลัง flag.Parse(): testing.Short() = true

เหตุผลที่มันได้ผล: flag ตระกูล -test.* (รวม -test.short) ถูกลงทะเบียนตั้งแต่ก่อน TestMain จะเริ่มทำงาน — ไฟล์ _testmain.go ที่ go test สร้างให้ เรียก testing.MainStart(…) ก่อน แล้ว MainStart เรียก testing.Init() ซึ่งเป็นตัวที่ลงทะเบียน flag ทั้งหมด (ดูได้เองที่ $(go env GOROOT)/src/testing/testing.go บรรทัด 2321)
สิ่งที่ยังไม่เกิดขึ้นตอน TestMain เริ่มคือการ parse ไม่ใช่การลงทะเบียน — m.Run() ต่างหากที่เรียก flag.Parse() ให้ถ้ายังไม่มีใครเรียก

แล้วทำไมบทนี้ยังไม่แนะนำให้ใช้ท่านี้? ไม่ใช่เพราะมันพัง — แต่เพราะ -short เป็นสวิตช์เดียวที่หยาบเกินไป โครงแบบ TestMain ยัง boot container ทุกครั้งที่รัน package นี้แบบไม่ใส่ -short แม้คุณจะสั่ง go test -run TestOneUnitTest ที่ไม่แตะ DB เลยก็ตาม
ทางที่ดีกว่าคือ boot แบบ lazy ใน sync.Once — container จะเกิดเมื่อมีเทสตัวใดตัวหนึ่งขอใช้จริงเท่านั้น ไม่ต้องพึ่ง flag ใด ๆ เลย (หัวข้อถัดไป)

eager vs lazy — ต่างกันตรงไหน

❌ boot ใน TestMain (eager)

func TestMain(m *testing.M) {
    ctr, _ := tcpostgres.Run(...)
    code := m.Run()
    ctr.Terminate(ctx)
    os.Exit(code)
}

เป็นวิธีที่คนอ่านสไลด์เขียนตามโดยธรรมชาติ เพราะ Terminate อยู่ตรงนี้
container ถูกเปิดเสมอ ไม่ว่าจะ -short หรือไม่

✅ boot ใน sync.Once (lazy)

func TestMain(m *testing.M) {
    code := m.Run()
    testutil.TerminateShared()
    os.Exit(code)
}

TerminateShared เช็คเองว่ามี container ให้ปิดไหม
ถ้าทุกเทส skip → ไม่มีใครเรียก Do → ไม่มีการแตะ Docker

ทีนี้เอา TestMain ฝั่ง eager มาแก้ให้รันได้จริงก่อน — เอาบรรทัด testing.Short() ที่ทำให้ panic ออก เหลือแค่ boot container ตรง ๆ แล้วเทียบกับฝั่ง lazy ด้วยคำสั่งเดียวกัน:

// go test -short -v ./test/eager/      ← eager (แก้ให้ไม่เรียก testing.Short() แล้ว)
[eager TestMain] container booted BEFORE m.Run() in 1.047302208s
=== RUN   TestEagerOne
    eager_test.go:45: short mode: skipped, but the container was ALREADY started
--- SKIP: TestEagerOne (0.00s)
PASS
ok  	tcdemo/test/eager	1.673s

// go test -short -v ./test/integration/   ← lazy
--- SKIP: TestSearchByCity_CaughtByRealPostgres (0.00s)
--- SKIP: TestTypoInColumn (0.00s)
--- SKIP: TestWrongTable (0.00s)
--- SKIP: TestNilSliceMatrix (0.00s)
--- SKIP: TestSharedA (0.00s)
--- SKIP: TestSharedB (0.00s)
--- SKIP: TestSharedC (0.00s)
--- SKIP: TestUserRepoSuite (0.00s)
PASS
[TestMain] shared container boot time = 0s
ok  	tcdemo/test/integration	0.353s
✅ บรรทัดที่พิสูจน์ทุกอย่างคือ boot time = 0s

ตัวเลข 0s แปลว่า sharedOnce.Do ไม่เคยถูกเรียกเลย — ไม่ใช่แค่ "เร็วขึ้น" แต่คือไม่แตะ Docker เลยแม้แต่ครั้งเดียว
ผลคือเพื่อนร่วมทีมที่ยังไม่ได้ลง Docker ก็ยังรัน go test -short ./... ได้ และ CI ขั้น lint/unit ก็ไม่ต้องมี docker daemon

5 · testify suite — SetupSuite ไม่ใช่ SetupTest

ใน บทเรียน 17 คุณใช้ testify แค่ฝั่ง assert/require — package suite คืออีกครึ่งที่ยังไม่ได้แตะ มันให้ struct หนึ่งตัวถือ state ร่วม (db, repo, ctx) แล้วมี hook ให้แขวนงานเตรียมของ
มีกติกา 3 ข้อที่ต้องรู้ก่อนอ่านโค้ดข้างล่าง:
1. บรรทัด suite.Suite ที่เขียนลอย ๆ ไม่มีชื่อ field คือการฝัง struct ของ testify เข้ามาในของเรา ผลคือ struct ของคุณได้ method ของ testify ติดมาฟรี ตัวที่ใช้บ่อยที่สุดคือ s.T() ซึ่งคืน *testing.T ของเทสที่กำลังรันอยู่ตอนนั้น เอาไว้ส่งต่อให้ฟังก์ชันที่ต้องการ *testing.T เช่น testutil.SharedDB(s.T())
2. เฉพาะ method ที่ชื่อขึ้นต้นด้วย Test เท่านั้นที่จะถูกรันเป็นเทส ถ้าตั้งชื่อว่า CreateAndGet เฉย ๆ จะไม่มีอะไรเกิดขึ้นและไม่มี error ฟ้องเลย — เทสหายไปเงียบ ๆ แล้วขึ้น PASS
3. testify ไม่ได้ถูก go test เห็นเอง ต้องมีเทสธรรมดา 1 ตัว (func TestUserRepoSuite(t *testing.T)) เรียก suite.Run(t, new(UserRepoSuite)) เป็นประตูเข้า อย่างที่เห็นบรรทัดสุดท้ายของโค้ด

type UserRepoSuite struct {
    suite.Suite
    db   *gorm.DB
    dsn  string
    repo *repository.UserRepository
    ctx  context.Context
}

// SetupSuite — รันครั้งเดียวทั้ง suite: ขอ container ที่ share ไว้
func (s *UserRepoSuite) SetupSuite() {
    if testing.Short() {
        s.T().Skip("skipping integration suite in -short mode")
    }
    s.ctx = context.Background()
    s.db, s.dsn = testutil.SharedDB(s.T())
    s.repo = repository.NewUserRepository(s.db)
}

// SetupTest — รันก่อนทุกเทส: ล้างตาราง ไม่ต้องเปิด container ใหม่
func (s *UserRepoSuite) SetupTest() {
    testutil.TruncateUsers(s.T(), s.db)
}

func TestUserRepoSuite(t *testing.T) {
    suite.Run(t, new(UserRepoSuite))
}
🍲 เทียบให้เห็นภาพ — ร้านชาบู: งานก่อนเปิดร้าน กับ งานระหว่างเปลี่ยนโต๊ะ

ร้านชาบูมีงาน 2 จังหวะที่ไม่เหมือนกันเลย
· ก่อนเปิดร้านตอนเช้า — ติดตั้งเตา ต่อแก๊ส เปิดตู้แช่ ต้มน้ำซุปหม้อใหญ่ ทำครั้งเดียวจบ ทั้งวันไม่ต้องทำซ้ำ
· ตอนลูกค้าโต๊ะหนึ่งลุก ก่อนโต๊ะถัดไปนั่ง — เก็บจาน เช็ดโต๊ะ เปลี่ยนน้ำจิ้ม ทำทุกโต๊ะ และต้องทำ ไม่งั้นลูกค้าใหม่เจอคราบของคนเก่า

SetupSuite = งานก่อนเปิดร้าน (ขอ container, gorm.Open) · SetupTest = งานเช็ดโต๊ะระหว่างลูกค้า (TRUNCATE)
สลับที่กันเมื่อไหร่พังทันที: ต้มซุปใหม่ทุกโต๊ะ = ช้ามาก · เช็ดโต๊ะแค่ตอนเช้า = โต๊ะที่สองเจอของเก่าค้าง

ที่ไม่เหมือนกัน: ลูกค้าร้านชาบูเข้ามาตามคิวที่จองไว้ แต่ testify เรียงเทสตามตัวอักษรของชื่อ method ไม่ใช่ตามลำดับที่คุณเขียนในไฟล์ (กล่องทายผลถัดไปจะพิสูจน์ให้ดู) — จึงห้ามเขียนเทสที่พึ่งพา “โต๊ะก่อนหน้า” เด็ดขาด

SetupSuite (ครั้งเดียว)SetupTest (ทุกเทส)
ควรใส่อะไรของที่แพงและใช้ซ้ำได้ — container, gorm.Open, AutoMigrate, สร้าง repoของที่ถูกและต้องสะอาดใหม่ — TRUNCATE, seed ข้อมูลตั้งต้น
ถ้าใส่ผิดฝั่งเอา TRUNCATE มาไว้ตรงนี้ → เทสตัวที่ 2 เห็นข้อมูลที่ตัวแรกทิ้งไว้เอา container มาไว้ตรงนี้ → กลับไปจ่าย 760ms ต่อเทสเหมือนหัวข้อ 1
คู่ปิดท้ายTearDownSuiteTearDownTest

ลำดับ hook ทั้งหมด ผมรันจริงกับ suite ที่มี 2 เทส ได้แบบนี้:

  SetupSuite                          ←  1 ครั้ง
    SetupTest                         ┐
      BeforeTest(OrderSuite, TestApple)│
        >> body TestApple             │ ← ทุกเทส
      AfterTest(OrderSuite, TestApple) │
    TearDownTest                      ┘
    SetupTest
      BeforeTest(OrderSuite, TestZebra)
        >> body TestZebra
      AfterTest(OrderSuite, TestZebra)
    TearDownTest
  TearDownSuite                       ←  1 ครั้ง

BeforeTest/AfterTest เป็นของแถมที่สไลด์ไม่ได้พูดถึง — มันรับ (suiteName, testName string) เข้ามาด้วย เอาไว้ log หรือตั้งชื่อ schema แยกต่อเทสได้

// ในไฟล์ ผมประกาศ TestZebra ไว้ "ก่อน" TestApple

func (s *OrderSuite) TestZebra() { ... }   // บรรทัดที่ 30
func (s *OrderSuite) TestApple() { ... }   // บรรทัดที่ 40

go test -v จะรันตัวไหนก่อน — และทำไมคำตอบนี้ถึงเปลี่ยนวิธีเขียนเทสของคุณ?

=== RUN   TestOrderSuite
=== RUN   TestOrderSuite/TestApple
=== RUN   TestOrderSuite/TestZebra
--- PASS: TestOrderSuite (0.00s)
    --- PASS: TestOrderSuite/TestApple (0.00s)
    --- PASS: TestOrderSuite/TestZebra (0.00s)

TestApple รันก่อน ทั้งที่ประกาศทีหลัง — testify เก็บ method ผ่าน reflection ซึ่งเรียงตามตัวอักษร ไม่ใช่ลำดับที่คุณเขียน

ผลที่ตามมา: ห้ามเขียนเทสที่พึ่งพาลำดับ เช่น "TestCreate สร้าง user ไว้ให้ TestUpdate ใช้" — วันที่คุณเปลี่ยนชื่อเทสจาก TestUpdate เป็น TestAmend มันจะรันก่อนแล้วพังโดยที่คุณไม่ได้แตะ logic เลย
ทุกเทสต้องเตรียมข้อมูลของตัวเองใน SetupTest เสมอ

🗣 ศัพท์ที่เพิ่งเรียนไป
test suite
“ชุดเทสที่อยู่ก้อนเดียวกันและใช้ของกลางร่วมกัน” — ในที่นี้คือ struct UserRepoSuite · พูดว่า “ย้ายเทสพวกนี้ไปรวมเป็น suite” ได้เลย
hook
“ตะขอแขวนงาน” — จุดที่ตัว framework เปิดช่องไว้ให้เราแทรกโค้ดของตัวเองเข้าไป เช่น SetupTest · ถามว่า “ของนี้ควรอยู่ hook ไหน SetupSuite หรือ SetupTest”
embedding (การฝัง struct)
การเขียนชื่อ type ลอย ๆ ไม่มีชื่อ field อย่างบรรทัด suite.Suite · ผลคือได้ method ของตัวที่ฝังติดมาใช้ฟรี · ประโยคที่ใช้จริง: “embed suite.Suite เข้ามาใน struct”
test isolation
“เทสแต่ละตัวต้องไม่ยุ่งกัน” — ทุกตัวเริ่มจากสถานะสะอาดเท่ากัน · TRUNCATE ใน SetupTest คือสิ่งที่ซื้อ isolation ให้คุณ
reflection
ความสามารถของโปรแกรมในการ “ส่องดูตัวเอง” ตอนรันว่ามี method ชื่ออะไรบ้าง · testify ใช้ท่านี้หา method ที่ขึ้นต้นด้วย Test — และเป็นสาเหตุที่ลำดับกลายเป็นเรียงตามตัวอักษร

พอประกอบครบ นี่คือหน้าตาของการรัน integration suite เต็ม ๆ:

=== RUN   TestSearchByCity_CaughtByRealPostgres
    badsql_test.go:24: VERBATIM real-postgres error: ERROR: column "city" does not exist (SQLSTATE 42703)
--- PASS: TestSearchByCity_CaughtByRealPostgres (1.23s)
...
=== RUN   TestUserRepoSuite
    user_suite_test.go:33: SetupSuite: got DB in 3.292µs  dsn=postgres://testuser:testpass@localhost:32812/testdb?sslmode=disable
=== RUN   TestUserRepoSuite/TestCreateAndGet
=== RUN   TestUserRepoSuite/TestDelete
=== RUN   TestUserRepoSuite/TestDeleteMissingReturnsNotFound
=== RUN   TestUserRepoSuite/TestNilSliceVsEmptySlice
=== RUN   TestUserRepoSuite/TestUniqueIndexIsRealDDL
    user_suite_test.go:85: VERBATIM duplicate-key error: ERROR: duplicate key value violates unique constraint "idx_users_email" (SQLSTATE 23505)
=== RUN   TestUserRepoSuite/TestUpdate
--- PASS: TestUserRepoSuite (0.02s)
    (ตัดบรรทัด --- PASS: TestUserRepoSuite/<ชื่อ> ของ sub-test ทั้ง 6 ตัวออก)
PASS
[TestMain] shared container boot time = 1.203335125s
ok  	tcdemo/test/integration	1.757s

อ่านบรรทัด SetupSuite: got DB in 3.292µs ให้ออก — 3 ไมโครวินาที ไม่ใช่ 1 วินาที
เพราะ TestSearchByCity อยู่ก่อนในลำดับการรัน (เทส top-level ของ Go รันตามลำดับที่เขียนไว้ในไฟล์ โดยไล่ไฟล์ตามชื่อ — ไม่ใช่เรียงตามชื่อเทส กติกาเรียงตามตัวอักษรที่เพิ่งเห็นไปใช้กับ method ใน testify suite เท่านั้น) ไป trigger sync.Once ไปแล้ว
นี่คือหลักฐานว่า sync.Once ทำงานข้ามทั้ง plain test และ testify suite ตราบใดที่อยู่ package เดียวกัน — ทั้งหมด 8 top-level test + 6 sub-test ใช้ container ตัวเดียว

6 · t.Helper() — บรรทัดเดียวที่ทำให้ error ชี้ถูกที่

ฟังก์ชันอย่าง TruncateUsers(t, db) หรือ SharedDB(t) คือ "ฟังก์ชันช่วยเทส" ที่ถูกเรียกจากหลายที่ ถ้ามันฟ้อง error คุณอยากรู้ว่าเทสตัวไหนเรียก ไม่ใช่ว่ามันฟ้องที่บรรทัดไหนในตัวเอง

 7	func mustEqualWithHelper(t *testing.T, got, want string) {
 8		t.Helper()
 9		if got != want {
10			t.Errorf("got %q want %q", got, want)
11		}
12	}
13
14	func mustEqualNoHelper(t *testing.T, got, want string) {
15		if got != want {
16			t.Errorf("got %q want %q", got, want)
17		}
18	}
19
20	func TestWithHelper(t *testing.T) {
21		mustEqualWithHelper(t, "a", "b")
22	}
23
24	func TestWithoutHelper(t *testing.T) {
25		mustEqualNoHelper(t, "a", "b")
26	}
=== RUN   TestWithHelper
    helper_test.go:21: got "a" want "b"      ← จุดที่ "เรียก" ในเทส
--- FAIL: TestWithHelper (0.00s)
=== RUN   TestWithoutHelper
    helper_test.go:16: got "a" want "b"      ← t.Errorf ข้างในฟังก์ชันช่วย
--- FAIL: TestWithoutHelper (0.00s)
FAIL	tcdemo/test/helper	0.729s

⚠️ ทำไมเรื่องนี้ใหญ่กว่าที่คิด — ถ้ามี 10 เทสเรียก helper ตัวเดียวกัน แล้วไม่มี t.Helper() ทุกความล้มเหลวจะชี้ไปที่บรรทัด 16 เหมือนกันหมด
คุณจะรู้แค่ว่า "มีบางอย่างพัง" แต่ไม่รู้ว่าพังจากเทสไหน — ต้องไล่หาเอง ซึ่งเสียเวลาที่สุดเวลา CI แดง
กฎง่าย ๆ: ฟังก์ชันไหนรับ *testing.T เป็น argument ให้ใส่ t.Helper() เป็นบรรทัดแรกเสมอ

7 · cleanup — Ryuk เก็บกวาดให้จริงไหม

คำถามที่ควรถามคือ "ถ้าผมลืม Terminate จะมี container ค้างกิน RAM กับ disk ไหม"
ผมทดลองแล้ว: ปล่อยให้เทสจบโดยไม่ Terminate แล้วนั่งจับเวลาดู docker ps ทุกไม่กี่วินาที

// ลืม Terminate โดย Ryuk เปิดอยู่ (ค่า default)
// running_pg = postgres ที่ยังรันอยู่ (docker ps) · all_pg = นับรวมตัวที่ตายแล้วแต่ยังไม่ถูกลบด้วย (docker ps -a) · running_ryuk = ตัว Ryuk เอง
test process exited at t=0
t+0s   running_pg=1 all_pg=1 running_ryuk=1
t+3s   running_pg=1 all_pg=1 running_ryuk=1
t+9s   running_pg=1 all_pg=1 running_ryuk=1
t+10s  running_pg=0 all_pg=0 running_ryuk=0
t+23s  running_pg=0 all_pg=0 running_ryuk=0

// TESTCONTAINERS_RYUK_DISABLED=true + ลืม Terminate
t+0s:  1 postgres running
t+10s: 1 postgres running
t+25s: 1 postgres running
=== final docker ps ===
e43f38657fa3	postgres:16-alpine	thirsty_curie	Up 28 seconds

Ryuk คือ container ตัวเล็ก ๆ ที่ testcontainers เปิดให้อัตโนมัติ หนึ่งตัวต่อหนึ่ง test process (ไม่ใช่ต่อหนึ่ง container) มันถือ TCP connection (สายที่ต่อค้างไว้ระหว่างสองโปรแกรม พอฝั่งหนึ่งตาย อีกฝั่งรู้ทันที) กับ test process ไว้ พอ process ตายก็เริ่มนับถอยหลัง ประมาณ 10 วินาที แล้วลบทุก container ที่มี label (ป้ายชื่อที่แปะไว้บน container) ของการรันครั้งนั้น รวมถึงลบตัวเองด้วย

🧹 เทียบให้เห็นภาพ — แม่บ้านที่มานั่งรอในร้านตั้งแต่เปิดร้าน

ร้านนี้แม่บ้านเข้ามาพร้อมพนักงานคนแรกตอนเช้า แล้วนั่งรออยู่มุมร้านทั้งวัน ยังไม่ทำอะไร
พอเห็นไฟหน้าร้านดับ (พนักงานกลับกันหมดแล้ว) ก็รออีกสิบวินาที แล้วค่อยลุกไปเก็บของที่ลืมทิ้งไว้
ระหว่างสิบวินาทีนั้นของยังกองอยู่เต็มร้าน · และถ้าวันไหนแม่บ้านลาหยุด ของก็กองข้ามคืน

Ryuk คือแม่บ้านคนนั้น · “เข้ามาตอนเช้า” คือมันถูกเปิดขึ้นพร้อม container ตัวแรก (ไม่ใช่โผล่มาทีหลังตอน process ตาย) · “นั่งรอทั้งวัน” คือถือ TCP connection ค้างไว้เฉย ๆ · “ไฟหน้าร้านดับ” คือสายนั้นขาดเพราะ test process ตาย แล้วมันเริ่มนับถอยหลัง ~10 วินาที · “ลาหยุด” คือมีคนไปตั้ง TESTCONTAINERS_RYUK_DISABLED=true

ที่ไม่เหมือนกัน: แม่บ้านเก็บทุกอย่างที่เห็นในร้าน แต่ Ryuk ลบเฉพาะ container ที่มีป้ายชื่อของการรันครั้งนั้นติดอยู่เท่านั้น — container ที่คุณเปิดเองด้วยมือจาก docker run มันไม่แตะ

คำถามผลที่วัดได้
container ถูกลบจริง หรือแค่ stop?ลบจริง — docker ps -a ก็ได้ all_pg=0
anonymous volume (volume ที่ Docker สร้างให้เองโดยเราไม่ได้ตั้งชื่อ — มันจะได้ชื่อเป็นรหัสสุ่มยาว ๆ แทน) ของ postgres ค้างไหมไม่ค้าง — วัด dangling volume (volume ที่ไม่มี container ไหนใช้แล้ว) ก่อน/หลัง ได้ delta 0 ทั้งกรณี Terminate ปกติ และกรณี Ryuk เก็บ
ปิด Ryuk แล้วลืม Terminateค้างถาวร — วัดถึง 28 วินาทีก็ยัง Up
🧯 สรุปนโยบาย cleanup

เขียน Terminate เสมอ — Ryuk คือ safety net ไม่ใช่แผนหลัก
เหตุผล: 10 วินาทีนั้นเกิดหลัง process ตาย ระหว่างนั้น port ยังถูกจอง RAM ยังถูกใช้ และถ้าใครไปตั้ง TESTCONTAINERS_RYUK_DISABLED=true (ซึ่งมีเหตุผลบางอย่างที่คนทำ) แผนของคุณจะเหลือศูนย์
อีกเรื่อง: ถ้าคุณตามเก็บเองด้วย docker rm -f <id> ต้องใส่ -v ด้วย ไม่งั้น volume ค้าง (ผมพลาดเองมาแล้วตอนตรวจ ต้องตาม docker volume rm)

🗣 ศัพท์ที่เพิ่งเรียนไป
resource leak
“ของที่เปิดไว้แล้วลืมปิด” — container, ไฟล์, connection ก็นับหมด · พูดว่า “เทสนี้ leak container ตอน wait timeout” ตรงกว่าบอกว่า “มีอะไรค้างอยู่ไม่รู้”
resource reaper (Ryuk)
ชื่อที่ testcontainers ใช้เรียกตัวตามเก็บ container ที่ไม่มีใครใช้แล้ว · ใน Go SDK เป็น type ชื่อ Reaper และ container ที่มันเปิดชื่อว่า reaper_<session id> ส่วน env var ทั้งชุดใช้คำว่า ryuk เช่น TESTCONTAINERS_RYUK_DISABLED · บางคนเรียกลอย ๆ ว่า “GC ของ testcontainers” แต่คำที่ค้นเจอในเอกสารคือ reaper — และมันคนละตัวกับ GC ที่เก็บหน่วยความจำในภาษา Go แม้แนวคิดจะคล้ายกัน
safety net
“ตาข่ายรองรับ” — ของสำรองที่ช่วยไว้ตอนพลาด ไม่ใช่แผนหลัก · ประโยคที่ senior ชอบพูด: “อย่าออกแบบโดยพึ่ง safety net”
label
“ป้ายชื่อ” ที่ Docker ให้แปะไว้บน container · Ryuk ใช้ป้ายนี้แยกว่าตัวไหนเป็นของการรันครั้งนี้ ตัวไหนไม่ใช่ธุระของมัน
ป้ายที่ใช้แยกคือ org.testcontainers.sessionId — เปิด container แล้วลอง docker inspect -f '{{json .Config.Labels}}' <id> ดูของจริงได้เลย

8 · กับดักที่เหลือ

8.1 Run คืน error พร้อมกับ container ที่ไม่ใช่ nil

บทที่แล้วสอนรูปแบบ cleanup ที่ถูกไปแล้ว ตรงนี้คือหลักฐานว่าทำไมมันสำคัญ — เมื่อ wait strategy (กติกาที่บอกว่าจะถือว่า container พร้อมใช้เมื่อไหร่ เช่น รอจนเห็น log บรรทัดหนึ่ง) รอจนหมดเวลา Run คืนทั้ง error และ container ที่ start ไปแล้วจริง ๆ:

    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
        context deadline exceeded
    waittimeout_test.go:29: returned container is nil? false
    waittimeout_test.go:31: container id = 6116d6c24184482b076d1c70a39046ad846920da81381698a84b52b9070ccca9
    waittimeout_test.go:37: TerminateContainer: <nil> (cleaned up)

❌ leak ทุกครั้งที่ wait timeout

ctr, err := tcpostgres.Run(ctx, ...)
if err != nil {
    t.Fatal(err)   // ออกก่อนถึง defer
}
defer ctr.Terminate(ctx)

t.Fatal จบเทสทันที บรรทัด defer ไม่เคยถูกรัน → container ที่ start แล้วค้าง

✅ defer ก่อนเช็ค err

ctr, err := tcpostgres.Run(ctx, ...)
defer func() {
    _ = testcontainers.TerminateContainer(ctr)
}()
if err != nil {
    t.Fatal(err)
}

TerminateContainer เป็น nil-safe — ผมทดสอบแล้วส่ง typed nil เข้าไปก็คืน <nil> ไม่ panic

8.2 terminate container ที่ยังไม่ ready = รอฟรี 10 วินาที

    nowait_test.go:53: VERBATIM early-connect error after 3.590916ms: ...
2026/08/18 20:00:27 🐳 Stopping container: 3270a343e678
2026/08/18 20:00:38 ✅ Container stopped: 3270a343e678
2026/08/18 20:00:38 🐳 Terminating container: 3270a343e678
2026/08/18 20:00:38 🚫 Container terminated: 3270a343e678
--- PASS: TestNoWaitStrategy (11.04s)

เทสล้มตั้งแต่มิลลิวินาทีที่ 3.6 แต่ใช้เวลารวม 11 วินาที — ดู timestamp: 20:00:27 สั่ง stop แต่ 20:00:38 ถึงหยุดได้
เพราะ postgres ที่กำลัง initdb อยู่ไม่ตอบ SIGTERM Docker จึงรอครบ stop timeout (10 วินาที) แล้วค่อย SIGKILL
นี่คือเหตุผลที่สองว่าทำไมต้องใส่ BasicWaitStrategies() — ไม่ใช่แค่กันเทส flaky (เดี๋ยวผ่านเดี๋ยวไม่ผ่านทั้งที่โค้ดไม่ได้เปลี่ยน) แต่กัน teardown ช้าด้วย

8.3 sync.Once กับ t.Parallel() — เตือนไว้ก่อน

sync.Once เองปลอดภัยกับหลาย goroutine อยู่แล้วโดยธรรมชาติ (บทเรียน 24)
แต่ TruncateUsers ใน SetupTest ไม่ปลอดภัย — ถ้าเทสสองตัวรันขนานกันบน DB เดียวกัน ตัวหนึ่งจะล้างตารางกลางคันของอีกตัว
ข้อจำกัดข้อนี้ผมยังไม่ได้พิสูจน์ด้วยการรันจริง จึงไม่มี output มาให้ดู — เอาไว้เป็นข้อควรระวังว่าถ้าจะใช้ t.Parallel() ต้องแยก schema (ชุดตารางที่แยกกันอยู่คนละกลุ่มใน DB เดียวกัน) หรือแยกตารางต่อเทส ไม่ใช่ใช้ TRUNCATE ร่วมกัน

ทดสอบความเข้าใจ

1. ทำไม go test -short ของแบบ lazy ถึงได้ boot time = 0s แต่แบบ eager ยังเสีย 1.047s?

TestMain ทำงานก่อนเทสทุกตัว — โค้ดที่ boot container ไว้ตรงนั้นจะรันเสมอ ไม่ว่า flag จะเป็นอะไร (วัดได้ 1.673s ทั้งที่ทุกเทส SKIP)
ส่วน lazy ย้าย boot ไปอยู่ใน sync.Once ที่ถูก trigger จากในตัวเทส เมื่อทุกเทส t.Skip ก็ไม่มีใครเรียก Do → ไม่แตะ Docker เลย (0.353s)
ข้อ 1 ผิดเพราะ -short เป็นแค่ flag ที่คุณต้องเช็คเอง testcontainers ไม่รู้จักมัน · ข้อ 4 ผิดเพราะไม่มี container ค้าง Ryuk เก็บกวาดตั้งแต่ 10 วินาทีหลัง process ตาย

2. AutoMigrate และ TRUNCATE TABLE users ควรอยู่ที่ไหนใน testify suite?

SetupSuite = ของแพงที่ใช้ซ้ำได้ (container, gorm.Open, AutoMigrate) · SetupTest = ของถูกที่ต้องสดใหม่ทุกครั้ง (TRUNCATE, seed)
ข้อ 1 กลับหัวกลับหาง: TRUNCATE ครั้งเดียวต้นเทส แปลว่าเทสตัวที่สองเห็นข้อมูลที่ตัวแรกทิ้งไว้ → เทสพึ่งพาลำดับ ซึ่งอันตรายมากเพราะ testify รันตามตัวอักษร ไม่ใช่ลำดับที่เขียน
ข้อ 3 ทำให้จ่ายค่า DDL ทุกเทสโดยไม่จำเป็น · ข้อ 4 คือไม่มีอะไรล้างตารางเลย

3. เทสจบแล้วลืม Terminate โดยไม่ได้ปิด Ryuk — docker ps จะเห็นอะไร?

ผม poll ดูจริง: t+9s ยังเห็น running_pg=1 · t+10s ได้ running_pg=0 all_pg=0
all_pg มาจาก docker ps -a — ค่า 0 พิสูจน์ว่าถูกลบจริง ไม่ใช่แค่ stop (ข้อ 2 จึงผิด) และ dangling volume วัดได้ delta 0 คือ anonymous volume ถูกเก็บด้วย
ข้อ 3 จะจริงก็ต่อเมื่อตั้ง TESTCONTAINERS_RYUK_DISABLED=true ซึ่งผมทดสอบแยกแล้วได้ Up 28 seconds
อย่างไรก็ตาม ยังต้องเขียน Terminate เสมอ — Ryuk คือ safety net ไม่ใช่แผนหลัก

4. ทำไมต้องวาง defer cleanup ไว้ก่อนบรรทัดเช็ค err ของ Run?

ผมวัดจริง: ตอน wait strategy timeout ได้ returned container is nil? false พร้อม container id ยาว ๆ — แปลว่าของถูกสร้างและ start ไปแล้ว แค่ wait ไม่ผ่าน
t.Fatal(err) จบเทสทันทีที่บรรทัดนั้น defer ที่เขียนไว้ข้างล่างไม่เคยถูกลงทะเบียน → container ค้าง
และต้องใช้ testcontainers.TerminateContainer(ctr) ไม่ใช่ ctr.Terminate(ctx) เพราะกรณี image tag ผิดจะได้ returned container is nil? true — ตัวแรก nil-safe ตัวหลังไม่

🛠️ ภารกิจ — เอา repository ของคุณเองมาเทสให้ครบวงจร

ใช้ repository จาก บทเรียน 27 (internal/repository/ ที่คุณเขียนไว้) — บทนี้คือการทำให้มันมี integration test จริง ไม่ใช่ demo ใน sandbox ของผม

  1. ตั้ง helper ก่อน: สร้าง internal/testutil/container.go ที่มี StartPostgres, OpenGorm, SharedDB (ห่อด้วย sync.Once), TerminateShared, TruncateUsers
    ลายเซ็นที่ควรได้ (ดูวิธีใช้จากโค้ดหัวข้อ 2 และ 5 ประกอบ):
    func StartPostgres(ctx context.Context) (*tcpostgres.PostgresContainer, time.Duration, error)
    func OpenGorm(ctx context.Context, ctr *tcpostgres.PostgresContainer) (*gorm.DB, string, error)
    func SharedDB(t *testing.T) (*gorm.DB, string)
    func TerminateShared() — ต้องเช็คก่อนว่า sharedCtr == nil หรือเปล่า (กรณีทุกเทส skip จะไม่มี container ให้ปิด) แล้วค่อยเรียก testcontainers.TerminateContainer(sharedCtr)
    func TruncateUsers(t *testing.T, db *gorm.DB) — ยิง db.Exec("TRUNCATE TABLE users RESTART IDENTITY CASCADE") แล้ว require.NoError
    ทุกฟังก์ชันที่รับ *testing.T ต้องมี t.Helper() บรรทัดแรก
  2. เขียน TestMain ในไฟล์ main_test.go ของ package เทส: m.Run() → TerminateShared() → พิมพ์เวลา boot → os.Exit(code)
    ห้ามใช้ defer สำหรับ cleanup ตรงนี้ — อธิบายในคอมเมนต์ได้ว่าทำไม
  3. ย้าย CRUD ทั้งหมดของ repository มาเป็น testify suite: SetupSuite ขอ DB · SetupTest TRUNCATE · เขียนอย่างน้อย TestCreateAndGet, TestUpdate, TestDelete, TestDeleteMissingReturnsNotFound
  4. เทสสิ่งที่ mock ทำไม่ได้: ใส่ uniqueIndex บน field ที่ควรไม่ซ้ำ แล้วเขียนเทสที่ insert ซ้ำ — require.Error แล้ว t.Logf ตัว error ออกมาดู
    คุณควรเห็น SQLSTATE 23505 และชื่อ index ที่ GORM ตั้งให้เอง
  5. วัดว่าคุ้มไหม: ทำ package เทสอีกอันที่เปิด container ใหม่ทุกเทส (โค้ดเทสเหมือนกันเป๊ะ) แล้วเทียบเวลาสองฝั่ง — ของคุณเร็วขึ้นกี่เท่า? (ของผม 4.264s → 1.727s)
  6. ทำให้ -short ได้ผลจริง: ใส่ testing.Short() + t.Skip ครบทุกเทส แล้วรัน go test -short -v ./...
    ต้องเห็นเวลา boot เป็น 0s เท่านั้น ถ้าไม่ใช่ แปลว่าคุณ boot ผิดที่
  7. ท้าทาย 1 — พิสูจน์ t.Helper() ด้วยตัวเอง: ลบ t.Helper() ออกจาก TruncateUsers แล้วทำให้มันพังโดยตั้งใจ (เช่นเปลี่ยนชื่อตารางเป็น userz) — เลขบรรทัดที่ฟ้องเปลี่ยนไปไหม?
  8. ท้าทาย 2 — จับ leak ให้เห็น: ทำให้ Run พังโดยตั้งใจ ด้วยการใส่ wait strategy ที่รอ log ที่ไม่มีวันโผล่: testcontainers.WithWaitStrategy(wait.ForLog("this log line will never appear").WithStartupTimeout(10*time.Second))
    แล้วเขียนแบบผิดตามกล่องแดงในหัวข้อ 8.1 คือ t.Fatal(err) ไว้ก่อนบรรทัด defer
    รันด้วย go test -run TestLeak -v ./test/leak/ จากนั้นเปิดอีกเทอร์มินัลดู docker ps ทันทีที่เทสจบ แล้วดูซ้ำอีกครั้งตอนวินาทีที่ 15
    ถูกแล้วคือ: ครั้งแรกยังเห็น postgres:16-alpine สถานะ Up ค้างอยู่ (นี่คือตัวที่ leak) · ครั้งที่สองหายไปแล้วเพราะ Ryuk ตามเก็บให้ — ซึ่งแปลว่าถ้าใครไปปิด Ryuk มันจะค้างถาวร

ถ้าทำถูก go test -short จะได้หน้าตาแบบนี้ — สังเกตสองบรรทัดล่างสุด:

--- SKIP: TestSearchByCity_CaughtByRealPostgres (0.00s)
--- SKIP: TestTypoInColumn (0.00s)
--- SKIP: TestWrongTable (0.00s)
--- SKIP: TestNilSliceMatrix (0.00s)
--- SKIP: TestSharedA (0.00s)
--- SKIP: TestSharedB (0.00s)
--- SKIP: TestSharedC (0.00s)
--- SKIP: TestUserRepoSuite (0.00s)
PASS
[TestMain] shared container boot time = 0s
ok  	tcdemo/test/integration	0.353s

ถ้าคุณได้ตัวเลข boot ที่ไม่ใช่ 0s แปลว่า container ถูกเปิดไปแล้วทั้งที่ทุกเทส skip — ให้ไปดูว่ามีใครเรียก StartPostgres นอก sync.Once หรือเปล่า โดยเฉพาะใน TestMain เอง ผลของแบบนั้นคือ:

[eager TestMain] container booted BEFORE m.Run() in 1.047302208s
--- SKIP: TestEagerOne (0.00s)
ok  	tcdemo/test/eager	1.673s   ← จ่าย 1 วินาทีฟรี ๆ และยังต้องมี Docker

💬 ข้อ 5 คือข้อที่ผมอยากเห็นตัวเลขของคุณที่สุด — เพราะมันเปลี่ยน integration test จาก "ของหรูที่รันสัปดาห์ละครั้ง" เป็น "ของที่รันทุกครั้งที่กด save" ซึ่งเป็นคนละโปรเจกต์กันเลย
ถ้าเทสของคุณยังช้ากว่าที่คิด ส่งผล go test -v มาให้ดูได้ — ตัวเลข boot= ของแต่ละเทสจะบอกเองว่าเปิดกี่ตัว
บทถัดไป: schema ที่ AutoMigrate สร้างให้ ใช้ขึ้น production ไม่ได้ — แล้วต้องใช้อะไร

อ้างอิง