บทเรียน 28 · Day 7 · Mockery

Mockery — เลิกเขียน mock ด้วยมือ

~17 นาที · ต้องมี: บทเรียน 10 (interface), บทเรียน 17 (testify), บทเรียน 27 (layout + repository)

ตั้งแต่บทเรียน 15 มา คุณจ่ายค่าผ่านทางเดิมทุกครั้งที่อยากเทส service: ประกาศ interface · รับ dependency ทางพารามิเตอร์ · แล้วนั่งเขียน struct ปลอมขึ้นมาเอง
บทนี้เอาขั้นที่สามออก — แต่ไม่ใช่เพราะ "ประหยัดบรรทัด" อย่างที่มักเคลมกัน ตัวเลขจริงบอกตรงข้าม และเหตุผลที่แท้จริงดีกว่านั้นเยอะ

🧰 เตรียมก่อนเริ่ม — เช็คให้ครบก่อนถึงหัวข้อ 4
1) ต้องต่อเน็ตได้ — บทนี้โหลดของจากอินเทอร์เน็ตสองรอบ: ตัวเครื่องมือ mockery และ library testify (บทนี้ไม่ต้องใช้ Docker)
2) go version ต้องได้ Go 1.21 ขึ้นไป
3) รู้จัก go install ก่อน — go install <path>@<version> คือคำสั่งที่ไปดาวน์โหลดซอร์สของ "โปรแกรม" ตัวหนึ่งที่เขียนด้วย Go มาคอมไพล์ แล้ววาง binary (ไฟล์โปรแกรมที่กดรันได้เลย) ไว้ที่ $(go env GOPATH)/bin (ปกติคือ ~/go/bin)
ต่างจาก go get/go mod tidy ตรงที่ มันไม่แตะ go.mod ของโปรเจกต์เรา และไม่ทำให้โปรเจกต์เรามี dependency เพิ่ม — เราแค่ได้คำสั่งใหม่ไว้พิมพ์ใน terminal
4) shell ต้องมองเห็นโฟลเดอร์นั้น ไม่งั้นพิมพ์ mockery แล้วจะเจอ command not found ทั้งที่ติดตั้งสำเร็จ:

$ echo 'export PATH="$PATH:$(go env GOPATH)/bin"' >> ~/.zshrc && source ~/.zshrc

เช็คด้วย which mockery — ต้องได้ path ที่ลงท้ายด้วย /go/bin/mockery (ส่วนหน้าเป็นชื่อ user ของคุณเอง ไม่ต้องตรงกับของผม)

1 · ทบทวน: ราคาที่เราจ่ายมาตลอดบท 15–17

สูตรที่ใช้มาตั้งแต่ บทเรียน 15 จนถึง บทเรียน 17 มีสามชิ้นเสมอ:

  1) interface            2) DI                  3) mock เขียนมือ

  type UserRepository    func NewService(         type FakeUserRepository struct {
      interface {            repo UserRepository      GetByIDFunc  func(...) ...
      GetByID(...)       ) *Service {                 GetByIDCalls []int
      Save(...)              ...                      ...
      CountActive()      }                        }
  }                                               func (f *Fake) GetByID(...) {...}
       ▲                        ▲                 func (f *Fake) Save(...)    {...}
       │                        │                 func (f *Fake) CountActive(){...}
  โค้ดจริงพึ่งพา            เทสยัดของปลอม                     ▲
  "สัญญา" ไม่ใช่ตัวจริง        เข้ามาแทนได้         ชิ้นนี้เราพิมพ์เองทุกตัวอักษร

คำว่า DI ในภาพย่อมาจาก Dependency Injection — แปลตรงตัวว่า "ส่งของที่ต้องใช้เข้าไปให้" คือยื่น repository เข้าไปทางพารามิเตอร์ แทนที่จะให้ service ไปสร้างเอง · เพราะยื่นเข้าไปได้ ตอนเทสเราจึงยัดของปลอมเข้าไปแทนได้

ชิ้นที่ 1 กับ 2 เป็นการออกแบบ — เราได้ประโยชน์จากมันตรง ๆ (บทเรียน 10)
ชิ้นที่ 3 ไม่ใช่การออกแบบ มันคืองานถอดแบบ — อ่าน interface แล้วพิมพ์ struct ที่มี method ชื่อเดียวกัน signature เดียวกัน ครบทุกตัว (signature = พารามิเตอร์ที่รับ กับค่าที่คืน — ส่วนชื่อ method นับแยก ต้องตรงทั้งคู่ถึงจะ implement interface ได้) ถ้าพิมพ์ตกไปตัวหนึ่ง หรือแค่พิมพ์ GetById แทน GetByID compiler ก็ฟ้องว่าไม่ implement interface

นี่คือ mock เขียนมือของ interface ที่มีแค่ 3 method:

// mock เขียนมือ: struct + field เก็บค่า return + field เก็บ arg ที่ถูกเรียก
type FakeUserRepository struct {
    GetByIDFunc     func(id int) (*User, error)
    GetByIDCalls    []int
    SaveFunc        func(u *User) error
    SaveCalls       []*User
    CountActiveFunc func() int
    CountActiveHits int
}

func (f *FakeUserRepository) GetByID(id int) (*User, error) {
    f.GetByIDCalls = append(f.GetByIDCalls, id)
    if f.GetByIDFunc == nil {
        return nil, nil
    }
    return f.GetByIDFunc(id)
}

func (f *FakeUserRepository) Save(u *User) error {
    f.SaveCalls = append(f.SaveCalls, u)
    if f.SaveFunc == nil {
        return nil
    }
    return f.SaveFunc(u)
}

func (f *FakeUserRepository) CountActive() int {
    f.CountActiveHits++
    if f.CountActiveFunc == nil {
        return 0
    }
    return f.CountActiveFunc()
}

35 บรรทัด · 3 method · pattern เดียวกันเป๊ะทั้งสามอัน (บันทึก arg → เช็ค nil → เรียก func)
เพิ่ม method ที่ 4 ในวันหลัง = กลับมาพิมพ์ pattern เดิมอีกรอบ

2 · ความเจ็บปวดที่แท้จริงไม่ใช่ "เยอะ" แต่คือ "มันเงียบ"

ถ้าปัญหามีแค่ "พิมพ์เยอะ" มันก็แค่น่าเบื่อ ไม่ถึงกับอันตราย
แต่ mock เขียนมือมีรูโหว่ที่ใหญ่กว่านั้นมาก — ลองอ่านเทสข้างล่างนี้ก่อน

โค้ดที่กำลังจะเทสมีแค่นี้ — จำไว้ว่ามันเอา Name ที่ repository คืนมา ต่อหน้าด้วย "Mr. ":

type UserRepository interface {
    GetByID(id int) (*User, error)
    Save(u *User) error
    CountActive() int
}

func (s *Service) DisplayName(id int) (string, error) {
    u, err := s.repo.GetByID(id)
    if err != nil {
        return "", err
    }
    return "Mr. " + u.Name, nil
}
// mock เขียนมือ: GetByIDFunc คืน user id 7 เสมอ
func TestManual_WrongArgSilentlyPasses(t *testing.T) {
    repo := &FakeUserRepository{
        GetByIDFunc: func(id int) (*User, error) {
            return &User{ID: 7, Name: "Somchai"}, nil
        },
    }
    svc := NewService(repo)
    got, err := svc.DisplayName(999) // ควรจะเรียกด้วย 7 แต่เรียก 999
    if err != nil {
        t.Fatal(err)
    }
    if got != "Mr. Somchai" {
        t.Fatalf("got %q", got)
    }
    // ไม่มี assert เรื่อง arg
}

โค้ดเรียก repository ด้วย id 999 ทั้งที่ตั้งใจจะเทส id 7 — เทสนี้ผ่านหรือตก?

ผ่านสบาย ๆ:

=== RUN   TestManual_WrongArgSilentlyPasses
--- PASS: TestManual_WrongArgSilentlyPasses (0.00s)
PASS
ok  	example.com/manual/user	0.435s

