ในการพัฒนาซอฟต์แวร์ระดับองค์กร โค้ดของเราแทบจะไม่เคยทำงานในสุญญากาศ (Vacuum) เลย มันต้องพึ่งพา (Depend) ส่วนประกอบภายนอกจำนวนมาก ไม่ว่าจะเป็นการเชื่อมต่อฐานข้อมูล (Database), การเรียกใช้บริการผ่าน API ของ Third-party, หรือแม้แต่ระบบไฟล์ (File System) ความซับซ้อนเหล่านี้ทำให้การเขียน Unit Test แบบดั้งเดิมเป็นเรื่องที่ยากและไม่น่าเชื่อถือ
บทนำและแนวคิดสำคัญ
Mocking Object คือเทคนิคขั้นสูงในการทดสอบซอฟต์แวร์ (Testing Technique) ที่เราใช้สร้าง “วัตถุจำลอง” หรือ “ตัวปลอม” ขึ้นมาแทนที่ Dependency ภายนอกที่เราไม่ต้องการให้มันทำงานจริง ๆ ในระหว่างการรัน Unit Test วัตถุเหล่านี้จะทำหน้าที่เสมือนว่าพวกมันคือของจริง แต่เราสามารถควบคุมพฤติกรรม (Behavior) ของมันได้อย่างสมบูรณ์แบบ
- ปัญหาหลักที่ต้องแก้ไข (The Problem): หากโค้ดที่เรากำลังทดสอบมีส่วนที่เรียกใช้ฐานข้อมูลจริง ๆ การรัน Unit Test เพียงชุดเดียวอาจกินเวลาหลายวินาที และหาก Connection ขัดข้อง (DB Down) Test นั้นก็จะล้มเหลวด้วย ซึ่งความผิดพลาดนี้ไม่ได้เกิดจากตรรกะ (Logic) ในคลาสของเราเลย
- การแก้ไขด้วย Mocking: เราออกแบบให้คลาสที่ต้องการทดสอบทำงานผ่าน
Interfaceเสมอ จากนั้นในขั้นตอน Test เราจะใช้ Mocking Framework (เช่น PHPUnit หรือ Mockery) เพื่อสร้าง Mock Object ที่มีรูปร่างหน้าตาเหมือน Adapter จริง ๆ แต่เมื่อถูกเรียกใช้ มันจะไม่เชื่อมต่อ DB จริง แต่จะส่งค่าที่เรากำหนดไว้ให้เราทันที - ผลลัพธ์ (Isolation): การทดสอบของเราจึงรวดเร็ว แม่นยำ และที่สำคัญที่สุดคือ แยกส่วน (Isolated) อย่างสมบูรณ์ เรามั่นใจได้ว่าถ้า Test นี้ผ่าน แปลว่าตรรกะในคลาสนี้ทำงานถูกต้อง โดยไม่ขึ้นอยู่กับความพร้อมของระบบภายนอก
🤔 Mock vs Stub: ความแตกต่างที่ต้องรู้
- Stub Object (ตัวให้ข้อมูลปลอม): เป็นวัตถุที่ทำหน้าที่เพียงแค่ “ตอบกลับ” ข้อมูลปลอม (Canned Data) ตามที่เราต้องการ เช่น ถ้าเรียกเมธอด `getUser(1)` จะต้องคืนค่าเป็น User object ที่มี ID=1 เสมอ
- Mock Object (ตัวจำลองพฤติกรรม): มีความสามารถมากกว่า Stub เพราะนอกจากจะให้ข้อมูลปลอมแล้ว Mock ยังมีคุณสมบัติในการ บันทึกและตรวจสอบการโต้ตอบ (Verify Interaction) ด้วย กล่าวคือ มันสามารถตรวจสอบได้ว่า “โค้ดนี้เรียกเมธอด `saveUser()` ไปหรือไม่? และถูกเรียกด้วย Argument อะไร?”
ตัวอย่างการใช้งานและรูปแบบโค้ด (Code Examples)
เราจะใช้ตัวอย่างระบบจัดการผู้ใช้ที่ต้องพึ่งพา Repository Interface เพื่อสาธิตวิธีการ Mocking ด้วย PHPUnit
// 1. Interface (กำหนดสัญญา) - นี่คือหัวใจของการออกแบบที่ดี!
interface UserRepositoryInterface {
public function findById(int $id): ?User;
public function save(User $user): bool;
}
// 2. Class ที่ต้องการทดสอบ (Service Layer)
class UserService {
private UserRepositoryInterface $repository;
public function __construct(UserRepositoryInterface $repository) {
$this->repository = $repository;
}
/**
* ตรวจสอบและบันทึกผู้ใช้ หากไม่พบข้อมูล จะสร้างและส่งคืน user ใหม่
*/
public function ensureUserExists(int $userId, string $newEmail): User {
// 1. เรียกหาข้อมูลจาก Repository (Dependency)
$user = $this->repository->findById($userId);
if ($user === null) {
// กรณีที่ต้องสร้าง Object ขึ้นมาเองก่อนบันทึก
$user = new class($userId, $newEmail) {
public function __construct(private int $id, private string $email) {}
public function getEmail(): string { return $this->email; }
};
}
// 2. ตรรกะหลัก: อัพเดทอีเมลถ้ามันเปลี่ยนไป และบันทึกข้อมูล
if ($user->getEmail() !== $newEmail) {
$user = new class(null, $newEmail); // สร้าง object ใหม่ด้วย email ที่ถูกต้อง
return $this->repository->save($user);
}
// หากไม่มีการเปลี่ยนแปลงใดๆ ก็แค่ส่ง user ตัวเดิมคืนไป
return $user;
}
}
// 3. Unit Test (ใช้ Mock Object)
use PHPUnit\Framework\TestCase;
final class UserServiceTest extends TestCase {
public function test_ensureUserExists_updatesEmailAndCallsSave() {
$fakeId = 10;
$newEmail = '[email protected]';
// A. สร้าง Mock Object แทน UserRepositoryInterface
$mockRepository = $this->getMockBuilder(UserRepositoryInterface::class)
->getMock();
// B. EXPECTATION 1: กำหนดพฤติกรรมให้ findById
// เราบอกว่าเมื่อใครเรียก findById ด้วย ID=10 มันต้องคืนค่า User จำลองนี้เสมอ
$mockUserOld = new class('[email protected]') { public function getEmail(): string { return '[email protected]'; } };
$mockRepository->expects($this->once()) // ต้องถูกเรียกแค่ 1 ครั้ง
->method('findById')
->with(10)
->willReturn($mockUserOld);
// C. EXPECTATION 2: ตรวจสอบว่า method 'save' จะต้องถูกเรียกจริง และรับ Object ที่มีอีเมลใหม่เท่านั้น
$mockRepository->expects($this->once()) // ต้องถูกเรียกแค่ 1 ครั้งในการอัพเดท
->method('save')
->with($this->callback(function($user) {
// การตรวจสอบ Argument ว่าเป็น User ที่มีอีเมลใหม่จริง ๆ (นี่คือการ Verify Interaction)
return $user instanceof class && $user->getEmail() === '[email protected]';
}))
->willReturn(true);
// Act: รันเมธอดที่ต้องการทดสอบ โดยส่ง Mock เข้าไปแทน Dependency จริง
$service = new UserService($mockRepository);
$result = $service->ensureUserExists(10, $newEmail);
// Assert: ตรวจสอบผลลัพธ์ (Business Logic)
$this->assertTrue($result === true);
}
}
ข้อควรระวัง Security และ Best Practices
- 💡 หลักการสำคัญ: Dependency Inversion Principle (DIP): Best Practice ที่ดีที่สุดคือการออกแบบโค้ดให้พึ่งพา
Interfaceเสมอ ไม่ใช่คลาสที่ Implement จริง ๆ การทำเช่นนี้ทำให้เราสามารถ “สลับ” (Swap) Dependency เป็น Mock Object ได้อย่างง่ายดายในขั้นตอน Test - ⚠️ Over-Mocking: อย่าสร้าง Mock มากเกินไปจนเราต้องทดสอบว่า “Mock ทำงานถูกต้อง” แทนที่จะทดสอบว่า “โค้ดของเราทำงานถูกต้อง” ควร Mock เฉพาะ Dependency ที่เป็นระบบภายนอกเท่านั้น
- 🛡️ การจัดการ Exception (Failure Path): Unit Test ที่ดีต้องครอบคลุมทั้งกรณีสำเร็จ (Happy Path) และกรณีที่ระบบภายนอกล้มเหลวด้วย เราสามารถ Mock ให้เกิดข้อผิดพลาดได้เพื่อพิสูจน์ว่าโค้ดของเรามีการจัดการ Error อย่างถูกต้อง
// ตัวอย่างการ Mock เพื่อจำลองการล้มเหลวของระบบภายนอก (Database Connection Failed)
$mockRepository->expects($this->once())
->method('save')
->willThrowException(new Exception("Database connection failed"));
// การทดสอบนี้จะพิสูจน์ว่า UserService ของเราจับ Exception นี้ได้หรือไม่
try {
$service->ensureUserExists(10, '[email protected]');
} catch (SomeSpecificServiceException $e) {
// ถ้าโค้ดของเรา Catch Exception ได้อย่างถูกต้อง Test จะผ่าน
}
สรุปและการนำไปประยุกต์ใช้งาน
Mocking Objects ไม่ใช่แค่เทคนิค แต่เป็นหลักการที่ส่งเสริมให้เราออกแบบโค้ดตามแนวคิด Dependency Inversion Principle (DIP) ซึ่งทำให้ระบบของเรามีความยืดหยุ่นสูงและง่ายต่อการบำรุงรักษา
- ✅ คุณประโยชน์หลัก:
- ความเร็ว (Speed): Test ทำงานรวดเร็วมาก เพราะไม่ต้องรอ I/O จากระบบภายนอก
- ความเสถียร (Reliability): Test จะล้มเหลวเมื่อโค้ดเราผิดจริง ๆ เท่านั้น ไม่ใช่เพราะ DB ล่ม หรือ API Timeout
- การแยกส่วน (Isolation): เราสามารถทดสอบตรรกะของคลาส A ได้อย่างเต็มที่ โดยไม่สนใจว่าระบบภายนอกจะทำงานอย่างไร ตราบใดที่เรา Mock ให้มันตอบสนองตามสัญญา
💡 ข้อควรจำ: Mocking เหมาะสำหรับ Unit Tests เสมอ หากคุณกำลังเขียน Test ที่ต้องการตรวจสอบว่าระบบทั้งหมดทำงานร่วมกันได้อย่างถูกต้องจากต้นจนจบ (เช่น User Workflow ทั้งหมด) สิ่งที่คุณต้องการคือ Integration Test ซึ่งควรถูกรันแยกต่างหากและไม่ใช้ Mocking Objects ใด ๆ
อ่านเพิ่มเติม