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 ฟังก์ชัน หน้าตาเหมือนกันหมด
- ซ้ำซาก — ตรรกะเดิม 5 รอบ ต่างกันแค่ตัวเลข
- แก้ยาก — เปลี่ยน signature ของฟังก์ชันทีต้องแก้ 5 ที่ (signature = หน้าตาของฟังก์ชัน คือสิ่งที่รับเข้าและสิ่งที่คืนออกมา · เวลาคุยกันมักพ่วงชื่อฟังก์ชันไปด้วย แต่ทางการนับแค่สองอย่างนี้ ฟังก์ชันคนละชื่อจึงมี signature เดียวกันได้)
- ตกหล่นง่าย — copy แล้วลืมแก้ค่าใดค่าหนึ่ง เทสจะผ่านทั้งที่ไม่ควรผ่าน
- มองไม่เห็นภาพรวม — ตอบไม่ได้ทันทีว่าเทสครอบคลุมเคสไหนแล้วบ้าง
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 จริง? | คาดว่ามี? | != ได้ | ผล |
|---|---|---|---|
| false | false | false | ✅ ผ่าน — ไม่คาดว่าพัง และไม่พังจริง |
| true | true | false | ✅ ผ่าน — คาดว่าพัง และพังจริง |
| true | false | true | 💥 fail — ไม่ควรพังแต่ดันพัง |
| false | true | true | 💥 fail — ควรพังแต่ดันปล่อยผ่าน |
- คาดว่าไม่มี error แต่มี → fail ✅
- คาดว่ามี error แต่ไม่มี → 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 · ลงมือทำ
ภารกิจ: แปลงเทสซ้ำซากเป็นตาราง
- เขียนฟังก์ชัน
Grade(score int) (string, error)— คืน A/B/C/D/F ตามเกณฑ์ 80/70/60/50 และคืน error ถ้าscoreไม่อยู่ระหว่าง 0–100 - เขียนเทสแบบซ้ำซากก่อน — ฟังก์ชันแยก 4 ตัว (ทำจริง ๆ นะ จะได้รู้สึกถึงความน่ารำคาญ)
- แปลงเป็น table-driven ให้ครอบคลุม อย่างน้อย 9 เคส:
- ตัวแทนของแต่ละเกรด (5 เคส)
- ค่าขอบ: 80, 79, 50, 49 — จุดที่บั๊ก off-by-one ชอบซ่อน
(off-by-one = บั๊กพลาดไปหนึ่งช่อง เช่น เขียน
>ทั้งที่ต้องเป็น>=) - ค่าผิดพลาด: −1, 101
- ข้อความ error ต้องอยู่ในรูป
Grade(%d) = %q; want %q - รัน
go test -v .แล้วดูรายชื่อ subtest ทั้งหมด -runเจาะเฉพาะเคสขอบเคสเดียว- ท้าทาย 1: เปลี่ยน
wantErr boolเป็นwantErr errorแล้วใช้errors.Is— ต้องประกาศ sentinelErrScoreOutOfRangeก่อน - ท้าทาย 2: ใส่
t.Parallel()ในt.Runแล้วรันgo test -race -count=5 .— ผ่านทุกรอบไหม? - ท้าทาย 3: จงใจทำให้
Gradeผิดที่ค่าขอบ (เปลี่ยน>= 80เป็น> 80) → เคสไหนจับได้บ้าง? ถ้าไม่มีเคสไหนจับได้ แปลว่าตารางของคุณยังขาดเคสขอบ
อ่านต่อจากต้นทาง
แหล่งหลักที่แนะนำ:
Go Wiki — Table Driven Tests
สั้นมาก และมีลิงก์ไปดูตัวอย่างจริงใน standard library
ที่ผมแนะนำให้ทำจริง ๆ คือ เปิดซอร์สของ Go เองมาดู —
strings_test.go
เขียนแบบตารางเกือบทั้งไฟล์ เห็นแล้วจะรู้ว่าสำนวนนี้ไม่ใช่ทฤษฎี
แต่เป็นวิธีที่ทีม Go ใช้จริง
💬 ติดตรงไหนถามได้เลย — ท้าทายข้อ 3 (ค่าขอบ) คือหัวใจของบทนี้จริง ๆ ถ้าจงใจทำโค้ดพังแล้วเทสยังผ่านหมด แปลว่าตารางยังไม่ครบ ส่งตารางมาได้ จะช่วยดูว่าขาดเคสไหน
อ้างอิง
- Go Wiki — Table Driven Tests — สำนวนมาตรฐาน
- strings_test.go — ตัวอย่างจริงใน standard library
- The Go Blog — Using Subtests and Sub-benchmarks
- The Go Blog — Fixing For Loops in Go 1.22 — ที่มาของการเลิกเขียน
tt := tt - Go Code Review Comments — Useful Test Failures — รูปแบบข้อความ error ที่ควรใช้