มาทำความรู้จัก Dependency Injection และ Mocking กัน
Dependency Injection (DI) และ Mocking สองคำนี้หลายๆ ท่านอาจจะเคยได้ยินผ่านหูกันมาบ้างแล้ว แต่ก็อาจสงสัยว่ามันคืออะไรกันแน่ แล้วทำไมเมื่อมี Dependency Injection แล้วก็ต้องมีคำว่า Mocking ตามมา สองคำนี้มีดีอย่างไร? ลองมาดูกันครับ 😉
Dependency
คำว่า Dependency นั้นตามความหมายก็คือ “การพึ่งพา” ในทางโปรแกรมมิ่งนั้นก็คือการที่ Class หนึ่งต้องอาศัย Class อื่นในการทำงาน
// PizzaShop (v.1)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
class PizzaShop { protected $pizzaBoy; protected $paypalBiller; protected $pizzaChef; public function __construct() { $this->paypalBiller = $paypalBiller; $this->pizzaChef = $pizzaChef; $this->pizzaBoy = $pizzaBoy; } public function takeOrder() { // ถ้าเรียกเก็บเงินสำเร็จ if ($this->paypalBiller->chargeCustomer()) { $this->pizzaChef->makePizza(); $this->pizzaBoy->deliverPizza(); } throw new \Exception('Order not completed.'); } } class PaypalBiller { public function chargeCustomer() { // ติดต่อ Paypal API เรียกเก็บเงิน // ส่งต่ากลับเป็น true เมื่อการชำระเงินสำเร็จ return true; } } class PizzaChef { public function makePizza() { echo 'Making a pizza...'; } } class PizzaBoy { public function deliverPizza() { echo 'Delivering a pizza...'; } } |
จากโค้ดด้านบนจะเห็นว่าร้านพิซซ่า ( PizzaShop ) เมื่อได้รับ Order มาก็ต้องพึ่งพา (Dependent) ตัวเรียกเก็บเงิน ( PaypalBiller) พ่อครัว ( PizzaChef ) และเด็กส่งพิซซ่า ( PizzaBoy ) และนั่นก็หมายความว่า Dependency ของ PizzaShop ในที่นี้นั่นก็คือ PaypalBiller PizzaChef และ PizzaBoy นั่นเอง
และเมื่อเราต้องการเรียกใช้ Class นี้ก็สามารถทำได้โดย
1 2 |
$pizzaShop = new PizzaShop(); $pizzaShop->takeOrder(); |
Dependency Injection
ทีนี้เมื่อเรารู้จักคำว่า Dependency กันแล้ว มาต่อกันที่คำว่า Injection กันครับ Injection นั้นก็หมายถึง “การฉีด” ดังนั้น
Dependency + Injection = การฉีด Dependency นั่นเอง
จากคลาส PizzaShop เราใช้การสร้าง Dependency โดยการใช้ new ภายใน Class แต่หากเราให้เป็นไปตามวิธีการของ Dependency Injection เราก็ต้องหาทางฉีด (Inject) Dependency นั้นเข้าไป ซึ่งวิธีที่นิยมกันก็จะมี 2 วิธีด้วยกันนั่นก็คือ
- ผ่าน Constructor
- ผ่าน Setter Method
โดยตัวอย่างด้านล่างจะเป็นการ Inject ผ่าน Constructor นะครับ
// PizzaShop (v.2)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
class PizzaShop { protected $paypalBiller; protected $pizzaChef; protected $pizzaBoy; public function __construct(PaypalBiller $paypalBiller, PizzaChef $pizzaChef, PizzaBoy $pizzaBoy) { $this->paypalBiller = $paypalBiller; $this->pizzaChef = $pizzaChef; $this->pizzaBoy = $pizzaBoy; } public function takeOrder() { // ถ้าเรียกเก็บเงินสำเร็จ if ($this->paypalBiller->chargeCustomer()) { $this->pizzaChef->makePizza(); $this->pizzaBoy->deliverPizza(); return true; } throw new \Exception('Order not completed.'); } } |
และเรียกใช้ได้โดย
1 2 |
$pizzaShop = new PizzaShop(new PaypalBiller(), new PizzaBoy(), new PizzaChef()); $pizzaShop->takeOrder(); |
ทำไมต้อง Dependency Injection?
เหตุผลหลักๆ ที่เราทำให้คลาสนั้นเป็นไปตามหลักการ Dependency Injection นั่นก็คือ ความง่ายในการเขียน Unit Test ให้กับคลาสนั้นๆ ซึ่งตามจุดประสงค์ของการทำ Unit Test คือการทดสอบตัวโปรแกรมเป็นส่วนๆ (Unit) โดยทั่วไปก็คือ Method นั่นเอง
ทีนี้เราลองกลับมาดู PizzaShop (v.1) ซึ่งไม่ได้ทำตามหลัก Dependency Injection
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
class PizzaShop { protected $pizzaBoy; protected $paypalBiller; protected $pizzaChef; public function __construct() { $this->paypalBiller = $paypalBiller; $this->pizzaChef = $pizzaChef; $this->pizzaBoy = $pizzaBoy; } public function takeOrder() { // ถ้าเรียกเก็บเงินสำเร็จ if ($this->paypalBiller->chargeCustomer()) { $this->pizzaChef->makePizza(); $this->pizzaBoy->deliverPizza(); return true; } throw new \Exception('Order not completed.'); } } |
จากโค้ดด้านบนเราสามารถเขียน Test เพื่อทดสอบ Method takeOrder ได้ดังนี้
1 2 3 4 5 6 7 8 9 |
class ExampleTest extends PHPUnit_Framework_TestCase { public function testTakeOrderSuccess() { $pizzaShop = new PizzaShop(); $this->assertTrue($pizzaShop->takeOrder()); } } |
จาก Test ด้านบนจุดประสงค์เราก็คือการทดสอบ Method takeOrder เพียงอย่างเดียวเท่านั้น แต่ในความเป็นจริงแล้ว เมื่อเราสร้าง (Instantiate) Object PizzaShop ขึ้นมา สิ่งที่ถูกสร้างตามขึ้นมาด้วยก็คือ Dependency ทั้งหลายนั่นคือ PaypalBiller , PizzaChef และ PizzaBoy นั่นแปลว่าเมื่อเรารัน Test สิ่งที่เราทดสอบนั้นไม่ใช่แค่ Method takeOrder อีกต่อไปแต่กลายเป็นการทดสอบทุกๆ Class ซึ่งการทำเช่นนี้นั้นก็จะไม่ใช่ Unit Test อีกต่อไป แต่จะกลายเป็น Integration Test แทน
อีกหนึ่งปัญหาคือ… ถ้าหากเราต้องการทดสอบว่า Method takeOrder นั้น throw Exception จริง ถ้าหากการชำระเงินไม่สำเร็จ เราจะเขียน Test อย่างไรดี?
หรือว่าเราจะไปแก้ Method chargeCustomer ให้ส่งค่ากลับมาเป็น false เพื่อให้เราทดสอบได้?
ถ้าเป็นอย่างงั้นคงไม่ดีแน่ถ้าเราต้องไปแก้ Class เพื่อสำหรับการทำ Unit Test
ทางออกของเรานั้นก็คือ …
Dependency Injection + Mocking Framework
Dependency Injection + Mocking Framework
จะเห็นได้ว่า PizzaShop (v.1) นั้นเราไม่สามารถเขียน Unit Test ให้ครอบคลุมทุกเคสได้ เพราะเราไม่สามารถควบคุม Dependency รวมถึงค่าที่ Method จะ Return ได้
เราลองกลับมาดูที่ PizzaShop (v.2) กันอีกทีนะครับ
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
class PizzaShop { protected $paypalBiller; protected $pizzaChef; protected $pizzaBoy; public function __construct(PaypalBiller $paypalBiller, PizzaChef $pizzaChef, PizzaBoy $pizzaBoy) { $this->paypalBiller = $paypalBiller; $this->pizzaChef = $pizzaChef; $this->pizzaBoy = $pizzaBoy; } public function takeOrder() { // ถ้าเรียกเก็บเงินสำเร็จ if ($this->paypalBiller->chargeCustomer()) { $this->pizzaChef->makePizza(); $this->pizzaBoy->deliverPizza(); return true; } throw new \Exception('Order not completed.'); } } |
จาก PizzaShop (v.2) เราสามารถส่ง Dependency ทั้งหมดผ่าน Constructor ได้ เพราะฉะนั้นสิ่งที่เราทำได้นั้นก็คือ สร้าง Object ที่ Class นั้นต้องการและ Inject เข้าไป
แต่…. คงจะไม่ดีถ้าเรายังคงสร้าง Object จริงๆ เช่น new PaypalBiller() และส่งเข้าไป เพราะการทำเช่นนั้นก็เหมือนกับการสร้าง Dependency ตัวจริงและเป็นการ Test ฟังก์ชันอื่นๆ ที่เราไม่ต้องการ
สิ่งหนึ่งที่เราทำได้นั้นก็คือ การสร้าง Dependency เสมือนหรือการปลอม Object ขึ้นมา ที่เราเรียกกันทั่วไปว่า การทำ Mocking นั้นเอง
Mocking ~~
Mocking ~ ม๊อคกิ้ง คืออะไร? Mock นั้นแปลว่า การปลอม ซึ่งในทางโปรแกรมมิ่งเราใช้เทคนิค Mocking สำหรับการปลอม Object ขึ้นมาโดยที่ไม่จำเป็นต้องสร้าง Object ตัวจริง รวมถึงการควบคุมค่าที่ Method จะ Return กลับมาอีกด้วย
ตัวอย่างด้านล่างผมจะใช้ Mockery ซึ่งเป็น Mocking Framework ของ PHP (สำหรับภาษาอื่นๆ นั้นก็จะมี Mocking Framework เฉพาะไป เช่น Mockito ของ Java)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
class ExampleTest extends PHPUnit_Framework_TestCase { public function testTakeOrderSuccess() { // arrange // สร้าง Mock Object $paypalBiller = \Mockery::mock('PaypalBiller'); $pizzaChef = \Mockery::mock('PizzaChef'); $pizzaBoy = \Mockery::mock('PizzaBoy'); // สร้าง Method ที่จะโดนเรียก และ ค่าที่จะ Return เมื่อถูกเรียก $paypalBiller->shouldReceive('chargeCustomer')->andReturn(true); $pizzaChef->shouldReceive('makePizza')->andReturnNull(); $pizzaBoy->shouldReceive('deliverPizza')->andReturnNull(); // act // ส่งผ่าน Constructor $pizzaShop = new PizzaShop($paypalBiller, $pizzaChef, $pizzaBoy); // assert $this->assertTrue($pizzaShop->takeOrder()); } } |
จะเห็นว่าเมื่อเราใช้เทคนิค Mocking แล้วเราไม่จำเป็นที่จะต้องสร้าง Object จริงขึ้นมา ทำให้เราสามารถทดสอบเฉพาะ Method ที่เราต้องเพียงลำพังได้
และจากปัญหาที่เราเจอว่า “หากเราต้องการทดสอบว่า Exception นั้นถูก Throw จริงๆ จาก Method takeOrder เมื่อการชำระเงินไม่สำเร็จ” เราควรทำอย่างไร?
หากเราใช้เทคนิค Mocking แล้วการควบคุมค่าที่ Return จาก Method ของ Mocked Object สามารถทำได้โดยง่าย โดยการแก้ไขบรรทัดที่ 12 ให้เป็น
1 |
$paypalBiller->shouldReceive('chargeCustomer')->andReturn(false); |
หลังจากนั้น Test Case ใหม่ของเราก็จะเป็นไปตามนี้
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
/** * @expectedException Exception */ public function testTakeOrderFailed() { // arrange // สร้าง Mock Object $paypalBiller = \Mockery::mock('PaypalBiller'); $pizzaChef = \Mockery::mock('PizzaChef'); $pizzaBoy = \Mockery::mock('PizzaBoy'); // สร้าง Method ที่จะโดนเรียก และ ค่าที่จะ Return เมื่อถูกเรียก $paypalBiller->shouldReceive('chargeCustomer')->andReturn(false); $pizzaChef->shouldReceive('makePizza')->andReturnNull(); $pizzaBoy->shouldReceive('deliverPizza')->andReturnNull(); // act // ส่งผ่าน Constructor $pizzaShop = new PizzaShop($paypalBiller, $pizzaChef, $pizzaBoy); // assert $this->assertTrue($pizzaShop->takeOrder()); } |
บทส่งท้าย
จะเห็นได้ว่าการทำตามหลักการ Dependency Injection ควบคู่กับการทำ Mocking นั้นส่งผลให้การเขียน Test ของเรานั้นเป็นไปได้ง่าย และที่สำคัญก็คือ Test Case ของเรานั้นจะมีหน้าที่ในการทดสอบเฉพาะส่วนที่มันควรจะทำ และยังคงเป็นอิสระ (Isolated) จากคลาส (Dependency) อื่นๆ โดยสิ้นเชิง
อ้างอิง
หากบทความมีข้อผิดพลาดประการใดสามารถแนะนำ ติชม ได้เลยนะครับ
Pingback: SOLID หลักการพื้นฐานที่โปรแกรมเมอร์ควรรู้ (ตอนที่ 5 - DIP) - IKQ.ME()