PlusMagi's Blog By Pitt Phunsanit PHP,Testing PHP: การทำ Mocking Object เพื่อแยกส่วนทดสอบ (Test Isolation)

PHP: การทำ Mocking Object เพื่อแยกส่วนทดสอบ (Test Isolation)

ในการพัฒนาซอฟต์แวร์ระดับองค์กร โค้ดของเราแทบจะไม่เคยทำงานในสุญญากาศ (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 ใด ๆ


อ่านเพิ่มเติม

ป้ายกำกับ:, , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , ,