สารบัญ
กฎการตั้งชื่อ & package
คำสั่ง go test
method ของ testing.T
Table-Driven Test
Testify — assert / require
Testify — mock & suite
TDD & AI workflow
ตารางกับดักรวม
1 · กฎการตั้งชื่อ & package
สิ่งที่ต้องทำ ถ้าทำผิด
ไฟล์ลงท้าย _test.go [no test files] — เงียบสนิท ไม่มี error
ฟังก์ชันขึ้นต้น Test + ตัวถัดไปเป็นตัวใหญ่/_ ไม่ถูกรัน
รับ t *testing.T ตัวเดียว ไม่คืนค่า compile error
admin_handler.go
admin_handler_test.go ← วางคู่กันใน folder เดียวกัน
Internal vs External test package
package calc (internal)package calc_test (external)
import package ตัวเอง ❌ ไม่ต้อง ✅ ต้อง
เทส unexported ✅ ได้ ❌ name X not exported by package calc
มุมมอง คนใน คนนอก — เหมือนผู้ใช้จริง
1 folder มี 2 package ได้ เฉพาะกรณีที่ตัวที่สองลงท้าย _test
— เป็นข้อยกเว้นเดียวในภาษา · ใช้ทั้งสองแบบพร้อมกันได้
Build tag — แยก integration test
//go:build integration
← gofmt ต้องการบรรทัดว่างตรงนี้
package user
เขียนแบบ ผลรันจริง
อยู่บนสุด + มีบรรทัดว่าง ✅ ถูกต้อง
อยู่บนสุด + ไม่มีบรรทัดว่าง ⚠️ ยังทำงาน แต่ gofmt -l ฟ้อง
อยู่ใต้ package 💥 misplaced compiler directive
2 · คำสั่ง go test
go test [options] [packages]
packages ความหมาย
.folder ปัจจุบัน
./...ทุก folder ลงไปทุกชั้น — ใช้บ่อยสุด
./internal/userระบุ folder
Option ทำอะไร
-vโชว์ชื่อเทสทุกตัว + t.Log
-run <regex>รันเฉพาะที่ชื่อตรง (เจาะ subtest ด้วย / ได้)
-covercoverage: 42.9% of statements
-coverprofile=c.outบันทึกลงไฟล์
-count=1ล้าง cache · -count=10 จับ flaky test
-shortเปิด short mode → testing.Short() เป็น true
-raceจับ data race
-tags <tag>มี s! สไลด์เขียน -tag ซึ่งใช้ไม่ได้
-failfastหยุดทันทีที่มีเทสแรกพัง
go test -v -run 'TestDiscount' . # ทั้งฟังก์ชัน
go test -v -run 'TestDiscount/ลด_10' . # เจาะ subtest
go test -v -run 'TestUser|TestOrder' . # regex OR
go test -coverprofile=c.out .
go tool cover -func=c.out # ตาราง
go tool cover -html=c.out # หน้าเว็บระบายสี
Go แปลงเว้นวรรคในชื่อ subtest เป็น _ —
ตั้ง "ลด 10%" ต้อง -run ด้วย ลด_10%
ok (cached) = ไม่ได้รันจริง — ใช้ -count=1
coverage 100% ≠ ไม่มีบั๊ก — นับแค่ว่าบรรทัดถูกรันผ่าน
ใช้หาส่วนที่ 0% ไม่ใช่ไล่ล่าตัวเลข
3 · method ของ testing.T
Method ทำอะไร หยุดเทสไหม
t.Error / t.Errorfทำให้ fail แล้วไปต่อ ❌
t.Fatal / t.Fatalfทำให้ fail แล้วหยุด ✅
t.Log / t.Logfข้อความดีบัก — เห็นเฉพาะ -v หรือตอน fail ❌
t.Skip / t.Skipfข้ามเทสนี้ → --- SKIP: ✅
t.Failed()ถามว่าตอนนี้ fail ไปหรือยัง (bool) ❌
t.Run(name, fn)สร้าง subtest — ได้ t ตัวใหม่ ❌
t.Parallel()จอดเทสไว้ แล้วปล่อยวิ่งพร้อมกันทีหลัง —
t.Cleanup(fn)ลงทะเบียนงานเก็บกวาด — LIFO รันแม้ fail ❌
t.Helper()บอกว่าฟังก์ชันนี้เป็น helper → error ชี้ที่คนเรียก ❌
t.TempDir()folder ชั่วคราวที่ลบให้เองตอนจบ ❌
t.Setenv(k, v)ตั้ง env var แล้วคืนค่าเดิมตอนจบ ❌
Error vs Fatal — กฎเลือก
user, err := GetUser ("U-101" )
if err != nil {
t.Fatalf ("GetUser พัง: %v" , err) // Fatal — บรรทัดล่างจะ panic ถ้าไปต่อ
}
if user.Name != "Alice" {
t.Errorf ("Name = %q; want Alice" , user.Name) // Error — อยากเห็นทุกจุดที่ผิด
}
บรรทัดถัดไปจะพังตามแน่ ๆ → Fatal · นอกนั้น Error
t.Fatal ใช้ใน goroutine อื่นไม่ได้ — ใช้ t.Error แทน
t.Cleanup vs defer
defert.Cleanup
รันเมื่อ ฟังก์ชันที่เขียน defer จบ เทสจบ
เขียนใน helper ❌ รันทันทีที่ helper จบ ✅ รอจนเทสจบ
ลำดับ · รันตอน fail LIFO · ✅ LIFO · ✅
func newTestDB (t *testing.T) *DB {
t.Helper ()
db := openTempDB ()
t.Cleanup (func () { db.Close () }) // defer จะปิดเร็วเกินไป
return db
}
t.Helper() — ผลรันจริง
ไม่ใส่ → parallel2_test.go:43 : got 5, want 10 // ชี้ในตัว helper (ไร้ประโยชน์)
ใส่ → parallel2_test.go:59 : got 5, want 10 // ชี้บรรทัดที่เรียก (ถูก)
helper ที่รับ *testing.T ต้องใส่ t.Helper() บรรทัดแรกเสมอ
t.Parallel() — กลไกและกับดักการวัดเวลา
// ❌ วัดผิด — Parallel คืนการควบคุมทันที
t.Run ("group" , func (t *testing.T) {
start := time.Now ()
for ... { t.Run (..., func (t *testing.T){ t.Parallel (); ... }) }
t.Logf ("%v" , time.Since (start)) // ได้ 139µs — หลอกตา!
})
// ✅ วัดถูก — วัดนอก group
start := time.Now ()
t.Run ("group" , func (t *testing.T) { /* สร้าง parallel subtest */ })
t.Logf ("%v" , time.Since (start)) // ได้ 201ms — จริง
วิธี ผลรันจริง (5 เคส × 200ms)
sequential 1.006 s
parallel วัดถูก 201 ms (เร็วขึ้น 5 เท่า)
parallel วัดแบบในสไลด์ 139 µs ← ไม่ใช่เวลาจริง
ก่อนใส่ t.Parallel() ต้องแน่ใจว่าไม่แชร์ state —
ตรวจด้วย -race -count=10
4 · Table-Driven Test
func TestCalculateDiscount (t *testing.T) {
tests := []struct {
name string // ← ขาดไม่ได้ ไม่งั้นไม่รู้ว่าเคสไหนพัง
price int
percent int
want int
wantErr bool
}{
{"ลด 10%" , 100 , 10 , 90 , false },
{"ไม่ลด" , 100 , 0 , 100 , false },
{"เปอร์เซ็นต์ติดลบ" , 100 , -5 , 0 , true },
}
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 ("err = %v; wantErr %v" , err, tt.wantErr)
}
if got != tt.want {
t.Errorf ("CalculateDiscount(%d, %d) = %d; want %d" ,
tt.price, tt.percent, got, tt.want)
}
})
}
}
ข้อความ error ต้องบอก 3 อย่าง: input · got · want
รูปแบบมาตรฐาน f(input) = got; want X
(err != nil) != tt.wantErr จับได้ทั้งสองทาง
รวมกรณีที่ควรพังแต่ไม่พัง
อยากแน่นกว่านั้น → wantErr error + errors.Is
(errors.Is(nil, nil) = true ครอบคลุมทั้งสองเคส)
Go 1.22+ ไม่ต้องเขียน tt := tt แล้ว —
ตัวแปรลูปเป็นตัวใหม่ทุกรอบ · แต่ดูบรรทัด go ใน go.mod ก่อน
5 · Testify — assert / require
go get github.com/stretchr/testify && go mod tidy // tidy ขาดไม่ได้
assert.Xrequire.X
เทียบเท่ากับ t.Errorf — ไปต่อt.Fatalf — หยุด
ใช้กับ ตรวจผลลัพธ์หลายจุด เงื่อนไขก่อนหน้า
result, err := CalculateDiscount (100 , 10 )
require.NoError (t, err) // ถ้าพังก็ไม่ต้องไปต่อ
assert.Equal (t, 90 , result) // ตรวจผล
แคตตาล็อก (รันผ่านจริงบน v1.11.1)
// ค่าปกติ
assert.Equal (t, expected, actual) // เท่ากัน (type ต้องตรง!)
assert.NotEqual (t, unexpected, actual)
assert.ElementsMatch (t, []int {3 ,1 ,2 }, list) // slice เท่ากัน ไม่สนลำดับ
// สถานะ
assert.Nil (t, err) · assert.NotNil (t, obj)
assert.Empty (t, slice) · assert.Len (t, slice, 3 )
assert.True (t, ok) · assert.False (t, failed)
// error — อย่าเทียบข้อความ!
require.Error (t, err) · assert.NoError (t, err)
assert.ErrorIs (t, err, ErrNotFound) // ← แม่นกว่า (ตาม chain)
assert.ErrorAs (t, err, &appErr)
assert.ErrorContains (t, err, "not found" ) // หลวม ใช้เมื่อจำเป็น
// float — ห้ามใช้ Equal!
assert.InDelta (t, 3.14 , result, 0.001 )
// slice/map/string
assert.Contains (t, "hello world" , "world" )
assert.Contains (t, []int {1 ,2 ,3 }, 2 )
assert.Contains (t, map [string ]int {"a" :1 }, "a" )
// เปรียบเทียบ · ข้อความเสริม
assert.Greater (t, a, b) · assert.Less (t, a, b)
assert.Equal (t, 10 , x, "input ตอนนั้นคือ %d" , input)
assert.Equal เทียบทั้ง type และค่า
(ฐานคือ reflect.DeepEqual) — ผลรันจริง:
assert.Equal(t, 90.0, result) // result เป็น int
→ expected: float64(90) actual: int(90) ✗ (สไลด์เขียนแบบนี้!)
var g Grade = "A"
assert.Equal(t, "A" , g)
→ expected: string("A") actual: Grade("A") ✗
6 · Testify — mock & suite
DI ก่อน แล้วค่อย mock ได้
type Store interface { Save (r Result) error }
type Calculator struct { store Store }
func NewCalculator (s Store) *Calculator { return &Calculator{store: s} }
type MockStore struct { mock.Mock }
func (m *MockStore) Save (r Result) error {
args := m.Called (r)
return args.Error (0 )
}
mockStore := new (MockStore)
mockStore.On ("Save" , Result{A:2 , B:3 , Sum:5 }).Return (nil )
calc := NewCalculator (mockStore)
result, err := calc.Add (2 , 3 )
mockStore.AssertExpectations (t)
// จับคู่ argument แบบหลวม
mock.Anything · mock.AnythingOfType ("main.Result" )
// ตรวจการเรียก
m.AssertExpectations (t) // ทุกอันที่ On ไว้ถูกเรียกครบ
m.AssertCalled (t, "Save" , mock.Anything)
m.AssertNotCalled (t, "Update" )
m.AssertNumberOfCalls (t, "Save" , 2 )
// จำกัดจำนวนครั้ง
.Once () · .Twice () · .Times (3 )
ลืม .On() แล้วเรียก method → panic
แต่ข้อความบอกวิธีแก้พร้อมค่า argument ที่ได้รับจริง:
mock: I don't know what to return because the method call was unexpected.
Suite
type UserServiceSuite struct {
suite.Suite
store *MockUserStore
svc *UserService
}
func (s *UserServiceSuite) SetupTest () {
s.store = new (MockUserStore) // สร้างใหม่ทุกเทส
s.svc = NewUserService (s.store)
}
func (s *UserServiceSuite) TestFindUser () {
s.store.On ("GetByID" , 1 ).Return (&User{Name: "Bob" }, nil )
user, err := s.svc.FindUser (1 )
s.Require ().NoError (err) // ⚠️ ไม่ต้องส่ง t
s.Assert ().Equal ("Bob" , user.Name)
}
func TestUserServiceSuite (t *testing.T) { // ← ขาดไม่ได้
suite.Run (t, new (UserServiceSuite))
}
Hook รันเมื่อ
SetupSuite()ครั้งเดียวก่อนทุกเทส
SetupTest()ก่อนทุกเทส
BeforeTest(suiteName, testName)ก่อนแต่ละเทส (รู้ชื่อ)
AfterTest(suiteName, testName)หลังแต่ละเทส
TearDownTest()หลังทุกเทส
TearDownSuite()ครั้งเดียวหลังทุกเทสจบ
suite เรียงเทสตามตัวอักษร ไม่ใช่ตามลำดับในไฟล์
→ ห้ามเขียนเทสที่พึ่งพาลำดับ
ใน suite ใช้ s.Assert() / s.Require()
ไม่ใช่ assert.X(t, …)
mock เฉพาะสิ่งที่อยู่นอกการควบคุม —
database, HTTP, ไฟล์, นาฬิกา, ตัวสุ่ม · mock เยอะเกิน = refactor ทีแดงหมด
7 · TDD & AI workflow
[Red] ──▶ [Green] ──▶ [Refactor] ──┐
▲ │
└────────────────────────────────┘
เขียนเทสให้พัง แก้ให้ผ่าน ทำให้สวย (เทสยังเขียว)
ขั้น กฎเหล็ก
🔴 Red ห้ามเขียน production code ก่อนเห็นไฟแดง
🟢 Green เขียนน้อยที่สุดที่ทำให้ผ่าน — สวยทีหลังได้
🔵 Refactor เทสต้องเขียวตลอด · ห้ามแก้เทสไปพร้อมกัน
Red มีสองแบบ — มีแบบเดียวที่ใช่
// 🚫 ยังไม่ใช่ Red — เทสยังไม่เคยรันเลย
./division_test.go:6:14: undefined: Division
FAIL tdd [build failed]
// ✅ Red ที่ต้องการ — เทสรันแล้วพังด้วยเหตุผลที่ถูก
--- FAIL: TestDivision (0.00s)
division_test.go:11: Division(10,2) = 0; want 5
AI workflow
1. คุณ: เขียน table-driven test ระบุพฤติกรรมทุกเคส (รวมเคสขอบ)
2. คุณ: รันให้เห็นแดง — พิสูจน์ว่าเทสจับได้จริง
3. AI: เขียน implementation ให้ผ่านเทส (ห้ามแก้ไฟล์เทส)
4. AI: รัน go test เอง แก้เอง จนเขียว
5. คุณ: อ่านโค้ด — มัน "โกง" เทสหรือเปล่า?
6. คุณ: เพิ่มเคส → กลับข้อ 2
อย่าให้ AI เขียนเทสจากโค้ดที่มันเพิ่งเขียนเอง
— ถ้าโค้ดผิด เทสจะยืนยันความผิดนั้น
ตารางเทสต้องมีเคสมากพอจนการโกงยากกว่าการทำจริง
— ระวัง if price == 100 && percent == 10 { return 90, nil }
8 · ตารางกับดักรวม — อ่านก่อนส่ง PR
สิ่งที่เห็น / เขียน สิ่งที่คุณคิด สิ่งที่เกิดจริง
ไฟล์ mytests.go มี func TestX
รันเทสได้
[no test files] เงียบสนิท
package calc_test เรียก double()
อยู่ folder เดียวกันน่าจะได้
💥 name double not exported
//go:build อยู่ใต้ package
อยู่ไฟล์เดียวกันก็พอ
💥 misplaced compiler directive
go test -tag=integration
ตามที่สไลด์เขียน
💥 flag provided but not defined: -tag · ใช้ -tags
ok (cached)
เทสผ่านแล้ว
ไม่ได้รันจริง · ใช้ -count=1
coverage: 100%
ไม่มีบั๊กแล้ว
แค่ทุกบรรทัดถูกรันผ่าน — assert อาจไม่มีเลย
t.Error แล้วใช้ค่าต่อ
หยุดแล้ว
💥 บรรทัดถัดไป panic (nil) · ใช้ t.Fatal
t.Fatal ใน goroutine
หยุดเทส
หยุดแค่ goroutine · ผลลัพธ์เพี้ยน
fmt.Println ในเทส
ดีบักสะดวก
พิมพ์ตลอด ไม่มีเลขบรรทัด · ใช้ t.Log
helper ไม่ใส่ t.Helper()
ไม่เป็นไร
error ทุกอันชี้บรรทัดเดียวกันในตัว helper
defer db.Close() ใน test helper
ปิดตอนเทสจบ
ปิดทันทีที่ออกจาก helper · ใช้ t.Cleanup
วัดเวลาในบล็อกที่มี t.Parallel()
ได้เวลารันจริง
ได้ ~139µs (แค่เวลาจอดคิว) · วัดนอกบล็อก
t.Parallel() + ตัวแปร global
เร็วขึ้น
flaky test · ตรวจด้วย -race -count=10
ตารางเทสไม่มี field name
ใช้ index ก็ได้
case 1 failed — ไม่รู้ว่าเคสอะไร
assert.Equal(t, 90.0, intResult)
ค่าเท่ากันก็ผ่าน
💥 float64(90) ≠ int(90) (สไลด์เขียนแบบนี้)
assert.Equal(t, 0.3, a+b) (float)
0.1+0.2 = 0.3
💥 ได้ 0.30000000000000004 · ใช้ InDelta
assert.Equal(t, "A", grade) (custom type)
ค่าเป็น "A" เหมือนกัน
💥 string("A") ≠ Grade("A")
ลืม go mod tidy หลัง go get testify
ลงเสร็จแล้ว
💥 missing go.sum entry for … go-spew
ใน suite เรียก assert.Equal(t, …)
เหมือนเทสปกติ
ไม่มี t ให้ใช้ · ใช้ s.Assert()
เขียนเทส suite ให้ทำงานตามลำดับ
รันตามลำดับในไฟล์
เรียงตามตัวอักษร → เทสพังแบบเดาไม่ได้
ลืม mockStore.On(...)
mock คืน zero value
💥 panic — แต่ข้อความบอกวิธีแก้ให้
build failed ตอนทำ TDD
นี่คือ Red แล้ว
เทสยังไม่เคยรัน · เขียนโครงเปล่าให้คอมไพล์ผ่านก่อน
บทเรียน 14 — test in Go
บทเรียน 15 — testing.T
บทเรียน 16 — table-driven
บทเรียน 17 — Testify
บทเรียน 18 — TDD
🏠 หน้าแรก
📚 Resources