เพราะ GetByIDFunc คืนค่าเดิมไม่ว่าใครส่ง id อะไรมา mock เขียนมือไม่รู้จักคำว่า "คาดหวัง" มันรู้จักแค่ "คืนค่านี้"
ถ้าอยากให้จับได้ ต้องเขียน assert เรื่อง argument เองทุกครั้ง — นั่นคือเหตุผลที่ field GetByIDCalls []int ถึงมีอยู่ในโค้ดหัวข้อ 1 แต่มีไว้เฉย ๆ ไม่ได้แปลว่ามีคนใช้

นี่คือบั๊กที่ไม่มีใครเห็น — โค้ด production ส่ง id ผิด เทสยังเขียว CI ยังผ่าน
เก็บความรู้สึกนี้ไว้ แล้วไปดูหัวข้อ 10 ว่า mockery เจอกรณีเดียวกันแล้วทำอะไร

🍜 เทียบให้เห็นภาพ — พนักงานร้านที่ไม่ฟังออเดอร์

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

นี่คือ mock เขียนมือในหัวข้อ 1 เป๊ะ ๆ — GetByIDFunc คืน Somchai เสมอ ไม่ว่าโค้ดจะส่ง id 7 หรือ 999 เข้ามา เทสจึงเขียวทั้งที่โค้ดส่งเลขผิด
ส่วนร้านอีกเจ้าที่ทวนออเดอร์ก่อนเสิร์ฟ ("โต๊ะ 7 เส้นเล็กต้มยำ ใช่ไหมครับ") จะทักทันทีที่ของไม่ตรงกับที่จดไว้ — นั่นคือสิ่งที่ mock ของ mockery + testify ทำให้ในหัวข้อ 10

ที่ไม่เหมือนกัน: พนักงานจริงยังเดาใจลูกค้าได้บ้าง แต่ testify เทียบ argument ทุกตัวแบบตรงเป๊ะ ไม่เดาให้เลย ถ้าอยากให้ยืดหยุ่นต้องบอกมันตรง ๆ ด้วย mock.Anything

⚠️ รูโหว่ที่สอง — อยู่ในการเขียน mock อีกสไตล์หนึ่ง
นอกจากแบบ field func ข้างบน ยังมีอีกสไตล์ที่คุณเจอมาแล้วใน บทเรียน 17 — คือฝัง mock.Mock ของ testify ไว้ใน struct แล้วตั้งความคาดหวังด้วย mock.On("GetByID", 7)
สไตล์นี้ดักเรื่อง argument ได้ (ดีกว่าแบบแรก) แต่แลกมาด้วยรูโหว่ใหม่: ชื่อ method อยู่ในเครื่องหมายคำพูด compiler ไม่ได้ตรวจอะไรให้เลย พิมพ์ "GetById" (d เล็ก) ก็ยังคอมไพล์ผ่าน แล้วไปพังตอนรันเป็นข้อความยาว ๆ ที่มือใหม่อ่านไม่ออก

จำรูโหว่นี้ไว้ เดี๋ยวหัวข้อ 8 จะบอกว่า mockery ปิดมันยังไง

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

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

stub
ของปลอมที่ทำหน้าที่แค่ "คืนค่าตามที่สั่งไว้" ไม่สนใจว่าใครเรียกด้วยค่าอะไร — คือ mock เขียนมือในหัวข้อ 1 · ถามให้ตรงคำ: “ตัวนี้เป็น stub เฉย ๆ หรือเป็น mock ที่ตรวจ argument ให้ด้วย”
mock
ของปลอมที่จำได้ว่าถูกเรียกด้วยค่าอะไร กี่ครั้ง และฟ้องเมื่อไม่ตรงกับที่ตั้งไว้ · ในทีมมักเรียกรวม ๆ ว่า "ม็อค" ทั้งสองแบบ เวลาคุยจริงจังให้แยกให้ชัดว่าหมายถึงแบบไหน
expectation
“ความคาดหวัง” ที่ตั้งไว้ล่วงหน้าว่า method ไหนจะถูกเรียกด้วยค่าอะไร · พูดว่า “expectation ตัวนี้ไม่ถูกเรียก” จะสื่อกว่าพูดว่า “เทสมันตกแปลก ๆ”
assert / assertion
บรรทัดที่ตรวจว่าผลลัพธ์ตรงกับที่ควรจะเป็นไหม เช่น assert.Equal · ประโยคที่อธิบายหัวข้อ 2 ได้ทั้งหัวข้อ: “เทสนี้ผ่านเพราะไม่มี assertion เรื่อง argument เลยสักบรรทัด”

ก่อนไปต่อ ขอปูคำเดียวที่จะใช้ทั้งบท: code generation (การสร้างโค้ดอัตโนมัติ)
mockery ไม่ใช่ library ที่เรา import แล้วมันเสกของปลอมให้ตอนโปรแกรมรัน แต่เป็น โปรแกรมแยกอีกตัวที่เรารันเองจาก terminal มันอ่านไฟล์ .go ของเรา เห็นว่ามี interface ชื่ออะไร มี method อะไรบ้าง แล้ว เขียนไฟล์ .go ใหม่ขึ้นมาให้ ซึ่งหน้าตาเหมือนเรานั่งพิมพ์เองทุกตัวอักษร

🔑 เทียบให้เห็นภาพ — ร้านทำกุญแจสำรองหน้าปากซอย

คุณเอาลูกกุญแจต้นฉบับไปให้ร้าน เขาวางทาบกับเครื่อง แล้วฝนออกมาเป็นดอกใหม่ที่ร่องตรงกันเป๊ะ
· ลูกกุญแจต้นฉบับ = interface ของเรา
· เครื่องฝนกุญแจ = คำสั่ง mockery ที่เรารันเองจาก terminal
· ดอกใหม่ที่ได้มา = ไฟล์ mocks_test.go

และมีอีกสองอย่างที่ตรงกันพอดี
· ถ้าต้นฉบับเปลี่ยนร่องใหม่ ดอกสำรองที่ฝนไว้รอบก่อนก็ไม่ตรงกับต้นฉบับอีกต่อไป ต้องเอาต้นฉบับใบใหม่ไปฝนซ้ำ — เหมือนแก้ interface แล้วต้องรัน mockery ซ้ำ
· ไม่มีใครเอาตะไบมาแต่งกุญแจสำรองเอง เพราะรอบหน้าเขาก็ฝนจากต้นฉบับใหม่อยู่ดี — เหมือนบรรทัด DO NOT EDIT ที่อยู่หัวไฟล์

ที่ไม่เหมือนกัน: กุญแจสำรองเปิดบ้านหลังเดียวกันได้จริง แต่ mock ที่ได้มาไม่ได้ทำงานจริงสักอย่าง — ไม่ต่อฐานข้อมูล ไม่เซฟอะไรทั้งนั้น มันแค่หน้าตาเหมือนพอที่จะยัดเข้าไปแทนของจริงตอนเทสได้

จำสามข้อนี้ไว้ เดี๋ยวมันจะอธิบายเกือบทุกเหตุการณ์ในบทนี้:

  1. ไฟล์ที่ได้เป็นโค้ด Go ธรรมดา เปิดอ่านได้ คอมไพล์รวมกับโปรเจกต์ตามปกติ
  2. มันคือภาพนิ่ง ณ เวลาที่รัน — แก้ interface แล้วไฟล์เก่าไม่อัปเดตให้เอง ต้องรัน mockery ใหม่
  3. เราไม่แก้ไฟล์นั้นด้วยมือ เพราะรันครั้งหน้ามันจะถูกเขียนทับทิ้ง
🗣 ศัพท์ที่เพิ่งเรียนไป
code generation (codegen)
“การให้เครื่องเขียนโค้ดให้” จากต้นแบบที่เรามีอยู่แล้ว · คำถามแรกที่ควรถามเวลาเปิดไฟล์แปลก ๆ ในโปรเจกต์คนอื่น: “ไฟล์นี้ generate มาหรือคนเขียนเอง”
generated code
ไฟล์ที่เครื่องเขียนให้ · สังเกตจากบรรทัด DO NOT EDIT หัวไฟล์ · เวลาเห็นเพื่อนกำลังจะแก้ไฟล์พวกนี้ให้ทัก: “อันนั้นเป็น generated code นะ แก้ไปเดี๋ยวก็โดนเขียนทับ ไปแก้ที่ต้นทางดีกว่า”
source of truth
“ต้นฉบับที่ยึดเป็นหลัก” — ในเรื่องนี้คือ interface ของเรา · ประโยคที่ใช้ได้เลย: “interface คือ source of truth ไฟล์ mock เป็นแค่ผลลัพธ์”
CLI tool (ไม่ใช่ library)
โปรแกรมที่พิมพ์เรียกใน terminal ต่างจาก library ที่เรา import เข้ามาในโค้ด · พูดว่า “mockery เป็น CLI tool ไม่ใช่ library” อีกฝ่ายจะเข้าใจทันทีว่าเราเรียกมันยังไง
ส่วนเหตุผลที่มันไม่โผล่ใน go.mod ของเรา คือเราติดตั้งมันด้วย go install <path>@<version> ซึ่งไม่แตะ go.mod และเราก็ไม่ได้ import มันในโค้ด
(ระวังพูดผิด: การเป็น CLI tool ไม่ได้แปลว่าอยู่ใน go.mod ไม่ได้ — Go รุ่นใหม่ ๆ มี go get -tool ที่เติมบรรทัด tool ... ลง go.mod ให้ track เครื่องมือไว้กับโปรเจกต์ได้ แต่ไม่ใช่ทางที่บทนี้ใช้)

3 · ส่วนประกอบของ mockery — สไลด์บอก 3 แต่จริง ๆ มี 4

#ชิ้นส่วนหน้าที่
1interface ในโค้ดต้นแบบ — mockery อ่านจากตรงนี้ ไม่มี interface ก็ไม่มีอะไรให้ generate
2.mockery.ymlบอกว่าอ่าน package ไหน · เขียนไฟล์ที่ไหน · ตั้งชื่ออะไร
3คำสั่ง mockeryรันแล้วได้ไฟล์ mock ออกมา
4github.com/stretchr/testifyที่สไลด์ไม่ได้พูดถึง — mock ที่ generate ออกมา import testify แต่ mockery ไม่เติม go.mod ให้

ชิ้นที่ 4 ต้องเติมเอง ด้วยคำสั่งที่คุ้นจาก บทเรียน 17:

$ go mod tidy
go: finding module for package github.com/stretchr/testify/mock
go: finding module for package github.com/stretchr/testify/assert
go: found github.com/stretchr/testify/assert in github.com/stretchr/testify v1.12.0
go: found github.com/stretchr/testify/mock in github.com/stretchr/testify v1.12.0
go: downloading github.com/stretchr/objx v0.5.3

4 · ติดตั้ง — และคำสั่งสองอันที่สไลด์เขียนผิด

$ go install github.com/vektra/mockery/v3@latest
$ which mockery
/Users/tuwanon/go/bin/mockery

binary ชื่อ mockery เฉย ๆ ไม่มี v3 ต่อท้าย
ทีนี้เช็คเวอร์ชัน — และนี่คือจุดแรกที่สไลด์พาหลงทาง:

❌ ตามสไลด์

$ mockery --version
Error: unknown flag: --version
Usage:
  mockery [flags]
  mockery [command]
... (ตามด้วยรายการ command ทั้งหมด)

exit 1 ตั้งแต่ก้าวแรก — แต่มันใจดีพอที่จะพิมพ์รายการคำสั่งที่มีจริงตามมาให้ ในนั้นมี version อยู่ด้วย

✅ ของจริง

$ mockery version
v3.7.3

version เป็น subcommand ไม่ใช่ flag

จุดที่สองอยู่ตรงคำสั่ง generate — ลองดู help ทั้งหมดที่ v3 มี:

$ mockery --help
Generate mock objects for your Go interfaces

Usage:
  mockery [flags]
  mockery [command]

Available Commands:
  completion  Generate the autocompletion script for the specified shell
  help        Help about any command
  init        Generate a basic .mockery.yml file
  migrate     Migrate v2 config to v3.
  showconfig  Show the yaml config
  version     Print the version of mockery

Flags:
      --config string      config file to use
  -h, --help               help for mockery
      --log-level string   Level of logging

⚠️ ไม่มีคำว่า generate ในรายการนั้นเลย

$ mockery generate mock
Error: unknown command "generate" for "mockery"
Run 'mockery --help' for usage.

การ generate ใน v3 คือรัน mockery เปล่า ๆ — บรรทัด Usage: mockery [flags] ในหน้า help คือสิ่งที่กำลังบอกอยู่
สังเกตว่ามันบอกวิธีให้แล้วตั้งแต่บรรทัดที่ 4 แต่คนอ่านมักกวาดสายตาไปที่รายการ command แทน

5 · mockery init — argument ที่ใส่ผิดกันบ่อยที่สุด

$ mockery init --help
This command generates a basic .mockery.yml file that can be used as a
starting point for your config.

Usage:
  mockery init [module_name] [flags]

คำในวงเล็บคือ module_name ไม่ใช่ package_name — ต้องเป็น module path จากบรรทัดแรกของ go.mod (บทเรียน 27) ไม่ใช่ชื่อ package ย่อยอย่าง user หรือ repository
และมันบังคับหนึ่งตัวเสมอ:

$ mockery init
Error: accepts 1 arg(s), received 0
$ mockery init example.com/shop
INF writing to file file=.mockery.yml version=v3.7.3
INF done version=v3.7.3

ชื่อไฟล์มีจุดนำหน้า: .mockery.yml
ls เปล่า ๆ มองไม่เห็น ต้อง ls -a — คนจำนวนไม่น้อยรันคำสั่งซ้ำเพราะคิดว่ามันไม่ทำงาน
และ mockery ค้นหา config ขึ้นไปตาม parent directory รันจากใน sub-package ก็ยังเจอไฟล์ที่ root:

$ cd app/user && mockery
INF Starting mockery config-file=/.../app/.mockery.yml version=v3.7.3

⚠️ ชื่อไฟล์ต้องมีจุด ห้ามพลาด — v3.7.3 มองหาเฉพาะ .mockery.yml และ .mockery.yaml เท่านั้น

=== ชื่อไฟล์: mockery.yml ===
FTL app failed error="getting config: discovering mockery config: file not found"
=== ชื่อไฟล์: .mockery.yaml ===
INF Starting mockery config-file=/.../app/.mockery.yaml version=v3.7.3
=== ชื่อไฟล์: mockery.yaml ===
FTL app failed error="getting config: discovering mockery config: file not found"

ถ้าสไลด์ของคุณเขียนว่า mockery.yml (ไม่มีจุด) — นั่นคือชื่อที่ mockery หาไม่เจอ ถ้าจำเป็นต้องใช้ชื่อนั้นจริง ๆ ให้ระบุเอง: mockery --config mockery.yml

6 · config ที่ init สร้างจริง — 27 บรรทัด ไม่ใช่ 8 ฟิลด์

📄 อ่าน YAML ให้ออกก่อน — สามข้อพอ
• หนึ่งบรรทัดคือ ชื่อ: ค่า เช่น recursive: false (ไม่มีเครื่องหมายคำพูด = ตัวเลข/true/false · มีเครื่องหมายคำพูด = ข้อความ)
• การเว้นวรรคหน้าบรรทัด (ย่อหน้า) แปลว่า "อันนี้อยู่ข้างในของบรรทัดบนที่ย่อหน้าน้อยกว่า" เหมือน field ที่ซ้อนอยู่ใน struct — ใช้ space เท่านั้น ห้าม Tab
• คอมเมนต์ขึ้นต้นด้วย # ไม่ใช่ //

ส่วน {{ ... }} ไม่ใช่ข้อความตรงตัว แต่เป็น ช่องว่างที่ mockery เติมค่าให้เองตอนรัน (Go template — เจอครั้งแรกในบทนี้):
• {{.InterfaceDir}} → โฟลเดอร์ของ interface ตัวที่กำลัง generate อยู่
• {{.InterfaceName}} → ชื่อ interface เช่น UserRepository
• {{.SrcPackageName}} → ชื่อ package ของโค้ดต้นทาง เช่น user
ดังนั้น structname: '{{.Mock}}{{.InterfaceName}}' แปลว่า "เอาคำว่า Mock ต่อหน้าชื่อ interface" = MockUserRepository

