บทเรียน 16 · Day 5 · Test Convention

Table-Driven Test: เพิ่มเคสใหม่ด้วยการพิมพ์บรรทัดเดียว

~13 นาที · ต้องมี: บทเรียน 15, บทเรียน 13 (struct)

นี่คือสำนวนการเขียนเทสที่เป็นมาตรฐานของ Go — เปิดซอร์สของ standard library ที่ไหนก็เจอ แนวคิดเดียว: แยก "ข้อมูลที่จะทดสอบ" ออกจาก "ตรรกะการทดสอบ" ผลคือเพิ่มเคสใหม่ = พิมพ์อีกบรรทัดในตาราง ไม่ใช่ copy โค้ดทั้งก้อน

1 · ปัญหาที่มันแก้

สมมติ CalculateDiscount(price, percent) ต้องเทส 5 เคส ถ้าเขียนแบบตรงไปตรงมาจะได้แบบนี้:

func TestCalculateDiscount_HappyPath(t *testing.T) {
    result, err := CalculateDiscount(100, 10)
    if err != nil {
        t.Errorf("did not expect error, got %v", err)
    }
    if result != 90 {
        t.Errorf("CalculateDiscount(100, 10) = %v; want 90", result)
    }
}

func TestCalculateDiscount_NoDiscount(t *testing.T) {
    result, err := CalculateDiscount(100, 0)
    if err != nil {              // ← copy มาทั้งดุ้น
        t.Errorf("did not expect error, got %v", err)
    }
    if result != 100 {           // ← แก้แค่ตัวเลข
        t.Errorf("CalculateDiscount(100, 0) = %v; want 100", result)
    }
}

// ... อีก 3 ฟังก์ชัน หน้าตาเหมือนกันหมด

2 · รูปแบบ Table-Driven

วิธีแก้ไม่ซับซ้อนเลย: เอาตัวเลขทุกเคสไปกองไว้ในตารางก่อน แล้วเขียนขั้นตอนการตรวจไว้ครั้งเดียว จากนั้นให้โปรแกรมวนอ่านตารางทีละแถว แล้วส่งเข้าขั้นตอนเดิมนั้น — อยากเพิ่มเคสก็แค่เติมแถว

🧾 เทียบให้เห็นภาพ — สมุดจดออเดอร์ร้านอาหาร

ร้านอาหารมีสมุดที่ตีตารางไว้แล้ว หัวตารางเขียนครั้งเดียวว่า โต๊ะ · เมนู · จำนวน · หมายเหตุ
ลูกค้ามาใหม่ = จดเพิ่มอีกหนึ่งแถว ไม่ต้องตีตารางใหม่ทั้งหน้า
แล้วพ่อครัวก็ทำทีละแถว ด้วยขั้นตอนเดิมทุกแถว

· ก้อน struct { name; price; … } = หัวตาราง — บอกว่าหนึ่งแถวมีช่องอะไรบ้าง
· แต่ละบรรทัดในปีกกา { } = ออเดอร์หนึ่งแถว
· ลูป for … range tests = พ่อครัวที่ทำตามขั้นตอนเดิมให้ทุกแถว

ที่ไม่เหมือนกัน: สมุดจดออเดอร์เขียนข้ามช่องก็ยังอ่านรู้เรื่อง แต่ใน Go แถวที่เขียนแบบเรียงตำแหน่ง ต้องใส่ให้ครบทุกช่องและชนิดข้อมูลตรงกับหัวตาราง ขาดช่องเดียวคือ compile ไม่ผ่านตั้งแต่ยังไม่ได้รัน (ถ้าเขียนแบบระบุชื่อช่อง {name: …, price: …} จะข้ามช่องได้ — ดูกล่องถัดไป) · และหัวตารางนี้ใช้ได้เฉพาะในเทสนี้ เพราะประกาศไว้ตรงนั้นที่เดียว

อ่านไวยากรณ์ก้อนนี้ก่อน

บรรทัด tests := []struct { ... }{ ... } ทำสองอย่างในคราวเดียว:

