Test Doubles & Dependency Boundaries
ทำไมต้อง Inject Dependency เพื่อให้เทสต์ได้
โค้ดที่ "new" dependency ของตัวเองขึ้นมาตรงๆ ข้างใน (เช่น สร้าง database connection เองใน constructor) ไม่มีทางแทนที่ด้วย test double ได้เลย เพราะไม่มีช่องให้ "สอด" ของปลอมเข้าไป แทน — ต้องออกแบบให้ dependency ถูกส่งเข้ามาจากภายนอก (constructor injection, parameter injection) ถึงจะทดสอบแยกส่วน (isolated) ได้
// ทดสอบไม่ได้: ผูกติดกับ database จริงแน่นเกินไป
type UserService struct{}
func (s *UserService) GetUser(id string) (*User, error) {
db, _ := sql.Open("postgres", "...") // สร้างเองข้างใน แทนที่ด้วย fake ไม่ได้
return queryUser(db, id)
}
// ทดสอบได้: รับ dependency จากภายนอกผ่าน interface
type UserService struct {
repo UserRepository // interface — ตอน test ใส่ fake/mock แทนได้
}
func (s *UserService) GetUser(id string) (*User, error) {
return s.repo.FindByID(id)
}
"Dependency injection = มีช่องเสียบปลั๊กให้สลับอุปกรณ์ได้" — โค้ดที่ "บัดกรีสายไฟติดเครื่อง" (new dependency ไว้ข้างในตรงๆ) เปลี่ยนอุปกรณ์ไม่ได้เลย ต้องรื้อทั้งเครื่อง ส่วนโค้ดที่มี "ช่อง เสียบปลั๊ก" (interface parameter) สลับอุปกรณ์ (fake/mock) เข้า-ออกได้ตามต้องการโดยไม่แตะโค้ดหลัก
"Testability" กับ "Good design" ไม่ใช่สองเรื่องที่บังเอิญมาคู่กัน แต่เป็นเรื่องเดียวกัน โดยพื้นฐาน — โค้ดที่ test ยากมักเป็นสัญญาณของ tight coupling (ผูกติดกับ dependency รูปธรรม ตรงๆ แทนที่จะผูกกับ abstraction) ซึ่งเป็น code smell ที่ส่งผลเสียอยู่แล้วไม่ว่าจะเขียนเทสต์หรือ ไม่ก็ตาม (แก้ไข/ขยายยาก, reuse ยาก) นี่คือเหตุผลที่ประสบการณ์ "เขียนเทสต์ยากมาก" สำหรับโค้ดชิ้น หนึ่ง มักเป็นสัญญาณเตือนที่มีประโยชน์ว่าโค้ดนั้นออกแบบไม่ดีตั้งแต่แรก ไม่ใช่แค่ "เทสต์เขียน ยาก" เฉยๆ — แนวคิดนี้คือรากฐานของคำว่า "testability-driven design" ที่หลายทีมใช้เป็น heuristic ตัดสินใจ refactor โดยไม่ต้องรอจน code review จับ code smell ได้
Dependency ประเภทไหนควร Mock/Fake และประเภทไหนไม่ควร
ไม่ใช่ dependency ทุกตัวที่ควรถูก mock — หลักการคือ mock เฉพาะ boundary ที่ควบคุมไม่ได้/ ช้า/มี side effect จริง (I/O: database, network, filesystem, เวลา/random) ส่วน dependency ที่เป็น pure logic ภายในระบบเดียวกัน ไม่ควร mock (ปล่อยให้ทำงานจริงไปเลย)
| Dependency | ควร Mock/Fake ไหม | เหตุผล |
|---|---|---|
| Database, external API | ควร | ช้า, ควบคุมผลลัพธ์ยาก, มี side effect จริง |
| File system, environment clock | ควร | ทำให้เทสต์ไม่ repeatable ถ้าไม่ fake |
| Pure function/calculation ภายใน package เดียวกัน | ไม่ควร | ไม่มี side effect, รันจริงได้เร็วอยู่แล้ว ไม่ได้ประโยชน์จากการ mock |
| Internal domain object (เช่น struct ธรรมดา) | ไม่ควร | mock แล้วเสีย type safety โดยไม่ได้อะไรคืน |
"Mock แค่ขอบเขตที่ข้ามออกนอกบ้าน ไม่ mock ของในบ้านตัวเอง" — database/network/filesystem คือ "โลกภายนอก" ที่ควบคุมไม่ได้ ต้องปลอมแทน แต่ logic ภายใน package เดียวกันคือ "ของในบ้าน" ที่ ควบคุมได้เต็มที่อยู่แล้ว ไม่จำเป็นต้องปลอม ปล่อยให้มันทำงานจริงในเทสต์ไปเลย
มีกับดักที่เรียกว่า "mocking the system under test" ที่เกิดขึ้นบ่อยโดยไม่รู้ตัว: เวลา refactor โค้ดใหญ่ให้แตกเป็น function ย่อยหลายตัวภายใน class เดียวกัน แล้วเขียนเทสต์ด้วยการ mock function ย่อยพวกนั้นเพื่อเทส function หลัก — จริงๆ แล้วนี่คือการ mock ส่วนหนึ่งของ "สิ่งที่กำลังทดสอบอยู่" เอง ไม่ใช่ mock dependency ภายนอก ผลคือเทสต์ไม่ได้พิสูจน์อะไรเกี่ยว กับ logic จริงเลย (เพราะ logic จริงถูก mock ทิ้งไปหมด) แค่พิสูจน์ว่า "function หลักเรียก function ย่อยที่ชื่อนี้จริง" ซึ่งไม่มีประโยชน์ในการจับบั๊ก — เป็นสัญญาณว่าถ้าต้อง mock "ตัวเอง" ขนาดนี้ อาจเป็นเพราะ class/function นั้นทำหน้าที่เกินไป (violate Single Responsibility) ควร extract ส่วนที่เป็น external dependency จริงๆ ออกมาเป็น interface ต่าง หาก แทนที่จะ mock internal method ของตัวเอง
Code that constructs its own dependencies inline (e.g. opening a DB connection directly inside a method) can never be isolated for testing — dependency injection (via constructor/parameter) is what creates the "socket" test doubles plug into. Crucially, testability difficulty is usually a direct signal of poor design (tight coupling to concretions instead of abstractions), not a separate concern from code quality — this is the basis of "testability-driven design" as a refactoring heuristic. Mock only true external boundaries (DB, network, filesystem, clock/ randomness) — don't mock pure internal logic, which loses type safety for no benefit. A common trap is "mocking the system under test": stubbing out internal helper methods of the very class being tested, which proves nothing about real behavior and often signals the class is doing too much and should have its true external dependency extracted into its own interface instead.