$ cat .mockery.yml
all: false
dir: '{{.InterfaceDir}}'
filename: mocks_test.go
force-file-write: true
formatter: goimports
formatter-options:
  goimports:
    all-errors: false
    comments: true
    format-only: true
    fragment: false
    local-prefix: ""
    tab-indent: true
    tab-width: 8
generate: true
include-auto-generated: false
log-level: info
structname: '{{.Mock}}{{.InterfaceName}}'
pkgname: '{{.SrcPackageName}}'
recursive: false
require-template-schema-exists: true
template: testify
template-schema: '{{.Template}}.schema.json'
packages:
  example.com/shop:
    config:
      all: true

อ่านทีละบรรทัดเฉพาะตัวที่เปลี่ยนชีวิตคุณจริง ๆ:

ฟิลด์ค่าที่ init ให้แปลว่าอะไร
templatetestifyให้ mock ที่ต่อยอดจาก mock.Mock ของ testify — คือของที่คุณรู้จักแล้วจากบท 17
dir{{.InterfaceDir}}วางไฟล์ mock ในโฟลเดอร์เดียวกับ interface ไม่ใช่โฟลเดอร์ mocks/ กลาง
filenamemocks_test.goลงท้าย _test.go → package อื่นมองไม่เห็น (ดูกับดักหัวข้อ 9)
pkgname{{.SrcPackageName}}ไฟล์ mock อยู่ package เดียวกับโค้ดต้นทาง → เรียกใช้ได้โดยไม่ต้อง import
structname{{.Mock}}{{.InterfaceName}}interface ชื่อ UserRepository → struct ชื่อ MockUserRepository
packagesmodule root + all: trueอ่าน interface ทุกตัวที่เจอใน package นั้น
force-file-writetrueอนุญาตเขียนทับไฟล์ mock เดิม — ถ้าเป็น false จะตายด้วย error="outfile exists"
recursivefalsefalse = มองหา interface เฉพาะในโฟลเดอร์ที่ระบุใน packages ตรง ๆ เท่านั้น ไม่ไล่ลงไปในโฟลเดอร์ย่อย · true = ไล่ลงไปทุกโฟลเดอร์ย่อยใต้ package นั้นด้วย — ตัวที่ทำให้ config เริ่มต้นใช้ไม่ได้กับโปรเจกต์จริง อ่านต่อหัวข้อ 7

สังเกตว่า all ปรากฏสองที่ และค่าไม่เหมือนกัน
บนสุดคือ all: false · ส่วน all: true อยู่ใต้ packages.example.com/shop.config
โครงของ config คือ ค่า default ระดับบนสุด แล้ว override รายแพ็กเกจข้างล่าง — ถ้าอ่านเจอ all แล้วไม่ดูว่าอยู่ระดับไหน จะสรุปผิดทันที

7 · รัน mockery แล้วรู้ได้ยังไงว่าสำเร็จ

คำตอบที่คนส่วนใหญ่ใช้คือ exit code — และมันใช้ไม่ได้กับเครื่องมือตัวนี้

❌ log แบบนี้ = ไม่ได้อะไรเลย

INF Starting mockery config-file=...
INF Parsing configured packages...
INF Done parsing configured packages.
EXITCODE=0

"Done" + exit 0 แต่ไม่มีไฟล์ mock เกิดขึ้นเลย

✅ log แบบนี้ = สำเร็จจริง

INF Parsing configured packages...
INF Done parsing configured packages.
INF adding interface to collection
    interface=UserRepository
INF Executing template file=.../mocks_test.go
INF Writing template to file file=.../mocks_test.go

บรรทัด adding interface to collection คือหลักฐานเดียว

⚠️ กับดักที่ร้ายที่สุดของบทนี้ — ผมทดลอง 3 แบบ: (ก) ชี้ที่ module root ที่ไม่มีไฟล์ .go (ข) ชี้ที่ example.com/shop/repository ที่ไม่มีอยู่จริง (ค) ชี้ที่ github.com/vektra/doesnotexist123 ที่ไม่มีในโลก
mockery v3.7.3 ไม่ error สักกรณี — exit 0 ทั้งสามแบบ
และที่แสบคือ mockery init <module> สร้าง config ชี้ module root มาให้เอง ซึ่งเป็นกรณี (ก) พอดี — ถ้า interface ของคุณอยู่ใน sub-package (คือแทบทุกโปรเจกต์จริง) คุณจะเจอกับดักนี้ตั้งแต่ครั้งแรกที่รัน

🔦 เทียบให้เห็นภาพ — ฝากหาของ แต่สั่งว่าห้ามเปิดห้องอื่น

คุณฝากเพื่อนหากรรไกรในบ้าน แล้วบอกไปว่า "ดูแค่ในห้องโถงนะ ห้ามเปิดห้องอื่น"
กรรไกรอยู่ในห้องครัว เพื่อนเดินดูห้องโถงจนครบ แล้วกลับมาบอกว่า "ดูเสร็จแล้ว"
ไม่ได้โกหกสักคำ ทำตามที่สั่งเป๊ะ ๆ แต่คุณก็ไม่ได้กรรไกร

· packages: example.com/shop = "ห้องโถง" (โฟลเดอร์บนสุด ที่มักมีแต่ go.mod กับโฟลเดอร์ย่อย)
· recursive: false = คำสั่ง "ห้ามเปิดห้องอื่น"
· interface ของเราอยู่ในโฟลเดอร์ย่อย user/ = ห้องครัว ที่ไม่มีใครเข้าไปดู
· recursive: true = อนุญาตให้เปิดห้องย่อยทุกห้อง

ที่ไม่เหมือนกัน: เพื่อนยังถามกลับได้ว่า "ห้องโถงไม่มีนะ ให้ดูห้องอื่นไหม" แต่ mockery ไม่ถามและไม่เตือน — จบด้วย exit 0 เหมือนสำเร็จ หลักฐานว่าเจอของจริงมีทางเดียวคือบรรทัด adding interface to collection

แก้ได้สองทาง ยืนยันแล้วทั้งคู่ — ทางที่ผมชอบกว่าคือเติมบรรทัดเดียว:

packages:
  example.com/shop:
    config:
      all: true
      # ← บรรทัดที่เปลี่ยน "รันแล้วเงียบ" เป็น "ใช้งานได้"
      recursive: true

(คอมเมนต์ในไฟล์ YAML ขึ้นต้นด้วย # ไม่ใช่ // แบบ Go — ใส่ // ลงไปจะกลายเป็นส่วนหนึ่งของค่า แล้ว recursive จะไม่ทำงาน)

เหตุผลที่ต้องเติม: mockery init เขียน key เป็น module root (example.com/shop) ซึ่งคือโฟลเดอร์บนสุด ที่มักมีแต่ go.mod กับโฟลเดอร์ย่อย ไม่มีไฟล์ .go เลย พอ recursive: false mockery จึงมองแค่ชั้นนั้นชั้นเดียว ไม่เจอ interface ที่อยู่ในโฟลเดอร์ย่อย user/ แล้วก็จบงานเงียบ ๆ

$ mockery
INF adding interface to collection collection=/.../app/user/mocks_test.go
    interface=UserRepository package-path=example.com/shop/user version=v3.7.3
INF Writing template to file file=/.../app/user/mocks_test.go

อีกทางคือเปลี่ยน key ให้เป็น package path เต็ม เช่น example.com/shop/user: แทน module root — ตรงกว่าแต่ต้องมาเติมทุกครั้งที่มี package ใหม่

🗣 ศัพท์ที่เพิ่งเรียนไป
exit code
เลขที่โปรแกรมคืนให้ terminal ตอนจบงาน · 0 = ปกติ, ไม่ใช่ 0 = มีปัญหา · CI ใช้เลขนี้ตัดสินว่างานผ่านหรือไม่ผ่าน · ประโยคที่ใช้ได้เลย: “เครื่องมือตัวนี้คืน exit 0 ทั้งที่ไม่ได้ทำอะไรให้เลย”
fail silently (silent failure)
“พังเงียบ ๆ” — ไม่สำเร็จ แต่ไม่มีข้อความเตือนใด ๆ · เวลาแจ้งปัญหาให้พูดว่า “มัน fail silently” อีกฝ่ายจะรู้ทันทีว่าต้องไปหาหลักฐานจาก log แทนที่จะเชื่อ exit code
module path กับ package path
module path = ชื่อโปรเจกต์ทั้งก้อน (บรรทัดแรกของ go.mod) · package path = ที่อยู่ของโฟลเดอร์ใดโฟลเดอร์หนึ่งข้างใน เช่น example.com/shop/user · ถามให้ตรงคำ: “key ใน config ตัวนี้เป็น module path หรือ package path”
recursive
“ไล่ลงไปในโฟลเดอร์ย่อยทุกชั้น” · คำเดียวกันนี้เจอในเครื่องมืออื่นเต็มไปหมด — cp -r · rm -rf · grep -r ความหมายเดียวกันหมด

8 · mock ที่ได้ออกมา หน้าตาเป็นยังไง

ไฟล์ที่ generate มา 193 บรรทัด ขึ้นต้นด้วยบรรทัดที่บอกชัดว่าอย่าไปแก้:

// Code generated by mockery; DO NOT EDIT.
// github.com/vektra/mockery
// template: testify

package user

import (
    mock "github.com/stretchr/testify/mock"
)

// NewMockUserRepository creates a new instance of MockUserRepository. It also
// registers a testing interface on the mock and a cleanup function to assert
// the mocks expectations.
func NewMockUserRepository(t interface {
    mock.TestingT
    Cleanup(func())
}) *MockUserRepository {
    mock := &MockUserRepository{}
    mock.Mock.Test(t)

    t.Cleanup(func() { mock.AssertExpectations(t) })

    return mock
}

type MockUserRepository struct {
    mock.Mock
}

อ่านบรรทัดพารามิเตอร์ก่อน มันหน้าตาแปลกกว่า interface ที่เคยเจอใน บทเรียน 10: t interface { mock.TestingT; Cleanup(func()) } คือการ ประกาศ interface คาไว้ตรงนั้นเลยโดยไม่ตั้งชื่อ อ่านว่า "รับอะไรก็ได้ที่ทำได้สองอย่าง: ทำสิ่งที่ mock.TestingT ต้องการ (Errorf, FailNow) และมี method Cleanup(func())"
แล้วอะไรที่เข้าเงื่อนไขนี้? *testing.T ที่คุณใช้มาตั้งแต่ บทเรียน 15 นั่นเอง — ตาม duck typing ของบทเรียน 10 มันเข้าเงื่อนไขโดยไม่ต้องประกาศอะไรเพิ่ม เวลาใช้จริงจึงเขียนแค่ NewMockUserRepository(t)
เหตุผลที่ mockery เขียนแบบนี้แทนที่จะรับ *testing.T ตรง ๆ คือจะได้รับ *testing.B (benchmark) หรือ mock ตัวอื่นได้ด้วย

✅ ของฟรีชิ้นที่ 1 — ไม่ต้องเขียน AssertExpectations เองแล้ว

บรรทัด t.Cleanup(func() { mock.AssertExpectations(t) }) อยู่ใน constructor
แปลว่าทุกเทสที่สร้าง mock ด้วย NewMockUserRepository(t) จะถูกตรวจให้อัตโนมัติตอนจบเทส ว่า expectation ที่ตั้งไว้ถูกเรียกครบไหม
ถ้าคุณเคยพิมพ์ defer repo.AssertExpectations(t) ตามสไตล์ v2 — ใน v3 เป็นการเรียกซ้ำโดยไม่จำเป็น
นี่คือ t.Cleanup ตัวเดียวกับที่คุณเจอใน บทเรียน 15

ถัดมาคือ method จริงที่ implement interface ให้:

func (_mock *MockUserRepository) GetByID(id int) (*User, error) {
    ret := _mock.Called(id)

    if len(ret) == 0 {
        panic("no return value specified for GetByID")
    }
    ...
}

และชั้น EXPECT() ที่เป็นพระเอกของ v3:

func (_m *MockUserRepository) EXPECT() *MockUserRepository_Expecter {
    return &MockUserRepository_Expecter{mock: &_m.Mock}
}

// GetByID is a helper method to define mock.On call
//   - id int
func (_e *MockUserRepository_Expecter) GetByID(id any) *MockUserRepository_GetByID_Call {
    return &MockUserRepository_GetByID_Call{Call: _e.mock.On("GetByID", id)}
}

func (_c *MockUserRepository_GetByID_Call) Return(user *User, err error) *MockUserRepository_GetByID_Call {
    _c.Call.Return(user, err)
    return _c
}

func (_c *MockUserRepository_GetByID_Call) RunAndReturn(run func(id int) (*User, error)) *MockUserRepository_GetByID_Call {
    _c.Call.Return(run)
    return _c
}
✅ ของฟรีชิ้นที่ 2 — ชื่อ method กลับมาให้ compiler ตรวจ

ดูบรรทัดในสุด: _e.mock.On("GetByID", id)
EXPECT() คือ syntactic sugar ที่ห่อ mock.On("ชื่อ method", args...) เอาไว้
syntactic sugar = ท่าเขียนที่สั้นกว่า แต่ข้างในทำงานเหมือนของเดิมทุกอย่าง
· ส่วนที่ปลอดภัยกว่าในเคสนี้ ไม่ได้มาจากความเป็น sugar — มาจากการที่ EXPECT() เป็น method จริงที่ compiler ตรวจชื่อให้ (sugar ตัวอื่นไม่ได้แถมความปลอดภัยมาด้วยเสมอไป)
string ยังอยู่ แต่ย้ายไปอยู่ในไฟล์ที่เครื่องสร้าง ไม่ใช่ไฟล์ที่คุณพิมพ์
ฝั่งที่คุณเขียนกลายเป็น repo.EXPECT().GetByID(7) ซึ่งเป็น method จริง → พิมพ์ชื่อผิดคือ compile error
ส่วน .Return(user *User, err error) เป็น type-safe เต็มตัว (type-safe = compiler ตรวจชนิดข้อมูลให้ตั้งแต่ยังไม่ได้รัน) ใส่ประเภทผิดไม่ผ่านตั้งแต่ compile

ทำไม argument ของ EXPECT().GetByID(id any) ถึงเป็น any ไม่ใช่ int?
เพราะต้องเปิดช่องให้ส่ง mock.Anything และ mock.MatchedBy(...) เข้าไปได้ (บทเรียน 17) ซึ่งไม่ใช่ int
แลกกันตรงนี้: ยืดหยุ่นเรื่อง matcher (matcher = เงื่อนไขที่ใช้ตัดสินว่า argument ที่โค้ดส่งมาตรงกับที่เราตั้งไว้ไหม — จะตรงเป๊ะอย่างใส่ 7 ลงไปตรง ๆ ก็ได้ หรือหลวม ๆ อย่าง mock.Anything กับ mock.MatchedBy(...) ก็ได้ · ทั้งหมดเข้าช่อง id any ช่องเดียวกัน) แต่ไม่ได้ type-check ฝั่ง argument

9 · เขียนเทสจริงด้วย mock ที่ได้มา

นี่คือ interface ต้นทางทั้งหมด — 3 method เท่าที่เห็นในหัวข้อ 1:

type UserRepository interface {
    GetByID(id int) (*User, error)
    Save(u *User) error
    CountActive() int
}

func (s *Service) DisplayName(id int) (string, error) {
    u, err := s.repo.GetByID(id)
    if err != nil {
        return "", err
    }
    return "Mr. " + u.Name, nil
}

func (s *Service) Register(name, email string) error {
    if email == "" {
        return errors.New("email required")
    }
    return s.repo.Save(&User{Name: name, Email: email})
}

และนี่คือเทส 5 เคสที่รันผ่านจริง — สังเกตว่าไม่มี AssertExpectations สักบรรทัด:

func TestDisplayName_OK(t *testing.T) {
    repo := NewMockUserRepository(t)
    repo.EXPECT().GetByID(7).Return(&User{ID: 7, Name: "Somchai"}, nil)

    svc := NewService(repo)
    got, err := svc.DisplayName(7)

    assert.NoError(t, err)
    assert.Equal(t, "Mr. Somchai", got)
}

func TestDisplayName_RepoError(t *testing.T) {
    repo := NewMockUserRepository(t)
    repo.EXPECT().GetByID(99).Return(nil, ErrNotFound)

    svc := NewService(repo)
    _, err := svc.DisplayName(99)

    assert.ErrorIs(t, err, ErrNotFound)   // errors.Is จากบทเรียน 12
}

func TestRegister_UsesMatcher(t *testing.T) {
    repo := NewMockUserRepository(t)
    repo.EXPECT().Save(mock.MatchedBy(func(u *User) bool {
        return u.Email == "a@b.com"
    })).Return(nil)

    svc := NewService(repo)
    assert.NoError(t, svc.Register("Ann", "a@b.com"))
}

func TestRegister_NoEmail_RepoNeverCalled(t *testing.T) {
    repo := NewMockUserRepository(t)
    // ไม่ตั้ง expectation เลย
    svc := NewService(repo)
    err := svc.Register("Ann", "")
    assert.Error(t, err)
    repo.AssertNotCalled(t, "Save", mock.Anything)
}

func TestOldStyle_OnStillWorks(t *testing.T) {
    repo := NewMockUserRepository(t)
    repo.On("GetByID", 1).Return(&User{ID: 1, Name: "Old"}, nil)

    svc := NewService(repo)
    got, err := svc.DisplayName(1)
    assert.NoError(t, err)
    assert.Equal(t, "Mr. Old", got)
}
$ go test ./... -v
=== RUN   TestDisplayName_OK
--- PASS: TestDisplayName_OK (0.00s)
=== RUN   TestDisplayName_RepoError
--- PASS: TestDisplayName_RepoError (0.00s)
=== RUN   TestRegister_UsesMatcher
--- PASS: TestRegister_UsesMatcher (0.00s)
=== RUN   TestRegister_NoEmail_RepoNeverCalled
--- PASS: TestRegister_NoEmail_RepoNeverCalled (0.00s)
=== RUN   TestOldStyle_OnStillWorks
--- PASS: TestOldStyle_OnStillWorks (0.00s)
PASS
ok  	example.com/shop/user	0.245s

เคสสุดท้ายพิสูจน์ว่า On() แบบเดิมยังใช้ได้ — ปนกับ EXPECT() ในไฟล์เดียวได้ด้วย
เพราะ EXPECT() ก็แค่ห่อ On() ไว้ (หัวข้อ 8) ไม่ต้องรีบไปแก้เทสเก่าทั้งหมด เขียนของใหม่ด้วย EXPECT() ก็พอ

10 · กับดัก — อาการที่ทำให้มือใหม่งงที่สุด

เทสตกทั้งที่โค้ด production ถูก · error ชี้ไปที่ไฟล์ที่เราไม่ได้เขียน · ขึ้นมาสามก้อนพร้อมกัน — นี่คือกับดักที่คุณจะเจอบ่อยที่สุด

func TestTrap_WrongArgument(t *testing.T) {
    repo := NewMockUserRepository(t)
    repo.EXPECT().GetByID(7).Return(&User{ID: 7, Name: "Somchai"}, nil)

    svc := NewService(repo)
    _, _ = svc.DisplayName(8)   // ← ส่ง 8 แต่ตั้ง expectation ไว้ที่ 7
}

DisplayName ทำงานถูกต้องทุกประการ · GetByID ก็ถูกเรียกจริง · แค่ค่าไม่ตรงกับที่ตั้งไว้
เทสนี้ได้ output อะไร? (เทียบกับเคสเดียวกันของ mock เขียนมือในหัวข้อ 2 ที่ผ่านฉลุย)

=== RUN   TestTrap_WrongArgument
    mock.go:361:

        mock: Unexpected Method Call
        -----------------------------

        GetByID(int)
        		0: 8

        The closest call I have is:

        GetByID(int)
        		0: 7


        Diff: 0: FAIL:  (int=8) != (int=7)
        at: [/.../app/user/mocks_test.go:84 /.../app/user/user.go:29 /.../app/user/fail2_test.go:11]
    mocks_test.go:20: FAIL:	GetByID(int)
        		at: [/.../app/user/mocks_test.go:118 /.../app/user/fail2_test.go:8]
    mocks_test.go:20: FAIL: 0 out of 1 expectation(s) were met.
        	The code you are testing needs to make 1 more call(s).
--- FAIL: TestTrap_WrongArgument (0.00s)
FAIL
FAIL	example.com/shop/user	0.440s

บั๊กเดียวกับหัวข้อ 2 เป๊ะ ๆ แต่คราวนี้จับได้ — ไม่ต้องเขียน assert เรื่อง argument เองสักบรรทัด

วิธีอ่านทีละบรรทัด:

  • mock: Unexpected Method Call — testify จับคู่ call ด้วยการเทียบ argument ทุกตัวแบบตรงเป๊ะ ไม่ตรง = ถือว่าเป็น call ที่ไม่คาดคิด
  • The closest call I have is — expectation ที่ใกล้เคียงที่สุดที่คุณตั้งไว้
  • Diff: 0: FAIL: (int=8) != (int=7) — บรรทัดที่ต้องอ่าน · 0 คือ index ของ argument · ซ้ายคือค่าที่โค้ดส่งจริง ขวาคือค่าที่คุณตั้งไว้
  • at: [... user.go:29 ... fail2_test.go:11] — ตัวสุดท้ายในวงเล็บคือบรรทัดในเทสของคุณ ตัวก่อนหน้าคือบรรทัดในโค้ด production ที่เรียก

ทำไมถึงมี FAIL สามก้อน?
testify เจอ call ที่ไม่คาดคิด → เรียก t.FailNow (panic) หยุดเทสตรงนั้น → t.Cleanup จาก constructor ทำงานต่อ พบว่า expectation ที่ตั้งไว้ยังไม่ถูกใช้ → ฟ้องเพิ่มอีกสองก้อน
ก้อนแรกคือต้นเหตุจริงเสมอ อีกสองก้อนเป็นผลพลอยได้ อย่าตกใจ

ทางแก้: ถ้าเลข 7 คือสิ่งที่ถูก → โค้ด production ส่งผิด แก้โค้ด · ถ้าไม่สนใจค่านั้นจริง ๆ → ใช้ mock.Anything หรือ mock.MatchedBy(func(u *User) bool { ... })
อย่าเผลอเปลี่ยน expectation เป็น 8 เพื่อให้เทสเขียว — นั่นคือการลบเทสทิ้ง

กับดักฝาแฝด: ตั้ง expectation แล้วไม่ถูกเรียกเลย

อาการคล้ายกันแต่คนละสาเหตุ — และหน้าตา error ต่างกันชัดเจน ถ้าอ่านเป็นจะแยกออกทันที

=== RUN   TestTrap_ExpectationNeverCalled
    mocks_test.go:20: FAIL:	Save(*user.User)
        		at: [/.../app/user/mocks_test.go:169 /.../app/user/fail1_test.go:8]
    mocks_test.go:20: FAIL: 0 out of 1 expectation(s) were met.
        	The code you are testing needs to make 1 more call(s).
        	at: [/.../app/user/mocks_test.go:20 .../testing/testing.go:1317 ...]
--- FAIL: TestTrap_ExpectationNeverCalled (0.00s)
FAIL
FAIL	example.com/shop/user	0.457s
argument ไม่ตรงตั้งแล้วไม่ถูกเรียก
ก้อนแรกของ errormock: Unexpected Method CallFAIL: Save(*user.User)
มี Diff: ไหมมี — บอกค่าที่ต่างกันไม่มี — เพราะไม่มี call ให้เทียบ
ต้นเหตุโค้ดเรียกด้วยค่าที่ไม่ตรงโค้ดไม่เรียก method นั้นเลย
อ่านที่ไหนบรรทัด Diff:ตัวท้ายสุดใน [] ก้อนแรก

ทั้งสองกรณี error ชี้ไปที่ mocks_test.go:20 — ไฟล์ที่ generate มา ไม่ใช่ไฟล์เทสของเรา
อย่าไปตามอ่านบรรทัดนั้น มันคือ t.Cleanup ใน constructor ให้ไปอ่านชื่อไฟล์ที่ลงท้ายด้วย _test.go ใน [] แทน — นั่นคือบรรทัดของคุณ

กับดักที่เหลือ ซึ่งเจอแน่ระหว่างทาง

อาการที่เห็นสาเหตุแก้
panic: no return value specified for CountActive EXPECT() แล้วลืมต่อ .Return() — mock ไม่รู้จะคืนอะไร อ่านคำหลัง specified for คือชื่อ method ที่ลืม แล้วต่อ .Return(...)
*MockUserRepository does not implement UserRepository (missing method Delete) แก้ interface แล้วลืมรัน mockery ใหม่ — mock เป็นโค้ด static ไม่ sync เอง รัน mockery ใหม่ · เจอคำว่า missing method ให้นึกถึงข้อนี้ก่อน
undefined: user.NewMockUserRepository จาก package อื่น filename default คือ mocks_test.go → Go คอมไพล์ให้เฉพาะ test binary ของ package นั้น เปลี่ยนเป็น filename: mocks.go แล้วลบไฟล์เก่าทิ้งด้วย
NewMockUserRepository redeclared in this block เปลี่ยน filename แล้วไฟล์เก่าค้าง — mockery ไม่ลบของเดิมให้ rm user/mocks_test.go ก่อนรันใหม่
assert: mock: The method has been called over 1 times. ตั้ง Once() ไว้ แต่โค้ดเรียกซ้ำ ใช้ .Times(n) หรือไม่ใส่ Once() เลย (default คือกี่ครั้งก็ได้)
config ที่แก้ไว้หายเกลี้ยง กลับเป็น default รัน mockery init ซ้ำ — มันเขียนทับตรง ๆ ไม่เตือน ไม่มี prompt รัน init ครั้งเดียวตอนเริ่มโปรเจกต์ · จะรันซ้ำต้อง backup เอง

⚠️ Writing template to file ไม่ได้แปลว่าสำเร็จ — ถ้าตั้ง force-file-write: false แล้วไฟล์มีอยู่แล้ว log จะเป็นแบบนี้:

INF Executing template file=/.../app/user/mocks_test.go
INF Writing template to file file=/.../app/user/mocks_test.go
ERR output file exists, can't write mocks file=/.../app/user/mocks_test.go force-file-write=false
FTL app failed error="outfile exists"

มันขึ้น "Writing" ก่อนแล้วค่อย ERR — อ่าน log ให้ถึงบรรทัดสุดท้ายเสมอ

11 · แล้วตกลง mockery คุ้มไหม — ตัวเลขจริง

สิ่งที่วัดบรรทัด
mock ที่ mockery generate (mocks_test.go)193
mock เขียนมือให้ครอบคลุมงานเดียวกัน35
.mockery.yml ที่เพิ่มเข้ามา28
interface ต้นทาง3 method (ทั้งไฟล์ 41)

⚠️ mockery ไม่ได้ "ลดจำนวนบรรทัดในโปรเจกต์" — มันเพิ่มขึ้นกว่า 5.5 เท่า (193 vs 35 บรรทัด) แถมยังบวก config อีก 27 บรรทัด
ถ้าใครเคลมว่า "ประหยัดโค้ด" ต้องพูดให้ตรงว่า ประหยัดบรรทัดที่มนุษย์ต้องพิมพ์และดูแลเอง: 35 → 0 ไม่ใช่บรรทัดในไฟล์

📊 คุณค่าจริงที่วัดได้คือ assertion ที่ได้ฟรี

เทียบเคสเดียวกันตรง ๆ — โค้ดส่ง argument ผิด:

mock เขียนมือ  →  --- PASS  (บั๊กหลุดออก production)
mockery       →  Diff: 0: FAIL:  (int=8) != (int=7)

เพราะ mock เขียนมือต้องเขียน assert เรื่อง argument เองทุกครั้ง (ต้องมี field GetByIDCalls []int + if เช็คอีกราว 4 บรรทัดต่อ method) ส่วน mockery ได้มาพร้อม EXPECT() และได้ AssertExpectations อัตโนมัติจาก t.Cleanup ใน constructor

เลือกยังไงแบบไม่โม้:
✅ ยิ่ง interface มี method เยอะ + ยิ่งต้อง assert argument ละเอียด mockery ยิ่งคุ้ม — ซึ่งคือ repository ทุกตัวที่คุณจะเขียนหลัง บทเรียน 27
⚖️ interface 1–2 method ที่แค่ stub ค่า return เฉย ๆ เขียนมือยังคุ้มกว่า เพราะไม่ต้องแบก config + toolchain + ขั้นตอน "อย่าลืมรัน mockery" ในทีม

ทดสอบความเข้าใจ

1. ใน mockery v3 คำสั่งที่ใช้ generate mock คืออะไร?

mockery --help แสดง subcommand ทั้งหมดแค่ 6 ตัว — completion · help · init · migrate · showconfig · version ไม่มี generate และไม่มี build
พิมพ์ตามนิสัย v2 จะได้ Error: unknown command "generate" for "mockery" exit 1
บรรทัด Usage: mockery [flags] คือคำตอบ: root command คือ ตัว generate
ส่วน init สร้างแค่ .mockery.yml ไม่ได้ generate mock ให้

2. รัน mockery แล้ว log จบที่ Done parsing configured packages. exit code 0 แต่ไม่มีไฟล์ mock — แปลว่าอะไร?

exit code ของ mockery v3.7.3 เชื่อไม่ได้ — ผมทดสอบ 3 แบบ (module root ที่ไม่มีไฟล์ .go · package ที่ไม่มีอยู่จริง · package ที่ไม่มีในโลก) ได้ exit 0 ทั้งสามกรณี ไม่ error สักอัน
หลักฐานเดียวที่เชื่อได้คือบรรทัด INF adding interface to collection ... interface=<ชื่อ>
สาเหตุที่พบบ่อยสุด: mockery init สร้าง config ชี้ module root มาให้ แต่ interface อยู่ใน sub-package → เติม recursive: true ใต้ packages.<module>.config

ทำไมข้ออื่นผิด: mockery ไม่มีระบบ cache ไฟล์ผลลัพธ์เลย รันซ้ำก็เขียนใหม่ทุกครั้ง · การเขียนลงโฟลเดอร์ mocks/ กลางเป็นพฤติกรรมของ v2 ส่วน v3 ใช้ dir: {{.InterfaceDir}} คือวางข้าง interface (หัวข้อ 6) และถ้าเขียนไฟล์จริงจะต้องมี log Writing template to file โผล่มาด้วยเสมอ · การไม่ได้ลง testify ไม่เกี่ยวกับขั้นตอน generate เลย mockery เขียนไฟล์ออกมาได้ตามปกติ แล้วค่อยไปพังตอน go test ว่าหา package ไม่เจอ

3. เทสตกด้วยบรรทัด Diff: 0: FAIL: (int=8) != (int=7) — อ่านว่าอะไร?

เลข 0 ข้างหน้าคือ index ของ argument (ตัวแรก)
ซ้ายคือค่าที่โค้ดส่งมาจริง ขวาคือค่าที่เราตั้งไว้ใน EXPECT()/On()
testify จับคู่ call ด้วยการเทียบ argument ทุกตัวแบบตรงเป๊ะ ไม่ตรง = mock: Unexpected Method Call แล้ว panic หยุดเทสทันที
ตัวเลือกเรื่องจำนวนครั้งผิด — กรณีนั้นข้อความจะเป็น The method has been called over 1 times. ซึ่งหน้าตาต่างกันชัดเจน

ทำไมข้ออื่นผิด: บรรทัด Diff: เทียบ argument ที่ส่งเข้าไป ไม่ใช่ค่าที่ mock คืนออกมา (ค่าที่คืนถูกกำหนดด้วย .Return() ซึ่ง testify ไม่เอามาเทียบ) · ส่วนเรื่อง signature ไม่ตรงเพราะลืมรัน mockery จะพังตั้งแต่ compile ไม่มีทางรันมาถึงบรรทัด Diff: ได้

4. ทำไมเทสในหัวข้อ 9 ถึงไม่มี defer repo.AssertExpectations(t) เลยสักบรรทัด?

ใน NewMockUserRepository(t) ที่ generate ออกมามีบรรทัดนี้อยู่: t.Cleanup(func() { mock.AssertExpectations(t) })
เป็น t.Cleanup ตัวเดียวกับที่เรียนไปในบทเรียน 15 — ทำงานอัตโนมัติตอนเทสจบ ไม่ว่าจะจบแบบ pass หรือ fail
นี่คือความต่างสำคัญจาก v2 · ถ้ายังพิมพ์ defer AssertExpectations ตามนิสัยเดิม จะกลายเป็นเรียกซ้ำโดยไม่จำเป็น
และเพราะมันทำงานเสมอนี่เอง กับดัก "ตั้งแล้วไม่ถูกเรียก" ถึงฟ้องได้ ทั้งที่เราไม่ได้เขียน assert อะไรเลย

ทำไมข้ออื่นผิด: EXPECT() แค่ลงทะเบียนความคาดหวังไว้เฉย ๆ ตอนนั้นยังไม่มีอะไรให้ตรวจเพราะโค้ดยังไม่ทันเรียก · template testify ไม่ได้ปิดการตรวจ ตรงข้าม มันเป็นตัวที่ใส่ t.Cleanup ให้ · และการตรวจว่า method ถูกเรียกครบไหมเป็นเรื่องที่รู้ได้ตอนรันเท่านั้น compiler ไม่มีทางรู้

🛠️ ภารกิจ — ยก repository จากบทเรียน 27 มาเข้าโรงงาน

ใช้โปรเจกต์ที่คุณทำไว้ในบทเรียน 27 (มี internal/ · repository · service) ถ้ายังไม่มี ให้สร้าง interface สัก 3 method ขึ้นมาใหม่ตามตัวอย่างในหัวข้อ 9

  1. เขียน mock ด้วยมือก่อน: ทำ FakeXxxRepository ให้ครบทุก method แล้ว wc -l ดูว่ากี่บรรทัด
    เขียนเทส 1 เคสที่ โค้ดส่ง argument ผิด แต่เทสยังผ่าน — พิสูจน์ให้ตัวเองเห็นว่ารูโหว่นี้มีจริง
  2. ติดตั้ง: go install github.com/vektra/mockery/v3@latest แล้วเช็คด้วย mockery version
    ลองพิมพ์ mockery --version ด้วยหนึ่งครั้ง เพื่อจำ error ให้ขึ้นใจ
  3. รัน mockery ก่อนสร้าง config — จดข้อความ FTL ที่ได้ แล้วค่อย mockery init <module จาก go.mod>
  4. เปิด .mockery.yml (ls -a ถึงจะเห็น) แล้วรัน mockery
    มีบรรทัด adding interface to collection ไหม? ถ้าไม่มี → เติม recursive: true แล้วรันใหม่
  5. go mod tidy แล้วเขียนเทสใหม่ 3 เคสด้วย EXPECT(): เคสปกติ · เคส repository คืน error (ยืนยันด้วย assert.ErrorIs จาก บทเรียน 12) · เคสใช้ mock.MatchedBy
  6. สร้างกับดักที่ 1: เอาเทสข้อ 1 (argument ผิด) มาเขียนใหม่ด้วย mock ของ mockery
    คราวนี้ตกไหม? ชี้ให้ได้ว่าบรรทัด Diff: บอกอะไร
  7. สร้างกับดักที่ 2: ตั้ง EXPECT() ไว้แต่ไม่เรียก
    error ต่างจากข้อ 6 ตรงไหน? ตอบให้ได้โดยไม่ต้องเปิดย้อนกลับมาดู
  8. สร้างกับดักที่ 3: เพิ่ม method ใหม่ใน interface แล้ว อย่าเพิ่งรัน mockery — go test ./... ก่อน แล้วดูว่าพังที่ขั้นไหน (compile หรือ run?)
  9. ท้าทาย 1: ตั้ง EXPECT() แล้วจงใจไม่ต่อ .Return() — เดาก่อนว่าจะได้ FAIL ธรรมดาหรืออย่างอื่น แล้วค่อยรัน (กดเฉลยด้านล่างเทียบได้)
  10. ท้าทาย 2: แก้ filename: mocks_test.go เป็น filename: mocks.go ใน .mockery.yml แล้วรัน mockery ใหม่
    • รันครั้งแรกจะเจอ NewMockUserRepository redeclared in this block — รู้อยู่แล้วว่าทำไม (ไฟล์เก่ายังอยู่ mockery ไม่ลบให้) แก้เลย
    • จากนั้นสร้าง package ใหม่ (เช่น report/) เขียนเทสที่ import "<module>/user" แล้วเรียก user.NewMockUserRepository(t)
    • ถือว่าสำเร็จเมื่อ go test ./... ผ่านทั้งสอง package และ go build ./... ยังผ่าน
    • เสร็จแล้วตอบตัวเองให้ได้ว่า ทำไมค่าเริ่มต้นถึงเป็น mocks_test.go: เพราะเมื่อเป็น mocks.go ไฟล์ mock กับ testify จะถูกคอมไพล์ติดไปกับโปรแกรมจริงด้วย ไม่ใช่เฉพาะตอนเทส — ให้เปลี่ยนเฉพาะตอนที่จำเป็นต้องใช้ข้าม package จริง ๆ

ไม่ใช่ assertion failure ธรรมดา — มัน panic ทั้ง test binary:

=== RUN   TestTrap_ForgotReturn
--- FAIL: TestTrap_ForgotReturn (0.00s)
panic: no return value specified for CountActive [recovered, repanicked]

goroutine 35 [running]:
testing.tRunner.func1.2({0x1029efc20, 0x102a43f40})
	/Users/.../go/1.26.5/src/testing/testing.go:1974 +0x1a0
...
example.com/shop/user.(*MockUserRepository).CountActive(0x2baf863d6460?)
	/.../app/user/mocks_test.go:43 +0xb4
example.com/shop/user.TestTrap_ForgotReturn(0x2baf863fc6c8?)
	/.../app/user/fail3_test.go:9 +0x5c
FAIL	example.com/shop/user	0.547s

เหตุผลอยู่ในโค้ดที่ generate มาเอง (หัวข้อ 8):
if len(ret) == 0 { panic("no return value specified for CountActive") }
เพราะ mock ไม่รู้จะคืนอะไร และคืน zero value มั่ว ๆ ก็อันตรายกว่า — เลือกพังเสียงดังดีกว่าปล่อยค่าผิดออกไป (เหตุผลเดียวกับที่ Go runtime ฆ่าโปรแกรมทิ้งเมื่อเจอ concurrent map writes ใน บทเรียน 24)

วิธีอ่าน stack trace ยาว ๆ นี้: อย่าอ่านจากบนลงล่าง
อ่านคำหลัง specified for → ได้ชื่อ method ที่ลืม · แล้วมองหาบรรทัดที่ลงท้าย _test.go ตัวสุดท้าย (fail3_test.go:9) → ได้บรรทัดของคุณ

💬 ข้อ 1 กับข้อ 6 คือคู่ที่ผมอยากให้ทำติดกันที่สุด — บั๊กเดียวกัน โค้ดเดียวกัน ต่างกันแค่ mock มาจากไหน แล้วผลลัพธ์คือ PASS กับ FAIL
ถ้าคุณเห็นความต่างนั้นด้วยตาตัวเอง คุณจะไม่ต้องท่องว่า "mockery ดียังไง" อีกเลย
บทถัดไป: เทสที่ mock ช่วยไม่ได้ — เพราะมันต้องคุยกับของจริง

อ้างอิง