tests := []struct {      // ← ส่วนที่ 1: ประกาศ "หน้าตาของหนึ่งแถว" ตรงนี้เลย
    name    string       //    (struct ที่ไม่ต้องตั้งชื่อ type เพราะใช้ที่นี่ที่เดียว)
    price   int
    percent int
    want    int
    wantErr bool
}{                       // ← ส่วนที่ 2: เปิดปีกกาแล้วใส่แถวข้อมูลต่อได้เลย
    {"ลด 10%", 100, 10, 90, false},
}

ในแต่ละแถว ค่าเรียงตามลำดับ field ที่ประกาศไว้ข้างบนเป๊ะ ๆ — "ลด 10%"→name, 100→price, 10→percent, 90→want, false→wantErr · ถ้ากลัวสลับ เขียนแบบระบุชื่อได้: {name: "ลด 10%", price: 100, percent: 10, want: 90} (field ที่ไม่ใส่จะได้ค่าศูนย์ของ type นั้น)

func TestCalculateDiscount(t *testing.T) {
    // ── ส่วนที่ 1: ตาราง (ข้อมูล) ──
    tests := []struct {
        name    string
        price   int
        percent int
        want    int
        wantErr bool
    }{
        {"ลด 10%",              100,  10,  90,  false},
        {"ไม่ลด",                100,   0, 100,  false},
        {"ลดเต็ม",               100, 100,   0,  false},
        {"เปอร์เซ็นต์ติดลบ",      100,  -5,   0,   true},
        {"เปอร์เซ็นต์เกิน 100",   100, 101,   0,   true},
    }

    // ── ส่วนที่ 2: ตรรกะ (เขียนครั้งเดียว) ──
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := CalculateDiscount(tt.price, tt.percent)

            if (err != nil) != tt.wantErr {
                t.Fatalf("CalculateDiscount(%d, %d) err = %v; wantErr %v",
                    tt.price, tt.percent, err, tt.wantErr)
            }
            if got != tt.want {
                t.Errorf("CalculateDiscount(%d, %d) = %d; want %d",
                    tt.price, tt.percent, got, tt.want)
            }
        })
    }
}
สิ่งที่เพิ่งได้มา

เพิ่มเคสที่ 6 = พิมพ์อีกหนึ่งบรรทัด · ตารางกลายเป็นเอกสารที่อ่านแล้วรู้ทันทีว่าครอบคลุมอะไร · แก้ตรรกะที่เดียวมีผลทุกเคส · และเพราะห่อด้วย t.Run(tt.name, ...) — ทุกเคสได้เป็น subtest ที่ -run เจาะได้

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

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

table-driven test
ชื่อของสำนวนนี้ทั้งก้อน · พูดว่า “เทสตัวนี้เขียนแบบ table-driven ไหม” จะสื่อกว่าพูดว่า “เขียนเทสแบบมีตาราง” · หนึ่งแถวในตารางเรียกว่า test case — ประโยคที่ใช้จริง: “เพิ่ม test case ค่าขอบให้หน่อย”
anonymous struct
struct ที่ไม่ตั้งชื่อ type เพราะประกาศแล้วใช้ตรงนั้นที่เดียว — คือ “หัวตาราง” ในเทสนี้ · ถ้าเจอโค้ดแบบนี้แล้วงง ให้ถามว่า “ตรงนี้เป็น anonymous struct ใช่ไหม”
subtest
เทสย่อยที่เกิดจาก t.Run(ชื่อ, …) · แต่ละแถวในตารางกลายเป็น subtest หนึ่งตัว ทำให้ผลรันบอกชื่อเคสได้ · พูดว่า “แตกเป็น subtest ด้วย t.Run”

3 · ผลรันจริง

// ในตารางตั้งชื่อเคสไว้ว่า
{"ลด 10%", 100, 10, 90, false},

รัน go test -v แล้ว Go จะแสดงชื่อ subtest ว่าอะไร — และถ้าอยาก -run เจาะเคสนี้ต้องพิมพ์ยังไง?

แสดงเป็น TestCalculateDiscount/ลด_10% — ผมรันทดสอบแล้ว

Go แปลงเว้นวรรคในชื่อเป็น _ อัตโนมัติ เพราะชื่อเทสต้องเอาไปใช้ในคำสั่ง shell ได้

ส่วน -run — ผมทดสอบทั้งสองแบบแล้ว ใช้ได้ทั้งคู่ เพราะ Go แปลงเว้นวรรคในตัว pattern ให้ด้วย:

go test -v -run 'TestCalculateDiscount/ลด_10' .    ✓ เจอ
go test -v -run 'TestCalculateDiscount/ลด 10' .    ✓ เจอเหมือนกัน

แต่ให้ชินกับการอ่านจากผลลัพธ์ — เพราะ -v จะพิมพ์ชื่อแบบ _ เสมอ ก็อปวางจากตรงนั้นปลอดภัยที่สุด

$ go test -v -run TestCalculateDiscount .
=== RUN   TestCalculateDiscount
=== RUN   TestCalculateDiscount/ลด_10%
=== RUN   TestCalculateDiscount/ไม่ลด
=== RUN   TestCalculateDiscount/ลดเต็ม
=== RUN   TestCalculateDiscount/เปอร์เซ็นต์ติดลบ
=== RUN   TestCalculateDiscount/เปอร์เซ็นต์เกิน_100
--- PASS: TestCalculateDiscount (0.00s)
    --- PASS: TestCalculateDiscount/ลด_10% (0.00s)
    ...

$ go test -v -run 'TestCalculateDiscount/ลด_10' .   # เจาะเคสเดียว

4 · องค์ประกอบที่ทำให้มันดี

4.1 field name — สำคัญกว่าที่คิด

❌ ไม่มี name

tests := []struct{
    price, percent, want int
}{
    {100, 10, 90},
    {100,  0, 100},
}

for i, tt := range tests {
    t.Run(fmt.Sprint(i), ...)
}

// เวลาพัง: "case 1 failed"
// แล้ว case 1 คือเคสอะไร?

✅ มี name

tests := []struct{
    name                 string
    price, percent, want int
}{
    {"ลด 10%", 100, 10, 90},
    {"ไม่ลด",  100,  0, 100},
}

for _, tt := range tests {
    t.Run(tt.name, ...)
}

// เวลาพัง: "ลด_10% failed"
// อ่านออกทันที

4.2 ข้อความ error ต้องบอกครบ 3 อย่าง

t.Errorf("CalculateDiscount(%d, %d) = %d; want %d",
    tt.price, tt.percent,  // 1. input อะไร
    got,                   // 2. ได้อะไรมา
    tt.want)               // 3. อยากได้อะไร

รูปแบบ f(input) = got; want X เป็นสำนวนมาตรฐานของ Go ที่ใช้ทั่ว standard library — พอทุกคนเขียนแบบเดียวกัน คนอ่าน log ก็ไม่ต้องตีความใหม่ทุกครั้ง
เทียบกับ t.Error("ผิด") ซึ่งบอกอะไรไม่ได้เลยนอกจากว่าพัง

4.3 wantErr bool — เทสเส้นทางที่พังด้วย

if (err != nil) != tt.wantErr {
    t.Fatalf("err = %v; wantErr %v", err, tt.wantErr)
}

แปลทีละชิ้น: err != nil คือคำถาม "ตอนนี้มี error จริงไหม" ได้คำตอบเป็น true/false · tt.wantErr คือ "เราคาดว่าจะมีไหม" ก็ true/false เหมือนกัน · เอาสองอันมา != กัน = ถาม "สองคำตอบนี้ขัดกันหรือเปล่า" ขัดกันเมื่อไหร่ = เทสพัง — ครอบคลุมทั้งสองทิศทางในบรรทัดเดียว

🌧 เทียบให้เห็นภาพ — พยากรณ์อากาศกับฝนจริง

ตอนเช้าเราดูพยากรณ์ว่า “วันนี้ฝนตกไหม” ได้คำตอบ ตก/ไม่ตก
ตกเย็นเดินออกจากบ้าน ก็รู้ว่า “จริง ๆ ฝนตกไหม” ตก/ไม่ตก เหมือนกัน
สิ่งที่บอกว่าพยากรณ์แม่นหรือพลาด ไม่ใช่ว่าฝนตกหรือไม่ตก แต่คือ “สองอันนี้ตรงกันหรือเปล่า”

· tt.wantErr = คำพยากรณ์ — เราคาดไว้ว่าเคสนี้ควรพังไหม
· err != nil = ฝนจริง — พอรันแล้วมันพังจริงไหม
· != = ถามว่าสองอันขัดกันไหม · ขัดกัน (ได้ true) = พยากรณ์พลาด = เทส fail

