บทเรียน 17 · Day 5 · Testify

Testify: assert, require, mock, suite

~18 นาที · ต้องมี: บทเรียน 16, บทเรียน 10 (interface & DI)

testing เปล่า ๆ ทำงานได้ แต่คุณต้องเขียน if เองทุกครั้ง และเขียนข้อความ error เองทุกครั้ง Testify ยุบสามบรรทัดเหลือหนึ่ง พร้อม diff ที่อ่านรู้เรื่อง — และมี mock กับ suite แถมมาด้วย ซึ่งเป็นของที่ standard library ไม่มีให้

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.Xrequire.X
พฤติกรรมเมื่อไม่ผ่านฟ้องแล้วไปต่อฟ้องแล้วหยุด
เทียบเท่ากับt.Errorft.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 มีอยู่ เส้นทางที่พังคือเส้นทางที่เทสยากที่สุดและมีบั๊กเยอะที่สุด

🧑‍⚕️ เทียบให้เห็นภาพ — หุ่นฝึก CPR ในคอร์สปฐมพยาบาล

คอร์สปฐมพยาบาลไม่ได้รอให้มีคนหมดสติจริงถึงจะสอน เขาใช้หุ่นฝึก และหุ่นตัวนี้ทำได้สองอย่าง
· ตั้งสถานการณ์ล่วงหน้าได้ ว่ารอบนี้จะให้เจอเคสไหน
· จำได้ว่าเราทำอะไรกับมันบ้าง กดกี่ครั้ง แรงพอไหม แล้วรายงานตอนจบ

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 ทุกอย่าง

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 — พลาดแล้วเงียบ

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

  1. go get github.com/stretchr/testify && go mod tidy
  2. เอาเทสจาก บทเรียน 16 (Grade) มาแปลง if ทุกอันเป็น assert/require — บรรทัดลดลงกี่บรรทัด?
  3. ลอง assert.Equal(t, "A", grade) โดยที่ grade เป็น type Grade string (custom type จาก บทเรียน 09) — ผ่านไหม? ทำไม?
    สปอยล์ผลรันจริง: expected: string("A") vs actual: Grade("A") → ไม่ผ่าน เพราะ custom type คือคนละ type · แก้ด้วย assert.Equal(t, Grade("A"), grade)
  4. สร้างโครง DI: type UserRepo interface { GetByID(id int) (*User, error) } และ UserService ที่รับมันเข้ามาทาง constructor
  5. เขียน MockUserRepo ที่ embed mock.Mock
  6. เขียนสองเทส:
    • happy path — mock คืน user → service คืน user
    • sad path — mock คืน errors.New("db down") → service ต้องส่ง error ต่อขึ้นไป
    ปิดท้ายด้วย AssertExpectations(t) ทั้งสองเทส
  7. แปลงทั้งสองเทสเป็น suite ที่มี SetupTest สร้าง mock ใหม่ทุกครั้ง — อย่าลืม s.Assert() ไม่ใช่ assert.X(t, ...)
  8. เพิ่ม SetupSuite/TearDownSuite ที่ t.Log แล้วรัน -v เพื่อดูลำดับ hook และลำดับเทสด้วยตาตัวเอง
  9. ท้าทาย 1 — ดูว่า mock บ่นยังไงเมื่อไม่มีใครสอนมัน: ในเทส happy path ที่คุณเพิ่งเขียน ให้คอมเมนต์บรรทัด s.store.On("GetByID", 1).Return(...) ทิ้งไป แต่ยังเรียก s.svc.FindUser(1) เหมือนเดิม → รัน
    ต้องได้ test panicked ไม่ใช่ assertion ที่ fail ธรรมดา เพราะ mock ไม่รู้ว่าจะคืนค่าอะไร — สปอยล์ผลรันจริง (suite จะดักและรายงานเป็น FAIL ให้):
    --- 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: 1
    อ่านให้ครบ — มันบอกค่า argument ที่ได้รับจริงมาให้ด้วย (0: 1 คือ argument ตัวที่ 0 มีค่า 1) เอาไปวางใน .On() ได้ตรง ๆ
  10. ท้าทาย 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 อะไร" ในโปรเจกต์ของคุณ เพราะเป็นเรื่องที่ทำเกินแล้วเจ็บทีหลัง ถ้ามีโครงโปรเจกต์อยู่แล้วส่งมาได้

อ้างอิง