1 · t.Run — แตกเทสใหญ่เป็นเทสย่อย
func TestT_Examples(t *testing.T) {
t.Run("กลุ่ม_A", func(t *testing.T) {
t.Run("ย่อย_1", func(t *testing.T) {
t.Log("path: TestT_Examples/กลุ่ม_A/ย่อย_1")
})
t.Run("ย่อย_2", func(t *testing.T) {
t.Log("path: TestT_Examples/กลุ่ม_A/ย่อย_2")
})
})
}
ชื่อ subtest ต่อกันเป็น path คั่นด้วย /
TestT_Examples
└── กลุ่ม_A
├── ย่อย_1 → TestT_Examples/กลุ่ม_A/ย่อย_1
└── ย่อย_2 → TestT_Examples/กลุ่ม_A/ย่อย_2
path นี้เอาไปใส่ -run ได้ตรง ๆ
go test -run 'TestT_Examples/กลุ่ม_A/ย่อย_1'
- subtest (เทสย่อยที่ซ้อนอยู่ในเทสใหญ่อีกที) แต่ละตัวได้
tตัวใหม่ — พังตัวหนึ่งไม่ลามตัวอื่น - Go แปลงเว้นวรรคในชื่อเป็น
_อัตโนมัติ — ตั้งว่า"ลด 10%"จะแสดงเป็นลด_10% - ซ้อนกี่ชั้นก็ได้ แต่เกิน 2 ชั้นมักอ่านยาก
2 · Error vs Fatal — ต่างกันแค่ "หยุดหรือไม่หยุด"
t.Errorf — ฟ้องแล้วไปต่อ
result := 5
if result != 10 {
t.Errorf("ได้ %d ไม่เท่ากับ 10", result)
}
t.Log("บรรทัดนี้ยังรันต่อ")
t.Fatalf — ฟ้องแล้วหยุดทันที
result := 5
if result != 10 {
t.Fatalf("ได้ %d ไม่เท่ากับ 10", result)
}
t.Log("บรรทัดนี้จะไม่รัน")
ผลรันจริงของทั้งสองแบบ:
=== RUN TestErrorContinues/Error_ทำงานต่อ
behaviour_test.go:13: ได้ 5 ไม่เท่ากับ 10
behaviour_test.go:15: บรรทัดนี้ยังรันต่อ ← มา
--- FAIL: TestErrorContinues/Error_ทำงานต่อ
=== RUN TestFatalStops/Fatal_หยุดทันที
behaviour_test.go:23: ได้ 5 ไม่เท่ากับ 10 -> หยุด
--- FAIL: TestFatalStops/Fatal_หยุดทันที ← ไม่มี Log ตามมา
ตอนไปตรวจบ้านก่อนรับมอบ ช่างจะเดินดูทีละห้องแล้วจดข้อบกพร่องลงใบตรวจ
เจอสีลอกก็จด เจอปลั๊กไม่ติดก็จด แล้วเดินตรวจห้องต่อไป
จบรอบเดียวได้รายการที่ต้องแก้ครบทั้งใบ นี่คือ t.Error
แต่ถ้าไปถึงหน้าบ้านแล้วประตูล็อก เข้าไม่ได้เลย ช่างจะจดว่า
"เข้าบ้านไม่ได้" แล้วกลับ ไม่มีประโยชน์ที่จะยืนเดาว่าห้องน้ำชั้นสองสีลอกหรือเปล่า
นี่คือ t.Fatal — เจอปัญหาที่ทำให้ตรวจต่อไม่ได้ ก็ไม่ต้องตรวจต่อ
ที่ไม่เหมือนกัน: ช่างเลือกเองว่าจะกลับตอนไหน แต่
t.Fatal หยุดแค่เทส (หรือ subtest) ตัวที่เรียกมันเท่านั้น
เทสตัวอื่นในไฟล์ยังรันต่อตามปกติ ไม่ใช่ "เลิกตรวจทั้งหมู่บ้าน"
เลือกยังไง — มีกฎง่าย ๆ ข้อเดียว
ถ้าบรรทัดถัดไปจะพังตามแน่ ๆ ให้ใช้ Fatal ·
นอกนั้นใช้ Error
user, err := GetUser("U-101")
if err != nil {
t.Fatalf("GetUser พัง: %v", err) // ← Fatal เพราะ user เป็น nil
}
if user.Name != "Alice" { // ถ้าใช้ Error ข้างบน บรรทัดนี้ panic!
t.Errorf("Name = %q; want Alice", user.Name)
}
if user.Age != 30 { // ← Error เพราะอยากเห็นทุกจุดที่ผิด
t.Errorf("Age = %d; want 30", user.Age)
}
t.Fatal หยุดได้เฉพาะโค้ดที่รันอยู่ในสายการทำงานหลักของเทสเท่านั้น
วันที่คุณเรียนเรื่องรันงานหลายอย่างพร้อมกัน (concurrency / goroutine)
จะมีกรณีที่โค้ดบางส่วนแยกไปวิ่งอีกสาย ตรงนั้น t.Fatal จะหยุดได้แค่สายนั้น
ไม่หยุดทั้งเทส และต้องใช้ t.Error แทน
ตราบใดที่เทสของคุณยังเขียนเป็นลำดับบนลงล่างตามปกติ
ใช้ t.Fatal ได้สบาย ๆ
สิ่งที่เพิ่งเข้าใจไป วงการเรียกแบบนี้ — ใช้คำให้ตรงแล้วถาม senior หรือถาม AI จะได้คำตอบที่ตรง
- assertion (assert)
- ประโยคในเทสที่ยืนยันว่าผลลัพธ์ต้องเป็นแบบนี้ — ก้อน
if got != want { t.Errorf(...) }ทั้งก้อนคือ assertion หนึ่งข้อ · เวลาคุยให้พูดว่า “assert ว่า err ต้องเป็น nil” แทน “เช็คว่า err ว่าง ๆ” - fail fast
- แนวคิด “เจอปัญหาแล้วหยุดทันที ไม่เดินต่อ” = สิ่งที่
t.Fatalทำ · ประโยคที่เอาไปใช้ได้: “เคสนี้ควร fail fast ไหม หรืออยากเห็นทุกจุดที่ผิดในรอบเดียว”
อย่าสับสนกับธงgo test -failfastที่แปลว่า “พังตัวแรกแล้วไม่เริ่มเทสตัวถัดไป ทั้งชุด” — คนละระดับกัน อันนั้นคุมทั้งชุด อันนี้คุมแค่เทสตัวเดียว - panic
- อาการที่โปรแกรมพังกลางอากาศแล้วหยุดเอง ต่างจาก error ตรงที่ error
คือค่าที่ส่งกลับมาให้เราจัดการ ส่วน panic คือระเบิด · ในเทส ถ้าเผลอใช้ค่าที่เป็น
nilต่อ (เช่นuser.Nameตอนuserเป็น nil) จะได้ panic ซึ่งเป็นเหตุผลที่ตรงนั้นต้องใช้Fatal - nil
- ค่าเริ่มต้น (zero value) ของชนิดที่ยัง “ไม่ได้ชี้ไปไหน” — มี 6 ชนิดที่เป็น nil ได้:
pointer, slice, map, channel, func, interface ·
err != nilอ่านว่า “มี error เกิดขึ้น”
nil ไม่ได้แปลว่าใช้ไม่ได้เสมอไป — เช่น slice ที่เป็น nil ยังlen,range,appendได้ตามปกติ (ผมรันยืนยันแล้ว) ส่วนที่พังคือการอ้างฟิลด์ผ่าน pointer ที่เป็น nil อย่างในข้อ panic ข้างบน
3 · Log / Logf — เห็นเฉพาะตอน -v
func TestLogOnlyWithV(t *testing.T) {
t.Log("เริ่มเทส...")
t.Logf("input = %d, expected = %d", 5, 10)
}
สั่ง go test . เฉย ๆ (ไม่ใส่ -v) จะเห็นข้อความไหม?
ไม่เห็นเลย — ผมรันทดสอบแล้ว ได้แค่ ok
พอใส่ -v ถึงจะโผล่มา:
=== RUN TestLogOnlyWithV/Log_ใช้ดีบัก
behaviour_test.go:40: เริ่มเทส...
behaviour_test.go:41: input = 5, expected = 10
--- PASS: TestLogOnlyWithV/Log_ใช้ดีบัก
ยกเว้นกรณีเดียว: ถ้าเทสนั้น fail จะโชว์ log ให้เสมอ
แม้ไม่ใส่ -v — ออกแบบมาให้ log ที่จำเป็นตอนดีบักโผล่มาเอง
อย่าใช้ fmt.Println ในเทส
เพราะมันพิมพ์ตลอดเวลา รกจอ และไม่มีเลขบรรทัดกำกับ
4 · Skip — ข้ามเทสตามเงื่อนไข
func TestHeavyStuff(t *testing.T) {
if testing.Short() { // true เมื่อสั่ง go test -short
t.Skip("ข้ามเพราะอยู่ใน short mode")
}
t.Log("ถ้าไม่ใช่ short mode จะรันบรรทัดนี้")
}
| คำสั่ง | ผลรันจริง |
|---|---|
go test -v . | --- PASS: + log ปกติ |
go test -v -short . | --- SKIP: พร้อมเหตุผล |
SKIP ไม่ใช่ FAIL — เทสที่ถูกข้าม
ไม่ทำให้ build แดง แต่ยังปรากฏในรายงาน ต่างจากการ comment
โค้ดทิ้งซึ่งจะหายไปเลยและไม่มีใครนึกถึงอีก
ใช้ Skip กับ: เทสที่ต้องต่อเน็ต, เทสที่ช้ามาก,
เทสที่รันได้เฉพาะบน OS บางตัว (runtime.GOOS)
5 · t.Failed() — ถามว่าตอนนี้พังไปหรือยัง
result := 5
if result != 10 {
t.Error("ไม่เท่ากับ 10") // Error ไม่หยุด → มาถึงบรรทัดล่างได้
}
if t.Failed() {
t.Log("เทสนี้ fail แล้ว จะ dump ข้อมูลเพิ่ม")
}
ประโยชน์จริง: เก็บหลักฐานเฉพาะตอนพัง — เช่น dump ทั้ง struct ออกมา (dump = เทข้อมูลทั้งก้อนออกมาดูดิบ ๆ), เซฟ response ลงไฟล์, print SQL ที่รันไป ถ้าทำทุกครั้งจะรกเกินไป แต่ตอนพังคือตอนที่ต้องการมากที่สุด
6 · t.Cleanup — defer ที่ฉลาดกว่า
t.Cleanup(func() { t.Log("cleanup #1 (ลงทะเบียนก่อน)") })
t.Cleanup(func() { t.Log("cleanup #2 (ลงทะเบียนหลัง)") })
t.Log("กำลังเทส...")
t.Error("จงใจ fail")
เทส fail แล้ว cleanup จะรันไหม แล้วรันเรียงยังไง?
รันครบทั้งสองอัน และเรียงแบบ LIFO — ผมรันทดสอบแล้ว:
behaviour_test.go:61: กำลังเทส...
behaviour_test.go:62: จงใจ fail
behaviour_test.go:60: cleanup #2 (ลงทะเบียนหลัง) ← หลังสุดออกก่อน
behaviour_test.go:59: cleanup #1 (ลงทะเบียนก่อน)
--- FAIL: TestCleanupOrder/Cleanup_ทำความสะอาด
เป็นกฎเดียวกับ defer จาก
บทเรียน 08 —
last in, first out
แล้วต่างจาก defer ตรงไหน
defer | t.Cleanup | |
|---|---|---|
| รันเมื่อ | ฟังก์ชันที่เขียน defer จบ | เทส (และ subtest ทั้งหมด) จบ |
| เขียนใน helper แล้วใช้ได้ไหม | ❌ รันทันทีที่ helper จบ | ✅ รอจนเทสจบจริง |
| ลำดับ | LIFO | LIFO |
| รันตอน fail/panic | ✅ | ✅ |
// นี่คือเหตุผลที่ Cleanup มีอยู่ — helper ที่เก็บกวาดตัวเองได้
func newTestDB(t *testing.T) *DB {
t.Helper()
db := openTempDB()
t.Cleanup(func() { db.Close() }) // ← ถ้าใช้ defer จะปิดทันทีที่ออกจาก newTestDB
return db
}
func TestSomething(t *testing.T) {
db := newTestDB(t) // เรียกบรรทัดเดียว ได้ทั้ง setup และ teardown
// ... ใช้ db ได้ตามปกติ ...
}
ที่เคาน์เตอร์เช่ารถ พนักงานยื่นกุญแจให้ แล้วนัดว่า “คืนรถตอนจบทริป”
ลองนึกว่าพนักงานยึดรถคืนทันทีที่คุณเดินพ้นเคาน์เตอร์ — คุณยังไม่ได้ขับไปไหนเลย
ได้กุญแจมาเปล่า ๆ
นั่นคือสิ่งที่เกิดขึ้นถ้าเขียน defer db.Close() ไว้ใน newTestDB:
พอ helper คืนค่ากลับมา defer ก็ทำงานทันที ตัว db ที่เพิ่งได้มาปิดไปแล้ว
t.Cleanup คือการนัดคืนตอนจบทริป — Go จดไว้ก่อน
แล้วค่อยรันตอนเทสจบจริง
ที่ไม่เหมือนกัน: เช่ารถต้องขับไปคืนเอง แต่ t.Cleanup
รันให้อัตโนมัติ แม้เทสจะ fail หรือพังกลางทาง ·
และถ้านัดคืนไว้หลายอย่าง อันที่ลงทะเบียนทีหลังจะรันก่อน (LIFO) ไม่ใช่เรียงตามคิวที่มาก่อน
สิ่งที่เพิ่งเข้าใจไป วงการเรียกแบบนี้ — ใช้คำให้ตรงแล้วถาม senior หรือถาม AI จะได้คำตอบที่ตรง
- setup / teardown
- setup = เตรียมของก่อนเทส (เปิด db, สร้างไฟล์ชั่วคราว) · teardown = เก็บกวาดหลังเทส · ประโยคที่เอาไปใช้ได้: “teardown ของเทสนี้อยู่ตรงไหน ใช้ t.Cleanup หรือ defer”
- fixture
- “ของที่เตรียมไว้ให้เทสใช้” เช่น db ชั่วคราว ไฟล์ตัวอย่าง ข้อมูลตั้งต้น ·
newTestDB(t)คือฟังก์ชันที่สร้าง fixture ให้ - test helper
- ฟังก์ชันช่วยในเทส · ถ้ามันรับ
*testing.Tเข้าไปด้วย จะฟ้อง fail หรือลงทะเบียน cleanup แทนเราได้ และควรใส่t.Helper()เป็นบรรทัดแรก เพื่อให้ error ชี้ไปที่บรรทัดที่เรียกมัน (บท 7 ถัดไป) · ฟังก์ชันช่วยที่ไม่รับtก็ยังเรียกว่า test helper เหมือนกัน - lifetime (ผูกอยู่กับอะไร)
- “ของชิ้นนี้อยู่ถึงเมื่อไหร่ แล้วถูกเก็บกวาดตอนไหน” —
deferผูกกับฟังก์ชัน (รันตอนฟังก์ชันจบ) ส่วนt.Cleanupผูกกับเทส (รันตอนเทสจบ) · เวลาถามให้พูดว่า “ตัวนี้ผูก lifetime กับฟังก์ชันหรือกับเทส”
อย่าเรียกเรื่องนี้ว่า scope — ในวงการ scope หมายถึงขอบเขตที่ชื่อตัวแปร มองเห็นได้ ไม่ใช่จังหวะที่โค้ดถูกรัน ถามด้วยคำนั้นแล้วจะได้คำตอบคนละเรื่อง
7 · t.Helper() — ทำให้ error ชี้ถูกบรรทัด
❌ ไม่ใส่ t.Helper()
func assertEqual(
t *testing.T, got, want int,
) {
if got != want {
t.Errorf("got %d, want %d",
got, want)
}
}
✅ ใส่ t.Helper()
func assertEqual(
t *testing.T, got, want int,
) {
t.Helper() // ← บรรทัดเดียว
if got != want {
t.Errorf("got %d, want %d",
got, want)
}
}
ผลรันจริง — สังเกตเลขบรรทัด:
=== RUN TestHelperDemonstration/ไม่ใส่_Helper
parallel2_test.go:43: got 5, want 10 ← ชี้เข้าไปใน helper (ไร้ประโยชน์)
=== RUN TestHelperDemonstration/ใส่_Helper
parallel2_test.go:59: got 5, want 10 ← ชี้บรรทัดที่ "เรียก" helper (ถูก!)
เทสหนึ่งไฟล์เรียก assertEqual 30 ครั้ง — ถ้าไม่ใส่
t.Helper() ทุก error จะชี้ไปที่บรรทัดเดียวกันหมด
คุณจะไม่รู้เลยว่าเคสไหนพัง
กฎ: helper ที่รับ *testing.T ต้องใส่
t.Helper() เป็นบรรทัดแรกเสมอ
8 · t.Parallel() — และกับดักการวัดเวลาในสไลด์
t.Run(fmt.Sprintf("เคส_%d", n), func(t *testing.T) {
t.Parallel() // ← ต่างที่นี่
time.Sleep(200 * time.Millisecond)
})
t.Parallel() ทำงานแปลกกว่าที่คิด:
มันไม่ได้ "เริ่มรันขนาน" ทันที
สิ่งที่มันทำคือจอดเทสย่อยตัวนั้นไว้ก่อน แล้วคืนการควบคุมกลับให้เทสตัวแม่
พอฟังก์ชันของเทสตัวแม่ return (คือจบทั้งบล็อก ไม่ใช่แค่จบลูป)
Go ถึงปล่อยเทสที่จอดไว้ทั้งหมดวิ่งพร้อมกัน
แปลว่าโค้ดที่คุณเขียนต่อท้ายลูปในบล็อกแม่ ยังรันตอนที่ยังไม่มีเทสย่อยตัวไหนเริ่มเลย
t.Run("PARALLEL", func(t *testing.T) {
start := time.Now()
for _, n := range cases { // 5 เคส เคสละ 200ms
t.Run(..., func(t *testing.T) {
t.Parallel()
time.Sleep(200 * time.Millisecond)
})
}
t.Logf("parallel ใช้เวลา: %v", time.Since(start))
})
บรรทัด Logf จะพิมพ์เวลาประมาณเท่าไหร่?
ประมาณ 140 ไมโครวินาที — ผมรันทดสอบแล้ว ได้
139.959µs
แต่นั่นไม่ใช่เวลาที่ใช้จริง — มันคือเวลาที่ใช้
"จอดเทสทั้ง 5 ตัวไว้ในคิว" เท่านั้น เพราะ t.Parallel()
คืนการควบคุมกลับมาทันที
ตอนที่ Logf ทำงาน ยังไม่มีเทสไหนได้รันเลยสักตัว
วิธีวัดให้ถูก — ต้องวัดนอกบล็อกที่สร้าง parallel subtest:
start := time.Now()
t.Run("group", func(t *testing.T) {
for _, n := range cases {
t.Run(..., func(t *testing.T) {
t.Parallel()
time.Sleep(200 * time.Millisecond)
})
}
})
// ถึงบรรทัดนี้ subtest แบบ parallel จบครบแล้ว
t.Logf("ใช้เวลาจริง: %v", time.Since(start))
ผลรันจริงของทั้งสองแบบ:
· sequential (5 × 200ms) → 1.006 วินาที
· parallel วัดถูกวิธี → 201 มิลลิวินาที (เร็วขึ้น 5 เท่าจริง)
· parallel วัดแบบในสไลด์ → 139µs (ตัวเลขหลอกตา)
เอาเสื้อ 5 ตัวไปฝากร้านซักอบรีด วางบนเคาน์เตอร์ทีละตัว เขียนใบรับ แล้วเดินออกจากร้าน
ทั้งหมดนี้ใช้เวลาไม่ถึงนาที
ถ้าคุณจับเวลาแค่ "ตั้งแต่เข้าร้านจนเดินออก" แล้วสรุปว่า
"ซักเสื้อ 5 ตัวเสร็จในหนึ่งนาที" ก็ผิดเต็ม ๆ เพราะตอนนั้นร้านยังไม่ได้เริ่มซักเลยด้วยซ้ำ
t.Parallel() = วางเสื้อไว้บนเคาน์เตอร์แล้วเดินออก ·
เวลาที่วัดได้ในบล็อกแม่จึงเป็นแค่ "เวลาฝากของ"
เวลาจริงต้องจับจนถึงตอนไปรับเสื้อคืน — ซึ่งก็คือวัดนอกบล็อกแม่
ที่ไม่เหมือนกัน: ร้านซักเริ่มซักทันทีที่รับของ แต่เทสที่ถูก
t.Parallel() จอดไว้ จะยังไม่เริ่มจนกว่าฟังก์ชันของเทสแม่จะ return
(จบทั้งบล็อก ไม่ใช่แค่จบลูป)
· และจำนวนที่วิ่งพร้อมกันจริงถูกจำกัดไว้ด้วยธง -parallel
(ค่าเริ่มต้นเท่ากับ GOMAXPROCS — ปกติคือจำนวน CPU
แต่ถ้ารันใน container จะตามโควตา CPU ที่ตั้งไว้) ไม่ใช่ปล่อยพร้อมกันไม่จำกัด
t.Parallel() ต้องแน่ใจว่า…
เทสไม่แชร์ state กัน — ไม่ใช้ตัวแปร global ร่วมกัน ไม่เขียนไฟล์ชื่อเดียวกัน
ไม่แย่ง port เดียวกัน ถ้าแชร์กันจะกลายเป็น flaky test
ที่บางทีผ่านบางทีไม่ผ่าน ซึ่งแย่กว่าเทสช้าเยอะ
ใช้ go test -race ช่วยจับ และ -count=10
ช่วยยืนยันว่าเสถียรจริง
สิ่งที่เพิ่งเข้าใจไป วงการเรียกแบบนี้ — ใช้คำให้ตรงแล้วถาม senior หรือถาม AI จะได้คำตอบที่ตรง
- shared state
- “ของที่เทสหลายตัวใช้ร่วมกันและเปลี่ยนค่าได้” — ตัวแปรระดับไฟล์ ไฟล์ชื่อเดียวกัน port เดียวกัน · ประโยคที่เอาไปใช้ได้: “เทสชุดนี้แชร์ state กันอยู่หรือเปล่า ถ้าไม่แชร์ผมจะใส่ t.Parallel()”
- flaky test
- เทสที่บางรอบผ่าน บางรอบไม่ผ่านทั้งที่โค้ดไม่ได้เปลี่ยน · บอกทีมว่า “เทสตัวนี้ flaky” จะสื่อกว่าบอกว่า “เทสมันเพี้ยน ๆ” เพราะ flaky มีวิธีไล่จับเป็นเรื่องเป็นราวอยู่แล้ว
- data race
- สถานการณ์ที่โค้ดสองสายแตะตัวแปรตัวเดียวกันพร้อมกัน
โดยมีอย่างน้อยหนึ่งสายเป็นฝ่ายเขียน ผลลัพธ์จึงเดาไม่ได้ ·
เป็นสาเหตุยอดฮิตอันดับหนึ่งของ flaky test ที่มาจาก
t.Parallel() - race detector (
-race) - เครื่องมือของ Go toolchain ไว้ดักจับ data race — ใช้ได้ทั้ง
go test -race,go run -raceและgo build -raceไม่ใช่ของgo testอย่างเดียว
ผ่าน-raceไม่ได้แปลว่าไม่มี race — มันจับได้เฉพาะจังหวะที่ เกิดขึ้นจริงในรอบที่รันนั้น ถ้าจังหวะไม่ชนก็ไม่เห็นอะไร นี่คือเหตุผลที่ต้องรันซ้ำหลายรอบ ด้วย-countอย่างที่กล่องข้างบนบอก
เวลาขอความช่วยเหลือให้พูดว่า “รัน go test -race แล้วได้ WARNING: DATA RACE” แล้วแปะข้อความนั้นมาทั้งก้อน จะได้คำตอบเร็วกว่าเล่าด้วยปากมาก - goroutine
- “สายการทำงานหนึ่งสาย” ที่วิ่งคู่ขนานไปกับสายอื่น — คำนี้จะโผล่ในข้อความของ
-race· ยังไม่ต้องเข้าใจลึกตอนนี้ เรียนเต็ม ๆ ตอนบท concurrency
9 · ทดสอบความจำ
1. t.Errorf ต่างจาก t.Fatalf ตรงไหน?
ทั้งคู่ทำให้เทส fail เหมือนกัน ต่างที่จะเดินต่อหรือไม่ ·
กฎเลือก: ถ้าบรรทัดถัดไปจะพังตามแน่ ๆ (เช่นเพิ่งได้ nil มา)
ใช้ Fatal นอกนั้นใช้ Error
เพื่อจะได้เห็นทุกจุดที่ผิดในรอบเดียว
2. t.Cleanup ดีกว่า defer ตรงไหน?
ทั้งคู่เป็น LIFO และรันตอน fail เหมือนกัน · ต่างที่ ขอบเขต —
defer ผูกกับฟังก์ชันที่เขียนมัน ส่วน Cleanup
ผูกกับเทส → เขียนใน helper อย่าง newTestDB(t)
แล้วมันจะรอปิด db ตอนเทสจบจริง ไม่ใช่ตอนออกจาก helper
3. helper ที่รับ *testing.T ลืมใส่ t.Helper() — ผลคือ?
ทุกอย่างทำงานปกติ แต่เลขบรรทัดใน error ชี้ผิดที่ — ผมรันเทียบแล้ว ไม่ใส่ → ชี้ที่บรรทัด 43 (ในตัว helper) · ใส่ → ชี้ที่บรรทัด 59 (จุดที่เรียก) เรียก helper ตัวเดียว 30 ครั้งแล้วทุก error ชี้ที่เดิม = ดีบักไม่ได้เลย
4. วัด time.Since(start) ในบล็อกที่สร้าง parallel subtest ได้อะไร?
t.Parallel() จอดเทสไว้แล้วคืนการควบคุมทันที
→ ลูปจบเร็วมาก (ผมวัดได้ 139µs) แล้ว Go ค่อยปล่อยเทสทั้งหมด
วิ่งพร้อมกันหลังจากบล็อกแม่จบ
วัดให้ถูกต้องวัดนอกบล็อกนั้น — ได้ 201ms เทียบกับ sequential 1.006s
10 · ลงมือทำ
ภารกิจ: ใช้ t ให้ครบทุกเครื่องมือ
ต่อจากโปรเจกต์ calcdemo ในบทที่แล้ว
- เขียน
TestDiscountที่มี subtest 3 ตัว:"ลด 10%","ไม่ลด","เปอร์เซ็นต์เกิน" - รัน
go test -v .แล้วดูว่า ชื่อ subtest ที่แสดง ต่างจากที่คุณตั้งยังไง — จากนั้น-runเจาะเฉพาะตัวกลาง - ในเคส
"เปอร์เซ็นต์เกิน"ใช้t.Fatalfเมื่อerr == nilแล้วอธิบายให้ตัวเองฟังว่าทำไมต้อง Fatal ไม่ใช่ Error - เขียน helper
assertNoError(t *testing.T, err error)โดยยังไม่ใส่t.Helper()→ ทำให้เทสพัง → จดเลขบรรทัดใน error ไว้ - ใส่
t.Helper()แล้วรันใหม่ → เลขบรรทัดเปลี่ยนเป็นอะไร - เขียน
newTempFile(t *testing.T) stringที่สร้างไฟล์ชั่วคราว เขียนคำว่าhelloลงไป แล้วลงทะเบียนt.Cleanupให้ลบไฟล์ทิ้ง
ในเทส ให้เรียกnewTempFile(t)แล้วos.ReadFile(path)ต่อ — ต้องอ่านได้ว่าhello
จากนั้นเปลี่ยนt.Cleanup(...)เป็นdefer ...แล้วรันใหม่ → ต้องได้no such file or directoryเพราะdeferลบไฟล์ทิ้งตั้งแต่ตอนออกจากnewTempFileยังไม่ทันกลับมาถึงบรรทัดที่จะอ่าน
คำใบ้:os.CreateTemp("", "demo-*.txt") - ท้าทาย 1: ทำการทดลองเรื่อง
t.Parallel()ซ้ำ ทั้งแบบวัดผิด (ในบล็อก) และวัดถูก (นอกบล็อก) — ตัวเลขของคุณตรงกับที่ผมได้ไหม (sequential ~1s · parallel ~200ms · วัดผิด ~100µs) - ท้าทาย 2 — ทำให้เทสสองตัวแย่งของชิ้นเดียวกัน:
ประกาศตัวแปรระดับไฟล์
var counter intแล้วเขียน subtest สองตัวที่ทั้งคู่ เขียนทับค่านั้น (เช่นfor i := 0; i < 1000; i++ { counter++ }) และใส่t.Parallel()ทั้งคู่
รันgo test -race -count=10 .
ผ่านเมื่อ: เห็นบล็อกที่ขึ้นต้นด้วยWARNING: DATA RACEตามด้วยคู่บรรทัดRead at 0x... by goroutineและPrevious write at 0x... by goroutineที่ชี้กลับมาที่ subtest ทั้งสองตัวของคุณ
"data race" คือสถานการณ์ที่โค้ดสองสายแตะตัวแปรตัวเดียวกันพร้อมกัน โดยมีอย่างน้อยหนึ่งสายเป็นฝ่ายเขียน และไม่มีใครคุมคิว ผลลัพธ์จึงเดาไม่ได้ — เรื่องนี้เรียนเต็ม ๆ ตอนสอน concurrency ตอนนี้แค่เห็นว่า-raceจับมันได้ก็พอ
อ่านต่อจากต้นทาง
แหล่งหลักที่แนะนำ:
pkg.go.dev — testing.T
เป็นรายการ method ครบทุกตัวพร้อมคำอธิบายสั้น ๆ
อ่านผ่านทั้งหน้าครั้งเดียวจะรู้ว่ามีอะไรให้ใช้บ้าง ประมาณ 10 นาที
ส่วนเรื่อง t.Parallel() ที่ลึกกว่านี้ (เช่นการใช้ร่วมกับ
t.Cleanup) อยู่ในเอกสารของ
T.Parallel โดยตรง
💬 ติดตรงไหนถามได้เลย — ท้าทายข้อ 2 (data race)
ถ้ารัน -race แล้วไม่เห็น warning ส่งโค้ดมาได้
บางทีต้องออกแบบให้มันชนกันจริง ๆ ถึงจะจับได้
อ้างอิง
- pkg.go.dev — testing.T — method ทั้งหมดของ
t - pkg.go.dev — T.Parallel — กลไกการจอดและปล่อยเทส
- pkg.go.dev — T.Cleanup
- pkg.go.dev — T.Helper
- The Go Blog — Using Subtests and Sub-benchmarks — ที่มาของ
t.Run - Data Race Detector — คู่มือ
-race