ที่ไม่เหมือนกัน: การเทียบแบบนี้บอกได้แค่ “ตกหรือไม่ตก” บอกไม่ได้ว่าฝนแบบไหน — แปลเป็นโค้ดคือ wantErr bool รู้แค่ว่ามี error หรือไม่มี ไม่รู้ว่าเป็น error ตัวไหน ซึ่งเป็นเหตุผลที่ย่อหน้าถัดไปเปลี่ยนไปใช้ errors.Is

มี error จริง?คาดว่ามี?!= ได้ผล
falsefalsefalse✅ ผ่าน — ไม่คาดว่าพัง และไม่พังจริง
truetruefalse✅ ผ่าน — คาดว่าพัง และพังจริง
truefalsetrue💥 fail — ไม่ควรพังแต่ดันพัง
falsetruetrue💥 fail — ควรพังแต่ดันปล่อยผ่าน

ถ้าอยากเช็คให้แน่นกว่านั้น เปลี่ยนเป็น wantErr error แล้วใช้ errors.Is จาก บทเรียน 12:

tests := []struct{
    name    string
    percent int
    wantErr error       // ← เก็บ sentinel ไปเลย
}{
    {"ปกติ",      10, nil},
    {"ติดลบ",     -5, ErrPercentOutOfRange},
}

if !errors.Is(err, tt.wantErr) {
    t.Fatalf("err = %v; want %v", err, tt.wantErr)
}

errors.Is(nil, nil) คืน true → เขียนแบบนี้ครอบคลุมทั้งเคสที่มี error และไม่มี error ด้วยโค้ดชุดเดียว แน่นกว่า wantErr bool เพราะบอกได้ว่าต้องเป็น error ตัวไหน

🗣 ศัพท์ที่เพิ่งเรียนไป
got / want
got = ค่าที่ได้มาจริง · want = ค่าที่เราอยากได้ · เป็นคำที่ Go ใช้ทั่ว standard library · เวลาเล่าให้คนอื่นฟังให้พูดว่า “got เป็น 0 แต่ want 90” จะสื่อกว่าพูดว่า “ค่ามันไม่ตรง”
happy path / sad path
เส้นทางที่ทุกอย่างเรียบร้อย / เส้นทางที่มีอะไรพัง · ประโยคที่ได้ยินบ่อยใน code review: “เทสนี้มีแต่ happy path นะ ขาด sad path” = เทสแต่เคสที่สำเร็จ ไม่ได้เทสเคสที่ควรพัง
sentinel error · errors.Is
sentinel error = ตัวแปร error ที่ประกาศไว้ตายตัว เอาไว้ให้คนอื่นเทียบได้ว่า “พังด้วยเรื่องนี้ใช่ไหม” เช่น ErrPercentOutOfRange · ส่วน errors.Is คือวิธีเทียบตัวนั้น — เทียบถึงแม้ error จะถูกห่อด้วย %w มาหลายชั้น ต่างจาก == ที่เทียบได้แค่ชั้นนอกสุด (ห่อแล้วเทียบไม่เจอ) · ถามได้ว่า “ฟังก์ชันนี้คืน sentinel error ตัวไหน จะได้เช็คด้วย errors.Is”

5 · จับคู่กับ t.Parallel()

for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()          // ทุกเคสวิ่งพร้อมกัน
        got, err := CalculateDiscount(tt.price, tt.percent)
        // ...
    })
}

t.Parallel() คือการบอก Go ว่า “เคสนี้ไม่ต้องรอเคสอื่น ปล่อยวิ่งพร้อมกันได้” เทสทั้งชุดจะเสร็จเร็วขึ้น — แต่มีกับดักอยู่หนึ่งข้อที่โค้ดเก่าต้องดักไว้เอง

โค้ดเก่าจะเห็น tt := tt — ตอนนี้ไม่ต้องแล้ว

ก่อน Go 1.22 ตัวแปรของ for range ถูกใช้ซ้ำทุกรอบ → closure ทุกตัวชี้ตัวแปรเดียวกัน (closure = ฟังก์ชันที่เขียนซ้อนอยู่ข้างใน แล้วหยิบตัวแปรข้างนอกไปใช้ได้) → parallel test ทุกเคสได้ค่าเคสสุดท้าย ต้องเขียน tt := tt ดักไว้
ตั้งแต่ Go 1.22 เป็นต้นไป ตัวแปรลูปเป็นตัวใหม่ทุกรอบแล้ว — บรรทัด tt := tt จึงไม่จำเป็น (เขียนไว้ก็ไม่ผิด แค่ซ้ำซ้อน) แต่ถ้า go.mod ระบุ go ต่ำกว่า 1.22 พฤติกรรมเก่ายังมีผลอยู่ — เช็ค go.mod ก่อนเสมอ

📋 เทียบให้เห็นภาพ — ไวท์บอร์ดแผ่นเดียว กับ กระดาษโน้ตคนละแผ่น

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

· Go ก่อน 1.22 = ไวท์บอร์ดแผ่นเดียว — ตัวแปร tt ตัวเดิมถูกเขียนทับทุกรอบ
· tt := tt = ลอกชื่อจากกระดานลงกระดาษของตัวเองก่อนออกไปทำงาน
· Go 1.22 เป็นต้นไป = ภาษาแจกกระดาษให้เองทุกรอบ จึงไม่ต้องลอกอีกแล้ว

ที่ไม่เหมือนกัน: ปัญหานี้โผล่ตอนที่ closure ถูก “เก็บไว้อ่านทีหลัง” — ในบทนี้คือ t.Parallel() แต่ go func(){…}() หรือ defer ก็เจอแบบเดียวกันเป๊ะ ไม่ได้ผูกกับเรื่องเทส · ลูปธรรมดาที่ใช้ค่าจบในรอบนั้นเลยไม่เจอ · และตัวตัดสินว่าได้พฤติกรรมไหน คือบรรทัด go ใน go.mod ไม่ใช่เวอร์ชัน Go ในเครื่อง

🗣 ศัพท์ที่เพิ่งเรียนไป
closure
ฟังก์ชันที่เขียนซ้อนข้างใน แล้วจำตัวแปรข้างนอกไปใช้ได้ — ก้อน func(t *testing.T) { … } ใน t.Run คือ closure
loop variable capture
ชื่อของกับดักที่เพิ่งเล่าไป: closure ไป “จับ” ตัวแปรของลูปไว้ แล้วค่ามันเปลี่ยนทีหลัง · เวลาถาม AI ให้พิมพ์ว่า “Go loop variable capture in t.Parallel” จะตรงกว่าพิมพ์ว่า “ค่าในลูปเพี้ยน”
data race · race condition · -race
data race = โค้ดสองส่วนวิ่งพร้อมกันแล้วแตะข้อมูลก้อนเดียวกันโดยไม่ล็อก · race condition กว้างกว่า = ผลลัพธ์เพี้ยนเพราะลำดับเวลาไม่แน่นอน ถึงล็อกครบไม่มี data race เลยก็ยังเป็นได้ (เช่นเช็คเงื่อนไขเสร็จแล้วค่อยลงมือ แต่ระหว่างนั้นค่าเปลี่ยนไปแล้ว) · go test -race จับได้เฉพาะ data race ที่บังเอิญเกิดขึ้นจริงในรอบที่รัน — ผ่านไม่ได้แปลว่าไม่มี เส้นทางที่เทสไม่ได้เดินเข้าไป มันก็ไม่เห็น · ประโยคที่ใช้จริง: “เทสตัวนี้ flaky น่าจะมี race ลอง -race ดู”

6 · ทดสอบความจำ

1. table-driven test แยกอะไรออกจากอะไร?

ตาราง = ข้อมูล (input, expected, ชื่อเคส) · ลูป = ตรรกะ (เรียกฟังก์ชัน, เทียบผล) เขียนตรรกะครั้งเดียวใช้กับทุกเคส → เพิ่มเคสใหม่คือพิมพ์อีกบรรทัด และตารางกลายเป็นเอกสารว่าเทสครอบคลุมอะไรบ้าง

2. ทำไมต้องมี field name ในแต่ละแถวของตาราง?

ไม่มี name → ต้องใช้ index → "case 1 failed" ซึ่งต้องกลับไปนับแถวเอง · มี name → "เปอร์เซ็นต์ติดลบ failed" อ่านออกทันที และยัง -run 'TestX/เปอร์เซ็นต์ติดลบ' เจาะเคสเดียวได้ด้วย

