1 · ติดตั้ง
go get github.com/stretchr/testify
# แล้วก็
go mod tidy
ต้อง go mod tidy ด้วย — testify พึ่งพา
go-spew, go-difflib, yaml.v3
ถ้าไม่ tidy จะเจอ missing go.sum entry for module providing package …
(ผมเจอมาแล้วตอนทดสอบ)
เวอร์ชันที่ทดสอบในบทนี้: testify v1.11.1
2 · assert vs require — คู่แฝดของ Error/Fatal
testing เปล่า ๆ
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)
}
Testify
result, err := CalculateDiscount(100, 10)
require.NoError(t, err)
assert.Equal(t, 90, result)
assert.X | require.X | |
|---|---|---|
| พฤติกรรมเมื่อไม่ผ่าน | ฟ้องแล้วไปต่อ | ฟ้องแล้วหยุด |
| เทียบเท่ากับ | t.Errorf | t.Fatalf |
| ใช้เมื่อ | อยากเห็นทุกจุดที่ผิดในรอบเดียว | บรรทัดถัดไปจะพังตามแน่ ๆ |
ผมรันทดสอบยืนยันแล้ว:
=== RUN TestAssertVsRequire_assert
Error: Not equal: expected: 1 actual: 2
testify_test.go:33: assert -> บรรทัดนี้ยังรัน ← มา
--- FAIL: TestAssertVsRequire_assert
=== RUN TestAssertVsRequire_require
Error: Not equal: expected: 1 actual: 2
--- FAIL: TestAssertVsRequire_require ← ไม่มี log ตามมา
สำนวนที่ใช้จริง: require สำหรับ
เงื่อนไขก่อนหน้า (ได้ค่ามาแล้วใช่ไหม, ไม่มี error ใช่ไหม)
· assert สำหรับ ตัวผลลัพธ์ ที่อยากเห็นครบทุกจุด
require.NoError(t, err) ตามด้วย assert.Equal(...)
หลายบรรทัด — คือรูปแบบที่เจอบ่อยที่สุด
ครูตรวจข้อสอบ เจอข้อที่ผิดก็กากบาทไว้ แล้วตรวจข้อถัดไปต่อจนจบใบ
ตรวจรอบเดียวรู้เลยว่าผิดกี่ข้อ ข้อไหนบ้าง → นี่คือ assert
ยามหน้าตึก ถ้าไม่มีบัตร ก็ไม่ให้เข้าตั้งแต่ประตู
ไม่ต้องเสียเวลาไปดูต่อว่าจะขึ้นลิฟต์ชั้นไหน เพราะยังไงก็เข้าไปไม่ถึง → นี่คือ require
ที่ต้องใช้ require กับ err เพราะถ้ามี error แล้วยังตรวจต่อ
บรรทัดถัดไปมักจะไปหยิบค่าที่ยังไม่มี แล้วโปรแกรมพังกลางทาง
สิ่งที่เราจะได้อ่านคือกองข้อความพัง ไม่ใช่สาเหตุจริง
ที่ไม่เหมือนกัน: ยามกันคนไม่ให้เข้าตึกทั้งตึก
แต่ require ที่ไม่ผ่าน หยุดแค่เทสตัวที่กำลังรันอยู่
เทสตัวอื่นในไฟล์เดียวกันยังรันต่อจนครบตามปกติ
- assertion
- คำสั่ง “ยืนยันว่าต้องเป็นแบบนี้” หนึ่งบรรทัดในเทส · เวลาคุยให้พูดว่า “เทสตัวนี้มีกี่ assertion” จะสื่อกว่าพูดว่า “มีกี่บรรทัดที่เช็ค”
- precondition (เงื่อนไขก่อนหน้า)
- สิ่งที่ต้องจริงก่อน ถึงจะตรวจอย่างอื่นต่อได้ เช่น “ต้องไม่มี error ก่อน” · ประโยคที่ใช้ได้จริง: “ตรงนี้เป็น precondition ใช้ require ดีกว่า assert”
- fail fast
- “ผิดแล้วหยุดเลย ไม่ลากต่อ” ·
requireคือ fail fast ส่วนassertไม่ใช่ t.Error/t.Fatal- ของเดิมใน standard library ที่ testify ห่อไว้ให้ · ถ้า senior ถามว่า “อันนี้ Error หรือ Fatal” เขากำลังถามว่า ไม่ผ่านแล้วไปต่อหรือหยุด
3 · ⚠️ กับดักใหญ่: assert.Equal เข้มเรื่อง type
// CalculateDiscount คืน (int, error)
result, err := CalculateDiscount(100, 10)
require.NoError(t, err)
assert.Equal(t, 90.0, result) // สังเกต .0
เทสนี้ผ่านไหม? (result ได้ค่า 90 ตามที่คาด)
ไม่ผ่าน — ผมรันทดสอบแล้ว ได้ข้อความนี้:
Error: Not equal:
expected: float64(90)
actual : int(90)
assert.Equal ใช้ reflect.DeepEqual เป็นฐาน
ซึ่งเทียบทั้ง type และค่า —
90.0 คือ float64 ส่วน result คือ
int → คนละ type → ไม่เท่ากัน
ตรงนี้ตรงกับกฎที่เจอมาตั้งแต่
บทเรียน 09:
type ต่างกันคือคนละอย่าง ต่อให้ค่าเท่ากัน
ทางแก้: เขียน assert.Equal(t, 90, result)
สไลด์ Day 5 หน้า "Testify" เขียน assert.Equal(t, 90.0, result)
ไว้เป็นตัวอย่างของโค้ดที่สะอาดกว่า — แต่โค้ดนั้นเทสไม่ผ่านจริง ๆ
ถ้าลอกไปใช้แล้วงงว่าทำไมแดงทั้งที่ logic ถูก นี่คือคำตอบ
ไปติดต่อเคาน์เตอร์ธนาคาร เจ้าหน้าที่ขอ “สำเนาบัตรประชาชน” เรายื่นสำเนาใบขับขี่ให้แทน เลข 13 หลักบนใบขับขี่ก็เลขเดียวกันเป๊ะ แต่เจ้าหน้าที่ไม่รับ เพราะมันคนละเอกสาร ไม่ใช่เพราะเลขผิด
assert.Equal ตรวจแบบเดียวกัน — มันดูสองอย่างเสมอ:
ค่าตรงไหม และ เป็นของชนิดเดียวกันไหม
90.0 กับ 90 ค่าเท่ากันก็จริง
แต่ตัวแรกเป็นเลขทศนิยม (float64) ตัวหลังเป็นจำนวนเต็ม (int)
→ คนละชนิด → ไม่ผ่าน
ที่ไม่เหมือนกัน: เจ้าหน้าที่ธนาคารยังใช้ดุลพินิจอนุโลมได้บ้าง
แต่ assert.Equal ไม่มีดุลพินิจเลย ตรงก็คือตรง ไม่ตรงก็คือไม่ตรง
และเฉพาะตอนชนิดสองฝั่งไม่ตรงกัน testify จะพิมพ์ชนิดกำกับมาให้ด้วย —
เห็น float64(90) คู่กับ int(90) เมื่อไหร่ ให้รู้ทันทีว่าเป็นเรื่องชนิด ไม่ใช่เรื่องค่า
ถ้าชนิดตรงกันอยู่แล้ว มันจะพิมพ์แค่ค่าเปล่า ๆ (อย่าง expected: 1 actual: 2 ข้างบน
และผลรันหัวข้อ float ข้างล่าง) — ไม่เห็นชนิดในข้อความ error ไม่ได้แปลว่าชนิดถูก
แต่แปลว่าสองฝั่งชนิดเดียวกัน ปัญหาอยู่ที่ค่าล้วน ๆ
ญาติของกับดักนี้: float
a, b := 0.1, 0.2
assert.Equal(t, 0.3, a+b) // ✗ ไม่ผ่าน!
ผลรันจริง:
a+b = 0.30000000000000004441
0.3 = 0.29999999999999998890
Error: Not equal:
expected: 0.3
actual : 0.30000000000000004
assert.InDelta(t, 0.3, a+b, 0.0001) // ✓ ผ่าน — เทียบแบบมีค่าคลาดเคลื่อน
เรื่องแปลกที่เจอตอนทดสอบ: ถ้าเขียน
assert.Equal(t, 0.3, 0.1+0.2) ตรง ๆ (ไม่ผ่านตัวแปร)
มันผ่าน — เพราะ 0.1 + 0.2
เป็น untyped constant ที่ compiler คำนวณให้ตั้งแต่ตอน build
ด้วยความละเอียดไม่จำกัด ได้ 0.3 เป๊ะ
ต้องผ่านตัวแปรถึงจะเป็นการคำนวณ float64 ตอน runtime และเห็นปัญหา —
เป็นเรื่องเดียวกับ int(3.9) ใน
บทเรียน 02
กฎ: float ห้ามใช้ Equal ใช้ InDelta
เหตุผลที่เลขทศนิยมเชื่อไม่ได้แบบนั้น คิดเหมือนตอนหารค่าข้าว 1 บาทให้เพื่อน 3 คน
ได้คนละ 0.333… บาท เขียนทศนิยมยาวแค่ไหนก็ไม่จบ ต้องตัดสักตำแหน่ง พอรวมกลับจึงไม่ได้ 1 บาทเป๊ะ
ที่ไม่เหมือนกัน: ที่ 1/3 เขียนไม่จบ เพราะเราเขียนกันด้วยฐานสิบ
แต่คอมพิวเตอร์เก็บเลขด้วยฐานสอง ตัวที่เขียนไม่จบจึงเป็นคนละตัว —
0.1 0.2 0.3 เขียนในฐานสองไม่จบ (เก็บได้แค่ใกล้เคียง)
ส่วน 0.5 กับ 0.25 ฐานสองเก็บได้เป๊ะสบาย ๆ ทั้งที่ในฐานสิบก็มีทศนิยมเหมือนกัน
คนละตัวเลขที่ซวย แต่อาการเดียวกัน คือตัดทิ้งแล้วรวมกลับไม่ตรงเป๊ะ
เทียบ float จึงต้องบอกว่า “ต่างกันไม่เกินเท่าไหร่ถือว่าเท่ากัน”
ซึ่งก็คือตัวเลขตัวสุดท้ายใน InDelta
reflect.DeepEqual- ฟังก์ชันใน standard library ที่
assert.Equalใช้เป็นฐาน — เทียบทั้งชนิดและค่า ชนิดไม่ตรงก็ไม่เท่าทันที (ข้อยกเว้นเล็ก ๆ ของ testify: ถ้าเป็น[]byteมันเทียบไบต์ตรง ๆ ให้ ไม่ผ่าน DeepEqual) · ประโยคที่เอาไปถามได้เลย: “assert.Equal ใช้ DeepEqual ใช่ไหม type ต้องตรงด้วยหรือเปล่า” - type mismatch
- “ชนิดไม่ตรงกัน” · เวลาแปะ error ให้ senior ดู พูดว่า “มันเป็น type mismatch ระหว่าง float64 กับ int” จะจบเร็วกว่าพูดว่า “เทสแดงทั้งที่ค่าเท่ากัน”
- floating point
- วิธีที่คอมพิวเตอร์เก็บเลขทศนิยม โดยเก็บเป็นฐานสอง
· บางค่าเก็บได้เป๊ะ (
0.5,0.25,1.5และจำนวนเต็มทั่ว ๆ ไป —assert.Equal(t, 0.5, 0.5)ผ่านสบาย) แต่บางค่าเก็บได้แค่ใกล้เคียง (0.1,0.2,0.3) พอเอาตัวที่ไม่เป๊ะมาบวกกันจึงได้เลขหางยาว · คำที่เจอบ่อยคู่กันคือ floating point precision = ความละเอียดที่เก็บได้ - tolerance / delta
- “ค่าคลาดเคลื่อนที่ยอมให้ได้ แบบตัวเลขตรง ๆ” —
0.0001ในInDeltaคือตัวนี้ แปลว่า ห่างกันไม่เกิน 0.0001 ถือว่าเท่า · พูดว่า “เทียบแบบมี tolerance” ได้เลย - epsilon
- คนละตัวกับ delta · testify มี
InEpsilonด้วย แต่มันเทียบค่าคลาดเคลื่อนแบบสัดส่วน เช่น0.01= ยอมให้เพี้ยนไม่เกิน 1% ของค่า · ตอนไม่ผ่านมันฟ้องคนละแบบด้วย (“Relative error is too high”) — เวลาคุยกับ senior อย่าเรียก delta ว่า epsilon สลับกัน - untyped constant
- ค่าคงที่ที่เขียนตรง ๆ ในโค้ดและยังไม่ถูกกำหนดชนิด
compiler คำนวณให้ตั้งแต่ตอน build จึงเป๊ะกว่าการคำนวณตอนโปรแกรมทำงานจริง —
เป็นสาเหตุที่
0.1+0.2เขียนตรง ๆ แล้วผ่าน แต่ใส่ตัวแปรแล้วไม่ผ่าน
4 · Diff ที่อ่านรู้เรื่อง — เหตุผลหลักที่คนใช้ Testify
got := UserProfile{ID: 1, Name: "Alice", Email: "alice@test.com", Age: 30}
want := UserProfile{ID: 1, Name: "Alice", Email: "alice@test.com", Age: 25}
assert.Equal(t, want, got)
ผลรันจริง — มันชี้ field ที่ต่างให้เลย:
Error: Not equal:
expected: UserProfile{ID:1, Name:"Alice", Email:"alice@test.com", Age:25, ...}
actual : UserProfile{ID:1, Name:"Alice", Email:"alice@test.com", Age:30, ...}
Diff:
--- Expected
+++ Actual
@@ -4,3 +4,3 @@
Email: (string) (len=14) "alice@test.com",
- Age: (int) 25,
+ Age: (int) 30,
Roles: ([]string) <nil>
ถ้าเขียนเองด้วย t.Errorf("got %v; want %v", got, want)
คุณจะได้ struct สองก้อนยาว ๆ แล้วต้องนั่งไล่สายตาเองว่า field ไหนต่าง —
ยิ่ง struct ใหญ่ยิ่งทรมาน
5 · แคตตาล็อก assert ที่ใช้บ่อย
ทั้งหมดนี้ผมรันผ่านจริงบน testify 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) // ว่าง (nil ก็นับ)
assert.Len(t, slice, 3) // ยาว 3
// --- บูลีน ---
assert.True(t, ok)
assert.False(t, failed)
// --- error (อย่าเทียบข้อความสตริง!) ---
require.Error(t, err) // มี error
assert.NoError(t, err) // ไม่มี error
assert.ErrorIs(t, err, ErrNotFound) // ใช่ sentinel ตัวนี้ (แม่นกว่า)
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") // substring
assert.Contains(t, []int{1,2,3}, 2) // element
assert.Contains(t, map[string]int{"a":1}, "a") // key
// --- เปรียบเทียบ ---
assert.Greater(t, a, b) // a > b
assert.Less(t, a, b) // a < b
// --- แสดงข้อมูลเพิ่มเมื่อพัง (arg ท้ายสุด) ---
assert.Equal(t, 10, x, "input ตอนนั้นคือ %d", input)
ErrorIs ดีกว่า ErrorContains —
ตัวแรกเดินตาม error chain (บทเรียน 12) ส่วนตัวหลังเทียบข้อความ
ซึ่งพังทันทีที่มีคนแก้ข้อความ · ใช้ ErrorContains
เฉพาะกับ error จาก library ภายนอกที่ไม่มี sentinel
(ตัวแปร error ที่ประกาศไว้ตายตัวให้เทียบได้ตรง ๆ เช่น ErrNotFound) ให้จับ
6 · Dependency Injection — เตรียมโค้ดให้ mock ได้
ก่อนจะ mock ได้ โค้ดต้องออกแบบมาให้รับ dependency เข้ามา ไม่ใช่สร้างเองข้างใน — ตรงกับ บทเรียน 10 เป๊ะ ๆ
type Result struct { A, B, Sum int }
type Store interface {
Save(r Result) error
}
type Calculator struct {
store Store // ← dependency: Calculator "พึ่ง" Store
}
func NewCalculator(s Store) *Calculator { // ← constructor รับเข้ามา
return &Calculator{store: s}
}
func (c *Calculator) Add(a, b int) (int, error) {
sum := a + b
if err := c.store.Save(Result{A: a, B: b, Sum: sum}); err != nil {
return 0, err
}
return sum, nil
}
ถ้า Add เขียนว่า fileStore := NewFileStore("/data"); fileStore.Save(...)
ข้างในเลย → เทสจะต้องเขียนไฟล์จริงทุกครั้ง
พอรับ Store เป็น interface เข้ามา → ยัดอะไรก็ได้ที่มี
Save() เข้าไป รวมถึงของปลอม
7 · mock — ของปลอมที่ตอบอะไรก็ได้ และจำได้ว่าถูกเรียกกี่ครั้ง
ก่อนอ่านโค้ด: mock.Mock คือของสำเร็จรูปจาก testify
เราแค่เอามาแปะไว้ใน struct ของเรา — เรียกว่า embed เหมือนกัน
แต่ บทเรียน 11 คือการฝัง interface (เอา interface ไปรวมรายชื่อ method ของ interface อื่น)
ส่วนอันนี้คือ การฝัง struct ซึ่งได้มาสองอย่าง:
1) method ติดมาให้ใช้ อย่าง On, Return,
Assert… โดยไม่ต้องเขียนเอง
2) ที่เก็บข้อมูลภายในติดมาด้วย — mock.Mock มีฟิลด์ของมันเอง
ไว้จำว่าเราตั้งท่าอะไรไว้บ้าง และใครเรียก method ไหนไปแล้วกี่ครั้ง
ตัวนี้แหละที่ทำให้ AssertExpectations ตอบได้ว่าครบไหม
type MockStore struct {
mock.Mock // ← embed เข้ามา (บทเรียน 11)
}
func (m *MockStore) Save(r Result) error {
args := m.Called(r) // บันทึกว่าถูกเรียกด้วย r
return args.Error(0) // คืนค่าตัวที่ 0 ที่ตั้งไว้
}
func TestCalculator_Add_Success(t *testing.T) {
mockStore := new(MockStore)
// ตั้งโปรแกรมล่วงหน้า: ถ้าถูกเรียก Save ด้วยค่านี้ ให้คืน nil
mockStore.On("Save", Result{A: 2, B: 3, Sum: 5}).Return(nil)
calc := NewCalculator(mockStore)
result, err := calc.Add(2, 3)
require.NoError(t, err)
assert.Equal(t, 5, result)
mockStore.AssertExpectations(t) // ← ตรวจว่าทุกอันที่ On ไว้ ถูกเรียกจริง
}
ทดสอบเส้นทางที่พัง — จุดที่ mock มีค่าที่สุด
func TestCalculator_Add_StoreFails(t *testing.T) {
mockStore := new(MockStore)
// สั่งให้ของปลอม "พัง" ตามที่เราต้องการ
mockStore.On("Save", mock.AnythingOfType("main.Result")).
Return(errors.New("disk full"))
calc := NewCalculator(mockStore)
_, err := calc.Add(2, 3)
require.Error(t, err)
assert.ErrorContains(t, err, "disk full")
mockStore.AssertExpectations(t)
}
ลองคิดดู: จะทำให้ disk เต็มจริง ๆ เพื่อเทสยังไง? ทำไม่ได้ — นี่คือเหตุผลที่ mock มีอยู่ เส้นทางที่พังคือเส้นทางที่เทสยากที่สุดและมีบั๊กเยอะที่สุด
คอร์สปฐมพยาบาลไม่ได้รอให้มีคนหมดสติจริงถึงจะสอน เขาใช้หุ่นฝึก
และหุ่นตัวนี้ทำได้สองอย่าง
· ตั้งสถานการณ์ล่วงหน้าได้ ว่ารอบนี้จะให้เจอเคสไหน
· จำได้ว่าเราทำอะไรกับมันบ้าง กดกี่ครั้ง แรงพอไหม แล้วรายงานตอนจบ
mock คือหุ่นฝึกของโค้ด —
.On(…).Return(…) คือการตั้งสถานการณ์ไว้ก่อน (“ถ้าถูกเรียกแบบนี้ ให้ตอบแบบนี้”)
ส่วน AssertExpectations(t) คือการอ่านรายงานตอนจบ
ว่าที่ตั้งไว้ถูกใช้จริงครบไหม
“disk full” ที่สั่งให้เกิดข้างบน ก็เหมือนครูตั้งหุ่นให้เป็นเคสหนัก
เพื่อดูว่าเราจะรับมือถูกไหม โดยไม่ต้องมีใครเจ็บจริง
ที่ไม่เหมือนกัน: หุ่นฝึกวางเฉย ๆ ก็ยังเป็นหุ่นให้กดได้
แต่ mock ที่ยังไม่ได้ตั้ง .On() ไว้ก่อน พอถูกเรียกจะ panic ทันที
เพราะมันไม่รู้ว่าต้องตอบอะไร (ข้อความ panic ตัวจริงอยู่ในภารกิจท้ายบท ท้าทาย 1)
ตัวช่วยจับคู่ argument และตรวจการเรียก
// จับคู่แบบหลวม ๆ
mockStore.On("Save", mock.AnythingOfType("main.Result")).Return(nil)
mockStore.On("Transfer", 1, mock.Anything, mock.Anything).Return(nil)
// ตรวจหลังเรียกเสร็จ
mockStore.AssertExpectations(t) // ทุกอันที่ On ไว้ ถูกเรียกครบ
mockStore.AssertCalled(t, "Save", mock.Anything) // ถูกเรียกจริง
mockStore.AssertNotCalled(t, "Update") // ไม่ถูกเรียก
mockStore.AssertNumberOfCalls(t, "Save", 2) // ถูกเรียก 2 ครั้งพอดี
// จำกัดจำนวนครั้ง
mockStore.On("Save", mock.Anything).Return(nil).Once()
mockStore.On("Save", mock.Anything).Return(nil).Twice()
mockStore.On("Save", mock.Anything).Return(nil).Times(3)
AnythingOfType ต้องเปลี่ยนตามโปรเจกต์คุณ
รูปแบบคือ "ชื่อpackage.ชื่อType" —
ตัวอย่างข้างบนอยู่ใน package main จึงเขียน "main.Result"
ถ้าโค้ดคุณอยู่ใน package calcdemo ต้องเขียน "calcdemo.Result"
ใส่ผิด = mock หาคู่ไม่เจอ แล้ว panic (ไม่ใช่ fail ธรรมดา)
ไม่ต้องเดา — ปล่อยให้ panic ครั้งแรกไปเลย มันพิมพ์ชื่อเต็มที่ถูกต้องมาให้:
panic:
mock: Unexpected Method Call
-----------------------------
Save(day5check.Result)
0: day5check.Result{A:2, B:3, Sum:5}
The closest call I have is:
Save(mock.anythingOfTypeArgument)
0: "main.Result"
Diff: 0: FAIL: type main.Result != type Result - (day5check.Result={2 3 5})
บรรทัด Save(day5check.Result) คือชื่อจริงที่ควรใส่ —
ก็อปมาวางใน AnythingOfType ได้ตรง ๆ
mock เยอะเกินไป = เทสกลายเป็นการยืนยันว่าโค้ดเรียกฟังก์ชันตามลำดับที่คุณเขียนไว้
ไม่ใช่ยืนยันว่ามันทำงานถูก · พอ refactor ทีเทสแดงหมดทั้งที่พฤติกรรมไม่เปลี่ยน
mock เฉพาะสิ่งที่อยู่นอกการควบคุมของคุณ —
database, HTTP API, ระบบไฟล์, นาฬิกา, ตัวสุ่ม
- dependency injection (DI)
- “ส่ง dependency เข้ามาให้ แทนที่จะสร้างเองข้างใน” — คือสิ่งที่หัวข้อ 6 ทำ
· แบบที่ส่งผ่าน constructor (
NewCalculator(s Store)) เรียกเจาะจงว่า constructor injection · ประโยคที่ใช้ถามได้: “ตรงนี้ inject ผ่าน constructor ได้ไหม จะได้ mock ง่าย” - test double
- คำรวมของ “ตัวแสดงแทนของจริงในเทส” ทุกแบบ · mock คือแบบที่จำได้ว่าถูกเรียกอะไรบ้าง ส่วน stub คือแบบที่แค่ตอบค่าที่ตั้งไว้ ไม่สนใจว่าใครเรียก · ถ้าอยากได้แค่คำตอบ ไม่ได้จะตรวจการเรียก บอกว่า “อันนี้ใช้ stub พอ ไม่ต้อง mock”
- expectation
- สิ่งที่เราตั้งไว้ล่วงหน้าด้วย
.On(…).Return(…)ว่า “คาดว่าจะถูกเรียกแบบนี้” ·AssertExpectations= ตรวจว่า expectation ทุกอันถูกใช้จริง - argument matcher
- ตัวจับคู่ argument แบบไม่ต้องระบุเป๊ะ —
mock.Anything,mock.AnythingOfType(…)คือตัวนี้ · พูดว่า “ใช้ matcher แบบหลวมไปก่อน” ได้เลย - over-mocking
- อาการ mock เยอะเกินจนเทสยืนยันแค่ลำดับการเรียกฟังก์ชัน ไม่ได้ยืนยันพฤติกรรม · เป็นคำที่ senior ใช้ตอนรีวิว: “เทสนี้ over-mock ไปหน่อย”
8 · suite — จัดกลุ่มเทสที่ใช้ setup ร่วมกัน
type UserServiceSuite struct {
suite.Suite // ← embed
store *MockUserStore // สิ่งที่ใช้ร่วมในทุกเทส
svc *UserService
}
func (s *UserServiceSuite) SetupTest() {
s.store = new(MockUserStore) // สร้างใหม่ทุกเทส → ไม่ปนกัน
s.svc = NewUserService(s.store)
}
func (s *UserServiceSuite) TestFindUser_ReturnsUser() {
s.store.On("GetByID", 1).Return(&User{Name: "Bob"}, nil)
user, err := s.svc.FindUser(1)
s.Require().NoError(err) // ⚠️ s.Require() ไม่ใช่ require.X(t, …)
s.Assert().Equal("Bob", user.Name) // ⚠️ ไม่ต้องส่ง t
}
func (s *UserServiceSuite) TestFindUser_PropagatesError() {
s.store.On("GetByID", 2).Return(nil, errors.New("db down"))
_, err := s.svc.FindUser(2)
s.Assert().Error(err)
}
// ต้องมีตัวนี้ ไม่งั้น go test ไม่เห็น suite
func TestUserServiceSuite(t *testing.T) {
suite.Run(t, new(UserServiceSuite))
}
ร้านอาหารมีงานสองระดับ
· งานเปิดร้านตอนเช้า — เปิดแอร์ ติดเตา ทำครั้งเดียวจบ = SetupSuite
(ตอนปิดร้านก็ทำครั้งเดียว = TearDownSuite)
· งานก่อนรับลูกค้าแต่ละโต๊ะ — เช็ดโต๊ะ วางช้อนส้อมชุดใหม่ = SetupTest
(เก็บโต๊ะหลังลูกค้าลุก = TearDownTest)
เหตุผลที่ต้องเซ็ตใหม่ทุกโต๊ะคือ ลูกค้าโต๊ะใหม่ไม่ควรได้ช้อนที่โต๊ะก่อนหน้าใช้ไปแล้ว
เทสก็เหมือนกัน — เทสตัวถัดไปไม่ควรได้ mock ที่ตัวก่อนหน้าใช้ค้างไว้
เพราะ mock จำได้ว่าถูกเรียกไปกี่ครั้งแล้ว ถ้าใช้ตัวเดิมต่อ จำนวนครั้งจะทบกันจนตรวจไม่ตรง
ที่ไม่เหมือนกัน: ร้านอาหารรับลูกค้าตามคิวที่เดินเข้ามา แต่ suite เรียงเทสตามตัวอักษรของชื่อ method ไม่ใช่ตามลำดับที่เขียนในไฟล์ (มีผลรันจริงให้ดูท้ายหัวข้อนี้)
suite.Run จะหยิบมาเป็นเทสเฉพาะ method ที่
ชื่อขึ้นต้นด้วย Test เท่านั้น
(เป็นกฎเดียวกับชื่อฟังก์ชันเทสในบทเรียน 14)
ตั้งชื่อว่า HappyPath() → ไม่ถูกรันเลย
และ go test จะขึ้น PASS เฉย ๆ ไม่มีคำเตือน
เพราะมันไม่รู้ว่าคุณตั้งใจให้เป็นเทส — ผมรันทดสอบแล้ว ได้แค่นี้:
$ go test -v ./...
=== RUN TestUserServiceSuite
--- PASS: TestUserServiceSuite (0.00s)
PASS
ok day5check 0.480s
วิธีเช็ค: รัน -v แล้วนับว่าเห็น
=== RUN TestUserServiceSuite/<ชื่อเทส> ครบทุกตัวไหม
ถ้าเห็นแต่บรรทัดของ suite เปล่า ๆ แบบข้างบน = ไม่มีเทสไหนได้รันเลย
ส่วน SetupTest/TearDownTest เป็นชื่อสงวนของ hook
ไม่ต้องขึ้นต้นด้วย Test
Hook ทั้งหมด
| Method | รันเมื่อ |
|---|---|
SetupSuite() | ครั้งเดียว ก่อนทุกเทสใน suite |
SetupTest() | ก่อนทุกเทส |
BeforeTest(suiteName, testName string) | ก่อนแต่ละเทส — รู้ชื่อเทสด้วย |
AfterTest(suiteName, testName string) | หลังแต่ละเทส |
TearDownTest() | หลังทุกเทส |
TearDownSuite() | ครั้งเดียว หลังทุกเทสจบ |
ผลรันจริงของลำดับ hook:
=== RUN TestUserServiceSuite
SetupSuite: ครั้งเดียวก่อนทุกเทส
=== RUN TestUserServiceSuite/TestFindUser_PropagatesError
SetupTest: ก่อนทุกเทส
TearDownTest: หลังทุกเทส
=== RUN TestUserServiceSuite/TestFindUser_ReturnsUser
SetupTest: ก่อนทุกเทส
TearDownTest: หลังทุกเทส
TearDownSuite: ครั้งเดียวหลังทุกเทสจบ
--- PASS: TestUserServiceSuite
สังเกตอะไรแปลก ๆ ไหม — PropagatesError
รันก่อน ReturnsUser ทั้งที่ในโค้ดผมเขียน ReturnsUser ไว้ก่อน
suite เรียงเทสตามตัวอักษร ไม่ใช่ตามลำดับในไฟล์
→ ห้ามเขียนเทสที่ต้องพึ่งพาลำดับเด็ดขาด
ทุกเทสต้องตั้งต้นเองได้ครบใน SetupTest
- test suite
- กลุ่มเทสที่ใช้ของเตรียมชุดเดียวกัน · ประโยคที่ใช้ได้: “เทสพวกนี้ setup เหมือนกัน รวมเป็น suite ดีไหม”
- setup / teardown (hook)
- “งานก่อนเริ่ม” กับ “งานเก็บกวาดหลังจบ” ที่ testify เรียกให้เองอัตโนมัติ · คำว่า hook แปลว่า จุดที่เราแขวนโค้ดของเราเข้าไปให้ระบบเรียกให้
- fixture
- “ของที่เตรียมไว้ให้เทสใช้” เช่น mock, ข้อมูลตัวอย่าง, การต่อ database ปลอม
· ที่เขียนใน
SetupTestก็คือการสร้าง fixture ใหม่ทุกเทส - test isolation
- “เทสแต่ละตัวต้องไม่กวนกัน” — รันตัวเดียวก็ผ่าน รันพร้อมกันก็ผ่าน สลับลำดับก็ยังผ่าน · ถ้าเทสพังตอนรันรวมแต่ผ่านตอนรันเดี่ยว บอกได้เลยว่า “น่าจะเป็นปัญหา test isolation”
- flaky test
- เทสที่เดี๋ยวผ่านเดี๋ยวแดงทั้งที่โค้ดไม่เปลี่ยน · สาเหตุยอดฮิตคือเทสพึ่งพาลำดับหรือ state ค้างจากเทสก่อน
9 · ทดสอบความจำ
1. assert.Equal(t, 90.0, result) เมื่อ result เป็น int ค่า 90?
คอมไพล์ผ่าน (พารามิเตอร์เป็น any) แต่ตอนรัน
reflect.DeepEqual เทียบทั้ง type และค่า →
expected: float64(90) / actual: int(90)
สไลด์เขียนตัวอย่างนี้ไว้ — แต่มันเทสไม่ผ่านจริง ๆ
แก้เป็น assert.Equal(t, 90, result)
2. require.NoError ต่างจาก assert.NoError ยังไง?
require = t.Fatal · assert = t.Error
สำนวนที่ใช้จริง: require สำหรับเงื่อนไขก่อนหน้า
(ถ้าไม่ผ่านบรรทัดถัดไปจะ panic) แล้วตามด้วย assert หลายบรรทัด
สำหรับตรวจผลลัพธ์
3. ต้องออกแบบโค้ดยังไงถึงจะเอา mock ยัดเข้าไปได้?
ถ้าฟังก์ชันสร้าง dependency เองข้างใน (NewFileStore("/data"))
ก็ไม่มีทางแทนที่ได้ · รับเป็น interface ผ่าน
NewCalculator(s Store) → ยัดของจริงตอน production
ยัด mock ตอนเทส นี่คือ DI จากบทเรียน 10 มาใช้งานจริง
4. testify suite รันเทสในลำดับไหน?
ผมรันแล้วเห็นชัด: TestFindUser_PropagatesError
รันก่อน TestFindUser_ReturnsUser ทั้งที่ในไฟล์เขียนสลับกัน
→ ห้ามเขียนเทสที่พึ่งพาลำดับ
ทุกเทสต้องตั้งต้นเองได้ครบใน SetupTest
10 · ลงมือทำ
ภารกิจ: เขียนเทสที่ไม่ต้องต่อ database
go get github.com/stretchr/testify && go mod tidy- เอาเทสจาก บทเรียน 16
(
Grade) มาแปลงifทุกอันเป็นassert/require— บรรทัดลดลงกี่บรรทัด? - ลอง
assert.Equal(t, "A", grade)โดยที่gradeเป็นtype Grade string(custom type จาก บทเรียน 09) — ผ่านไหม? ทำไม?
สปอยล์ผลรันจริง:expected: string("A")vsactual: Grade("A")→ ไม่ผ่าน เพราะ custom type คือคนละ type · แก้ด้วยassert.Equal(t, Grade("A"), grade) - สร้างโครง DI:
type UserRepo interface { GetByID(id int) (*User, error) }และUserServiceที่รับมันเข้ามาทาง constructor - เขียน
MockUserRepoที่ embedmock.Mock - เขียนสองเทส:
- happy path — mock คืน user → service คืน user
- sad path — mock คืน
errors.New("db down")→ service ต้องส่ง error ต่อขึ้นไป
AssertExpectations(t)ทั้งสองเทส - แปลงทั้งสองเทสเป็น
suiteที่มีSetupTestสร้าง mock ใหม่ทุกครั้ง — อย่าลืมs.Assert()ไม่ใช่assert.X(t, ...) - เพิ่ม
SetupSuite/TearDownSuiteที่t.Logแล้วรัน-vเพื่อดูลำดับ hook และลำดับเทสด้วยตาตัวเอง - ท้าทาย 1 — ดูว่า mock บ่นยังไงเมื่อไม่มีใครสอนมัน:
ในเทส happy path ที่คุณเพิ่งเขียน ให้คอมเมนต์บรรทัด
s.store.On("GetByID", 1).Return(...)ทิ้งไป แต่ยังเรียกs.svc.FindUser(1)เหมือนเดิม → รัน
ต้องได้test panickedไม่ใช่ assertion ที่ fail ธรรมดา เพราะ mock ไม่รู้ว่าจะคืนค่าอะไร — สปอยล์ผลรันจริง (suite จะดักและรายงานเป็น FAIL ให้):
อ่านให้ครบ — มันบอกค่า argument ที่ได้รับจริงมาให้ด้วย (--- FAIL: TestUserServiceSuite (0.00s) --- FAIL: TestUserServiceSuite/TestFindUser_ReturnsUser (0.00s) mock.go:359: test panicked: assert: mock: I don't know what to return because the method call was unexpected. Either do Mock.On("GetByID").Return(...) first, or remove the GetByID() call. This method was unexpected: GetByID(int) 0: 10: 1คือ argument ตัวที่ 0 มีค่า1) เอาไปวางใน.On()ได้ตรง ๆ - ท้าทาย 2: เขียนเทสที่มี
assert.InDeltaเทียบ float แล้วลองเปลี่ยนเป็นassert.Equal— พิสูจน์ปัญหา floating point ด้วยตัวเอง
อ่านต่อจากต้นทาง
แหล่งหลักที่แนะนำ:
pkg.go.dev — testify/assert
มีรายการฟังก์ชันครบทุกตัว กด Ctrl+F หาสิ่งที่ต้องการเร็วกว่าเดา
· ส่วน testify/mock
มีตัวอย่างครบตั้งแต่ On ถึง Run
ข้อควรรู้: testify เป็น library ของบุคคลที่สาม ไม่ใช่ของทีม Go —
บางทีมเลือกไม่ใช้เลยเพราะอยากให้เทสพึ่งพา standard library อย่างเดียว
รู้ทั้งสองแบบไว้ดีที่สุด บทเรียน 15–16 คือแบบไม่ใช้ บทนี้คือแบบใช้
💬 ติดตรงไหนถามได้เลย — เรื่องที่คุ้มถามที่สุดคือ "ควร mock อะไร ไม่ควร mock อะไร" ในโปรเจกต์ของคุณ เพราะเป็นเรื่องที่ทำเกินแล้วเจ็บทีหลัง ถ้ามีโครงโปรเจกต์อยู่แล้วส่งมาได้
อ้างอิง
- testify/assert — รายการฟังก์ชันครบทุกตัว
- testify/require — เวอร์ชันที่หยุดเทส
- testify/mock —
On,Return,Assert* - testify/suite — hook ทั้งหมด
- github.com/stretchr/testify — README มีตัวอย่างครบ
- pkg.go.dev — reflect.DeepEqual — กฎการเทียบที่
assert.Equalใช้