Unit Testing Tutorial Part I: Mock Objects, Stub Methods and Dependency Injection

Lời nói đầu

Sau một khoảng thời gian làm việc thực tế thì cá nhân mình thấy rằng việc code làm sao cho chạy đúng yêu cầu và chuẩn conventions thôi là chưa đủ, code chạy trên local rồi mà không pass test thì còn lâu mới được merge :)). Dự án thì càng ngày càng phình to, khách hàng liên tục thay đổi yêu cầu và thêm chức năng. Lúc này, một vài dòng code nhỏ cũng có thể ảnh hưởng lớn tới toàn bộ hệ thống, bugs chồng bugs. Bạn sẽ phải thốt lên rằng giá mà có một công cụ hỗ trợ việc kiểm soát lỗi thì tốt biết bao. Và đây, Laravel đã support rất tốt cho việc Testing này rồi dù bạn có để ý hay không.

Nội dung

Không vòng vo nữa, bây giờ mình sẽ giới thiệu cho mọi người một ví dụ thể hiện rõ những lợi ích của việc dùng Mock Objects, Stub Methods và tầm quan trọng của Dependency Injection khi viết Test

Ví dụ:

<?php

  namespace phpUnitTutorial;

  class Payment
  {
      const API_ID = 02101998;
      const TRANS_KEY = 'TRANSACTION KEY';

      public function processPayment(array $paymentDetails)
      {
         $transaction = new \AuthorizeNetAIM(self::API_ID, self::TRANS_KEY);
         $transaction->amount = $paymentDetails['amount'];
         $transaction->card_num = $paymentDetails['card_num'];
         $transaction->exp_date = $paymentDetails['exp_date'];

         $response = $transaction->authorizeAndCapture();

         if ($response->approved) {
            return $this->savePayment($response->transaction_id);
         }
         throw new \Exception($response->error_message);
      }

      public function savePayment($transactionId)
      {
          // Logic for saving transaction ID to database or anywhere else would go in here
          return true;
      }
  }

Đây là một đoan code khá đơn giản phục vụ cho việc thanh toán. Nhưng ở đây chúng ta sẽ không thể viết test cho chức năng này, và bạn sẽ sớm tìm ra lý do tại sao. Trước khi viết Test, việc đầu tiên là bạn hãy nghĩ về những gì chúng ta cần kiểm tra từ chức năng được đưa ra.

Hai trường hợp rõ ràng nhất là:

  • $response->approved là true, kích hoạt cuộc gọi ::savePayment() trả về true

  • $response->approved là false, sau đó throw \Exception().

Chúng ta biết rằng ::processPayment() chấp nhận một mảng, và từ đoạn code chúng ta có thể nhìn thấy nó sử dụng amount, cardnum và expdate:

public function testProcessPaymentReturnsTrueOnSuccessfulPayment()
{
    $paymentDetails = array(
        'amount'   => 123.99,
        'card_num' => '4111-1111-1111-1111',
        'exp_date' => '03/2013',
    );

    $payment = new Payment();
    $result = $payment->processPayment($paymentDetails);

    $this->assertTrue($result);
}

Chính xác thì điều gì đã xảy ra tại đây, nhìn sơ qua thì chúng ta có thể thấy rằng máy chủ Authorize.net đã nói rằng: "ID hoặc mật khẩu đăng nhập không hợp lệ hoặc tài khoản không hoạt động".

Có lẽ chúng ta nên lấy thông tin đăng nhập Authorize.net hợp lệ và inject chúng vào function test của mình. Mặc dù điều đó chắc chắn sẽ giải quyết được vấn đề, nhưng vấn đề khác nhanh chóng xảy ra:

  • Nếu bạn đi sâu vào class \AuthorizeNetAIM, bạn sẽ nhận thấy sự phức tạp nhanh chóng tăng lên các phương thức gọi các phương thức khác. Cuối cùng, thậm chí còn có một cuộc gọi cURL call tới các máy chủ của Authorize.net.

  • Điều gì xảy ra nếu các máy chủ của Authorize.net không hoạt động hoặc kết nối mạng của bạn đang có vấn đề khi bạn đang viết và chạy chương trình test của mình.