3. if (err != nil) != tt.wantErr ครอบคลุมกรณีอะไรบ้าง?

เทียบ "มี error จริงไหม" กับ "คาดว่ามีไหม" → จับได้ทั้งสองทาง รวมถึงกรณีที่คนลืมเทสบ่อยที่สุด: input ที่ควรพังแต่ฟังก์ชันดันปล่อยผ่าน · แต่มันบอกไม่ได้ว่าเป็น error ตัวไหน — ถ้าต้องการแบบนั้นเปลี่ยนเป็น wantErr error + errors.Is

4. ตั้งแต่ Go 1.22 บรรทัด tt := tt ก่อน t.Parallel() เป็นยังไง?

Go 1.22 เปลี่ยนให้ตัวแปรของ for range เป็นตัวใหม่ทุก iteration → closure แต่ละตัวจับค่าของตัวเอง · เขียน tt := tt ไว้ก็ไม่ผิด แค่ซ้ำซ้อน แต่ถ้า go.mod ระบุเวอร์ชันต่ำกว่า 1.22 พฤติกรรมเก่ายังมีผล — Go ใช้บรรทัด go ใน go.mod ตัดสิน ไม่ใช่เวอร์ชัน toolchain

7 · ลงมือทำ

ภารกิจ: แปลงเทสซ้ำซากเป็นตาราง

  1. เขียนฟังก์ชัน Grade(score int) (string, error) — คืน A/B/C/D/F ตามเกณฑ์ 80/70/60/50 และคืน error ถ้า score ไม่อยู่ระหว่าง 0–100
  2. เขียนเทสแบบซ้ำซากก่อน — ฟังก์ชันแยก 4 ตัว (ทำจริง ๆ นะ จะได้รู้สึกถึงความน่ารำคาญ)
  3. แปลงเป็น table-driven ให้ครอบคลุม อย่างน้อย 9 เคส:
    • ตัวแทนของแต่ละเกรด (5 เคส)
    • ค่าขอบ: 80, 79, 50, 49 — จุดที่บั๊ก off-by-one ชอบซ่อน (off-by-one = บั๊กพลาดไปหนึ่งช่อง เช่น เขียน > ทั้งที่ต้องเป็น >=)
    • ค่าผิดพลาด: −1, 101
  4. ข้อความ error ต้องอยู่ในรูป Grade(%d) = %q; want %q
  5. รัน go test -v . แล้วดูรายชื่อ subtest ทั้งหมด
  6. -run เจาะเฉพาะเคสขอบเคสเดียว
  7. ท้าทาย 1: เปลี่ยน wantErr bool เป็น wantErr error แล้วใช้ errors.Is — ต้องประกาศ sentinel ErrScoreOutOfRange ก่อน
  8. ท้าทาย 2: ใส่ t.Parallel() ใน t.Run แล้วรัน go test -race -count=5 . — ผ่านทุกรอบไหม?
  9. ท้าทาย 3: จงใจทำให้ Grade ผิดที่ค่าขอบ (เปลี่ยน >= 80 เป็น > 80) → เคสไหนจับได้บ้าง? ถ้าไม่มีเคสไหนจับได้ แปลว่าตารางของคุณยังขาดเคสขอบ

อ่านต่อจากต้นทาง

แหล่งหลักที่แนะนำ: Go Wiki — Table Driven Tests สั้นมาก และมีลิงก์ไปดูตัวอย่างจริงใน standard library
ที่ผมแนะนำให้ทำจริง ๆ คือ เปิดซอร์สของ Go เองมาดู — strings_test.go เขียนแบบตารางเกือบทั้งไฟล์ เห็นแล้วจะรู้ว่าสำนวนนี้ไม่ใช่ทฤษฎี แต่เป็นวิธีที่ทีม Go ใช้จริง

💬 ติดตรงไหนถามได้เลย — ท้าทายข้อ 3 (ค่าขอบ) คือหัวใจของบทนี้จริง ๆ ถ้าจงใจทำโค้ดพังแล้วเทสยังผ่านหมด แปลว่าตารางยังไม่ครบ ส่งตารางมาได้ จะช่วยดูว่าขาดเคสไหน

อ้างอิง