บทเรียน 15 · Day 5 · Testing

testing.T: เครื่องมือทั้งหมดที่อยู่ใน t

~16 นาที · ต้องมี: บทเรียน 14, บทเรียน 08 (defer)

t ไม่ใช่แค่ที่ไว้บอกว่า "พัง" — มันคือรีโมตควบคุมการทดสอบ หยุดกลางคัน ข้ามทั้งเทส แตกเป็นเทสย่อย รันขนานกัน เก็บกวาดตอนจบ หรือแม้แต่บอกให้ error ชี้ไปที่บรรทัดที่ถูกต้อง บทนี้ไล่ทีละอย่างพร้อมผลรันจริง

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'

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 ตรงไหน

defert.Cleanup
รันเมื่อฟังก์ชันที่เขียน defer จบเทส (และ subtest ทั้งหมด) จบ
เขียนใน helper แล้วใช้ได้ไหม❌ รันทันทีที่ helper จบ✅ รอจนเทสจบจริง
ลำดับLIFOLIFO
รันตอน 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 ในบทที่แล้ว

  1. เขียน TestDiscount ที่มี subtest 3 ตัว: "ลด 10%", "ไม่ลด", "เปอร์เซ็นต์เกิน"
  2. รัน go test -v . แล้วดูว่า ชื่อ subtest ที่แสดง ต่างจากที่คุณตั้งยังไง — จากนั้น -run เจาะเฉพาะตัวกลาง
  3. ในเคส "เปอร์เซ็นต์เกิน" ใช้ t.Fatalf เมื่อ err == nil แล้วอธิบายให้ตัวเองฟังว่าทำไมต้อง Fatal ไม่ใช่ Error
  4. เขียน helper assertNoError(t *testing.T, err error) โดยยังไม่ใส่ t.Helper() → ทำให้เทสพัง → จดเลขบรรทัดใน error ไว้
  5. ใส่ t.Helper() แล้วรันใหม่ → เลขบรรทัดเปลี่ยนเป็นอะไร
  6. เขียน 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")
  7. ท้าทาย 1: ทำการทดลองเรื่อง t.Parallel() ซ้ำ ทั้งแบบวัดผิด (ในบล็อก) และวัดถูก (นอกบล็อก) — ตัวเลขของคุณตรงกับที่ผมได้ไหม (sequential ~1s · parallel ~200ms · วัดผิด ~100µs)
  8. ท้าทาย 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 ส่งโค้ดมาได้ บางทีต้องออกแบบให้มันชนกันจริง ๆ ถึงจะจับได้

อ้างอิง