Nhưng vấn đề là tại sao chúng ta lại đi phụ thuộc vào một lớp bên ngoài mà chúng ta không thể kiểm soát được logic và làm cách nào để loại bỏ đi sự phụ thuộc này . Giải pháp ở đây chính là Mock Objects.

PHPUnit đi kèm với một tính năng rất mạnh mẽ để giúp chúng ta xử lý các phụ thuộc bên ngoài. Về cơ bản, nó liên quan đến việc thay thế đối tượng thực bằng đối tượng 'Fake' hoặc 'Mock' mà chúng ta hoàn toàn có thể kiểm soát, loại bỏ tất cả các phụ thuộc vào các hệ thống hoặc bussiness logic bên ngoài mà chúng ta không cần quan tâm.

Trong class \AuthorizeNetAIM chúng ta thấy rằng rào cản lớn nhất cho việc test chức năng này là phương thức authorizeAndCapture(), nếu đi sâu vào bên trong phương thức này thì chúng ta có thể thấy nó ping đến một máy chủ bên ngoài mà chúng ta không có quyền kiểm soát cũng như không muốn kiểm soát.

Tuy nhiên vẫn còn một vấn đề nhỏ là làm sao chúng ta có thể chuyển Mock objects vào đoạn code mà chúng ta đang test trong khi chúng ta đang khởi tạo trực tiếp đối tượng Authorize.net bên trong nó.

 <?php
 // ...

 $transaction = new \AuthorizeNetAIM(self::API_ID, self::TRANS_KEY);

Dependency Injection

Thay vì sử dụng từ khóa new có trong các phương thức của bạn, hãy chuyển nó vào các tham số.

Vì vậy nên:

 <?php
    // ...

    public function processPayment(array $paymentDetails)
    {
       $transaction = new \AuthorizeNetAIM(self::API_ID, self::TRANS_KEY);
       $transaction->amount = $paymentDetails['amount'];
       $transaction->card_num = $paymentDetails['card_num'];
       $transaction->exp_date = $paymentDetails['exp_date'];

       $response = $transaction->authorizeAndCapture();

       if ($response->approved) {
          return $this->savePayment($response->transaction_id);
       }

       throw new \Exception($response->error_message);
    }

sẽ trở thành:

       public function processPayment(\AuthorizeNetAIM $transaction, array $paymentDetails)
       {
          $transaction->amount = $paymentDetails['amount'];
          $transaction->card_num = $paymentDetails['card_num'];
          $transaction->exp_date = $paymentDetails['exp_date'];

          $response = $transaction->authorizeAndCapture();

          if ($response->approved) {
             return $this->savePayment($response->transaction_id);
          }

          throw new \Exception($response->error_message);
       }

Ở đây chúng ta đang chuyển trách nhiệm tạo đối tượng AuthorizeNetAIM ra khỏi class Payment và chuyển nó sang bất kỳ lớp nào gọi nó. Khái niệm rất đơn giản, nhưng lợi ích rất nhiều.

Nhưng tại sao lại phải tiêm phụ thuộc?

Chúng ta muốn thay thế một phần phụ thuộc trong code của bạn bằng một đối tượng giả. Làm thế nào để bạn làm điều đó một cách chính xác trong khi code của bạn đang bị phụ thuộc trặt trẽ về đối tượng mà nó đang tạo.

   <?php
      // ...

      $transaction = new \AuthorizeNetAIM(self::API_ID, self::TRANS_KEY);

Câu trả lời ngắn gọn: Bạn không thể.

Câu trả lời chi tiết: Bạn có thể nhưng "giải pháp" khá là kinh khủng và bạn nên tránh nó bằng mọi giá: Runkit.

Runkit cho phép bạn thay thế mã trong thời gian chạy, thoạt nghe có vẻ giống như những gì bạn muốn, phải không? Thay thế một đối tượng thực tế trong code của bạn bằng một đối tượng giả. Quá trình này được gọi là quá trình monkey patching và để biết một bản tóm tắt khá tốt về lý do tại sao nó là một ý tưởng tồi, hãy nhấp vào đây .

Thay vì phần khởi tạo đối tượng không thể thay thế, được hiển thị ở trên, việc chuyển phụ thuộc với public function processPayment(\AuthorizeNetAIM $transaction, array $paymentDetails)có nghĩa là bây giờ bạn có thể chuyển vào một đối tượng sẽ vượt qua một kiểm tra is_a().

Vậy kiểm tra is_a() là gì?

Theo khái niệm ở trên thì chúng ta có thể hiểu rằng bất kỳ class nào mở rộng \AuthorizeNetAIM sẽ vượt qua kiểm tra is_a(). Và nó cũng sẽ cần phải vượt qua các yêu cầu nhất định:

  • Có tất cả các phương pháp mà logic test của bạn đang cần.
  • Bất kỳ phương pháp nào gây ra sự cố trong bài test (như authorizeAndCapture()) phải được thay đổi để đảm bảo chúng an toàn cho bài test. Vậy thì đơn giản chỉ cần tạo một class mới, chẳng hạn như \AuthorizeNetAIMFake ghi đè lên tất cả các phương thức và chỉ trả về một số giá trị mong đợi.

Đó thực sự không phải là một ý tưởng tồi, và trên thực tế nó có thể dễ dàng hoạt động tốt cho các dự án có quy mô nhỏ...nhưng điều gì sẽ xảy ra khi bạn có 5 class mà bạn cần ghi đè như này hoặc thậm chí là 10 hoặc 50 ?Bạn có thể dễ dàng có hàng chục class bạn cần được ghi đè. Và bạn có thực sự muốn tạo và duy trì hàng chục class mà chức năng của chúng không làm gì khác ngoài việc mở rộng một class khác và ghi đè lên tất cả các phương thức của nó không? Phải có cách tốt hơn!

PHPUnit để giải quyết!

Một trong những công cụ mạnh mẽ nhất có sẵn cho bạn là getMockBuilder(), nó cho phép bạn tạo một lớp mới đáp ứng được 2 yêu cầu chính của chúng ta ở trên, tất cả đều nhanh chóng. Bạn không cần phải tạo các tệp riêng biệt cho từng class và cũng không cần phải lo lắng về việc duy trì chúng trong quá trình phát triển.

Và đây là $authorizeNet của chúng ta với getMockBuilder():

<?php
   // ...
   $authorizeNet = $this->getMockBuilder('\AuthorizeNetAIM')
      ->setConstructorArgs(array($payment::API_ID, $payment::TRANS_KEY))
      ->getMock();

Phương thức getMockBuilder() trả về một Mock Objects, nhưng có hành vi tương tự như đối tượng ban đầu. Nhưng tất cả các phương thức bên trong Mock Objects đều trả về NULL.

<?php
   // ...
   var_dump($authorizeNet->authorizeAndCapture());

Kết quả bạn nhận được là NULL. Và những phương thức như này được gọi là Stubs method!

Phương thức Stubs

Phương thức Stubs là một phương thức bắt chước phương thức gốc theo 2 cách-chấp nhận cùng tên và cùng tham số. Tuy nhiên, điều làm cho một phương thức stubs trở nên đặc biệt là tất cả code bên trong nó đã bị xóa.
Đây là phương thức gốc từ class \AuthorizeNetAIM

<?php
   // ...
   public function authorizeAndCapture($amount = false, $card_num = false, $exp_date = false)
   {
      ($amount ? $this->amount = $amount : null);
      ($card_num ? $this->card_num = $card_num : null);
      ($exp_date ? $this->exp_date = $exp_date : null);
      $this->type = "AUTH_CAPTURE";
      return $this->_sendRequest();
   }

Hiện tại, chúng ta có thể coi phương thức Stubs là như thế này:

<?php
   // ...
   public function authorizeAndCapture($amount = false, $card_num = false, $exp_date = false)
   {
       return null;
   }

Tất cả các phương thức khác bên trong Mock Object của bạn cũng là Stubs và chúng cũng trả về NULL.

Điều tốt là phương thức authorizeAndCapture() không còn gửi yêu cầu đến máy chủ Authorize.net nữa. Thay vào đó, nó trả về NULL mỗi khi nó được gọi.

Tuy nhiên đây là kicker: Bây giờ bạn có thể ghi đè giá trị trả về theo phương thức Stubs của bạn khi Test, code của bạn sẽ nghĩ rằng giá trị trả về là bình thường và hành động theo ý muốn của bạn.

Giá trị trả về có thể là bất cứ thứ gì: NULL, một chuỗi, một mảng, số nguyên, các đối tượng khác và thậm chí là các Mock Objects khác.

Bây giờ, hãy xem lại logic test của chúng ta:

<?php

   namespace phpUnitTutorial\Test;

   use phpUnitTutorial\Payment;

   class PaymentTest extends \PHPUnit_Framework_TestCase
   {
      public function testProcessPaymentReturnsTrueOnSuccessfulPayment()
      {
         $paymentDetails = array(
            'amount'   => 123.99,
            'card_num' => '4111-1111-1111-1111',
            'exp_date' => '03/2013',
         );

         $payment = new Payment();

         $authorizeNet = $this->getMockBuilder('\AuthorizeNetAIM')
           ->setConstructorArgs(array($payment::API_ID, $payment::TRANS_KEY))
           ->getMock();

         $result = $payment->processPayment($authorizeNet, $paymentDetails);

         $this->assertTrue($result);
     }
  }

Nếu bạn chạy thử nghiệm của mình ngay bây giờ, bạn sẽ nhận được

Payment.php:18 tương ứng với if ($response->approved) đã được khởi tạo với $response = $transaction->authorizeAndCapture();. Như mình đã nói ở trên, bạn biết điều này là do tất cả các phương thức sơ khai đều trả về NULL trừ khi bị ghi đè.

Điều đang xảy ra là $response = NULL, nhưng sau đó chúng ta cố gắng để gọi approved từ đối tượng, Vì vậy nên dòng thông báo Trying to get property of non-object xuất hiện là điều hiển nhiên.

Chúng ta biết chúng ta phải ghi đè giá trị trả về của authorizeAndCapture(), và rất may là nó khá đơn giản!

Để ghi đè giá trị trả về của một Stubs, bạn phải làm quen với 5 phương thức PHPUnit mới:

<?php
   // ...
   $authorizeNet->expects($this->once())
     ->method('authorizeAndCapture')
     ->will($this->returnValue('RETURN VALUE HERE!'));

Trường hợp ở đây chúng ta thấy đối tượng $authorizeNet dự kiến sẽ gọi phương thức authorizeAndCapture() một lần và nó sẽ trả về giá trị RETURN VALUE HERE!.

Đến thời điểm này thì khi run Test chúng ta vẫn nhận được Fail vì authorizeAndCapture() đang trả về một chuỗi, trong khi chúng ta đang mong đợi một đối tượng có khóa approved và transaction_id. Một cách đơn giản cho loại đối tượng này là sử dụng \stdClass():

<?php
   // ...
   $response = new \stdClass();
   $response->approved = true;
   $response->transaction_id = 123;

Bây giờ bạn có thể chuyển nó vào phương thức returnValue(). Đây là một bài test đã hoàn thành.

 <?php

    namespace phpUnitTutorial\Test;

    use phpUnitTutorial\Payment;

    class PaymentTest extends \PHPUnit_Framework_TestCase
    {
       public function testProcessPaymentReturnsTrueOnSuccessfulPayment()
       {
          $paymentDetails = array(
            'amount'   => 123.99,
            'card_num' => '4111-1111-1111-1111',
            'exp_date' => '03/2013',
          );

          $payment = new Payment();

          $response = new \stdClass();
          $response->approved = true;
          $response->transaction_id = 123;

          $authorizeNet = $this->getMockBuilder('\AuthorizeNetAIM')
            ->setConstructorArgs(array($payment::API_ID, $payment::TRANS_KEY))
            ->getMock();

          $authorizeNet->expects($this->once())
            ->method('authorizeAndCapture')
            ->will($this->returnValue($response));

          $result = $payment->processPayment($authorizeNet, $paymentDetails);

          $this->assertTrue($result);
       }
    }

Bây giờ bạn đã biết khái niệm về Mock Object, các phương thức Stubs và tại sao việc Depedency Injection lại là một phần không thể thiếu khi viết Test.

Hẹn gặp lại mọi người vào bài viết tiếp theo trong series này!

Related article