🧰 เตรียมก่อนเริ่ม — เช็คให้ครบก่อนถึงหัวข้อ 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 ที่ได้มาไม่ได้ทำงานจริงสักอย่าง — ไม่ต่อฐานข้อมูล ไม่เซฟอะไรทั้งนั้น มันแค่หน้าตาเหมือนพอที่จะยัดเข้าไปแทนของจริงตอนเทสได้
จำสามข้อนี้ไว้ เดี๋ยวมันจะอธิบายเกือบทุกเหตุการณ์ในบทนี้:
- ไฟล์ที่ได้เป็นโค้ด Go ธรรมดา เปิดอ่านได้ คอมไพล์รวมกับโปรเจกต์ตามปกติ
- มันคือภาพนิ่ง ณ เวลาที่รัน — แก้ interface แล้วไฟล์เก่าไม่อัปเดตให้เอง ต้องรัน mockery ใหม่
- เราไม่แก้ไฟล์นั้นด้วยมือ เพราะรันครั้งหน้ามันจะถูกเขียนทับทิ้ง
- 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
| # | ชิ้นส่วน | หน้าที่ |
|---|---|---|
| 1 | interface ในโค้ด | ต้นแบบ — mockery อ่านจากตรงนี้ ไม่มี interface ก็ไม่มีอะไรให้ generate |
| 2 | .mockery.yml | บอกว่าอ่าน package ไหน · เขียนไฟล์ที่ไหน · ตั้งชื่ออะไร |
| 3 | คำสั่ง mockery | รันแล้วได้ไฟล์ mock ออกมา |
| 4 | github.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 ให้ | แปลว่าอะไร |
|---|---|---|
template | testify | ให้ mock ที่ต่อยอดจาก mock.Mock ของ testify — คือของที่คุณรู้จักแล้วจากบท 17 |
dir | {{.InterfaceDir}} | วางไฟล์ mock ในโฟลเดอร์เดียวกับ interface ไม่ใช่โฟลเดอร์ mocks/ กลาง |
filename | mocks_test.go | ลงท้าย _test.go → package อื่นมองไม่เห็น (ดูกับดักหัวข้อ 9) |
pkgname | {{.SrcPackageName}} | ไฟล์ mock อยู่ package เดียวกับโค้ดต้นทาง → เรียกใช้ได้โดยไม่ต้อง import |
structname | {{.Mock}}{{.InterfaceName}} | interface ชื่อ UserRepository → struct ชื่อ MockUserRepository |
packages | module root + all: true | อ่าน interface ทุกตัวที่เจอใน package นั้น |
force-file-write | true | อนุญาตเขียนทับไฟล์ mock เดิม — ถ้าเป็น false จะตายด้วย error="outfile exists" |
recursive | false | false = มองหา 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 ตัวอื่นได้ด้วย
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
}
ดูบรรทัดในสุด: _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 ไม่ตรง | ตั้งแล้วไม่ถูกเรียก | |
|---|---|---|
| ก้อนแรกของ error | mock: Unexpected Method Call | FAIL: 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
ไม่ใช่บรรทัดในไฟล์
เทียบเคสเดียวกันตรง ๆ — โค้ดส่ง 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 ไม่มีทางรู้
ใช้โปรเจกต์ที่คุณทำไว้ในบทเรียน 27 (มี internal/ · repository · service)
ถ้ายังไม่มี ให้สร้าง interface สัก 3 method ขึ้นมาใหม่ตามตัวอย่างในหัวข้อ 9
- เขียน mock ด้วยมือก่อน: ทำ
FakeXxxRepositoryให้ครบทุก method แล้วwc -lดูว่ากี่บรรทัด
เขียนเทส 1 เคสที่ โค้ดส่ง argument ผิด แต่เทสยังผ่าน — พิสูจน์ให้ตัวเองเห็นว่ารูโหว่นี้มีจริง - ติดตั้ง:
go install github.com/vektra/mockery/v3@latestแล้วเช็คด้วยmockery version
ลองพิมพ์mockery --versionด้วยหนึ่งครั้ง เพื่อจำ error ให้ขึ้นใจ - รัน
mockeryก่อนสร้าง config — จดข้อความFTLที่ได้ แล้วค่อยmockery init <module จาก go.mod> - เปิด
.mockery.yml(ls -aถึงจะเห็น) แล้วรันmockery
มีบรรทัดadding interface to collectionไหม? ถ้าไม่มี → เติมrecursive: trueแล้วรันใหม่ go mod tidyแล้วเขียนเทสใหม่ 3 เคสด้วยEXPECT(): เคสปกติ · เคส repository คืน error (ยืนยันด้วยassert.ErrorIsจาก บทเรียน 12) · เคสใช้mock.MatchedBy- สร้างกับดักที่ 1: เอาเทสข้อ 1 (argument ผิด) มาเขียนใหม่ด้วย mock ของ mockery
คราวนี้ตกไหม? ชี้ให้ได้ว่าบรรทัดDiff:บอกอะไร - สร้างกับดักที่ 2: ตั้ง
EXPECT()ไว้แต่ไม่เรียก
error ต่างจากข้อ 6 ตรงไหน? ตอบให้ได้โดยไม่ต้องเปิดย้อนกลับมาดู - สร้างกับดักที่ 3: เพิ่ม method ใหม่ใน interface แล้ว
อย่าเพิ่งรัน mockery —
go test ./...ก่อน แล้วดูว่าพังที่ขั้นไหน (compile หรือ run?) - ท้าทาย 1: ตั้ง
EXPECT()แล้วจงใจไม่ต่อ.Return()— เดาก่อนว่าจะได้ FAIL ธรรมดาหรืออย่างอื่น แล้วค่อยรัน (กดเฉลยด้านล่างเทียบได้) - ท้าทาย 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 ช่วยไม่ได้ — เพราะมันต้องคุยกับของจริง
อ้างอิง
- Mockery — เอกสารทางการ · Configuration — รายการฟิลด์ทั้งหมดใน
.mockery.yml - github.com/vektra/mockery — source · เวอร์ชันที่ใช้ในบทนี้คือ v3.7.3
- pkg.go.dev — testify/mock —
On·Return·Times·MatchedBy·Anything·AssertExpectations - pkg.go.dev — testing.T.Cleanup — กลไกที่ทำให้ constructor ตรวจ expectation ให้เอง
- Go Modules Reference —
go install— ทำไม binary ถึงไปอยู่ที่$GOPATH/bin