Hướng dẫn phpunit mock constructor - phương thức khởi tạo giả phpunit

Trong bài trước, chúng ta đã được tìm hiểu về các khái niệm rất quan trọng đó là

$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->getMock();
7 và
$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->getMock();
8. Các khái niệm này là trọng tâm của 1 unit test thành công, và một khi nó đã đi sâu vào tâm trí của bạn, bạn sẽ bắt đầu nhận ra unit có ích và đơn giản như thế nào.

Có một thứ khác mà tôi muốn làm rõ đó là: tạo unit tests chỉ đơn giản là 1 trò chơi puzzle, bạn chỉ cần đi từng bước 1, và chắc chắn rằng tất cả các mảnh ghép được khớp đúng với nhau. Tôi hy vọng sẽ làm rõ được điều này sau khi kết thúc bài này.

Mock methods

Bạn đã được biết về

$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->getMock();
9 và
$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->setMethods([])
    ->getMock();
0. Có 1 khái niệm khác cũng khá quan trọng bạn cần phải biết đó là:
$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->setMethods([])
    ->getMock();
1.

Mock Object

$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->getMock();
7 là một đối tượng giả mà chúng ta có toàn quyển kiểm soát, đối tượng này extends từ lớp đang liên quan đến unit test.

Stub Method

$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->getMock();
8 là 1 phương thức được bao gồm bên trong 1
$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->getMock();
7, phương thức này trả về null theo mặc định, nhưng có thể thay đổi dễ dàng.

Mock Method

$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->setMethods([])
    ->getMock();
5 cũng rất đơn giản, nó làm việc giống hoàn toàn với method ban đầu. Nói cách khác, mọi dòng code bên trong method mà bạn đang mocking sẽ được chạy và sẽ không trả về null theo mặc định (trừ khi method ban đầu trả về như thế).

Mark Nichols đưa ra một lời giải thích rất tốt về sự khác nhau giữa mock và stub method.

Nói một cách đơn giản,

$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->setMethods([])
    ->getMock();
1 rất có ích khi bạn muốn code bên trong nó chạy, nhưng cũng muốn thực hiện một số assertions theo hành vi của method. Ví dụ một số assertions như các tham số cụ thể được truyền vào method hoặc method đó được gọi chính xác 3 lần hoặc không được chạy lần nào.

Đừng lo lắng nếu nó không được rõ ràng ngay được.

4 cách dùng getMockBuilder()

Chúng ta đã sử dụng PHPUnit API

$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->setMethods([])
    ->getMock();
7 nhưng bạn có biết, có 4 cách khác nhau để tạo object? Nó phụ thuộc hoàn toàn vào việc sử dụng method
$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->setMethods([])
    ->getMock();
8.

/**
 * Specifies the subset of methods to mock. Default is to mock none of them.
 *
 * @return MockBuilder
 */
public function setMethods(array $methods = null)
{
    $this->methods = $methods;
    return $this;
}
TH1: Không gọi method
$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->setMethods([])
    ->getMock();
8

Đây là cách đơn giản nhất:

$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->getMock();

Đoạn này sẽ tạo ra 1 mock object trong đó các method của nó:

  • Tất cả đều là stub,
  • Tất cả trả về null theo mặc định,
  • Dễ dàng override.
TH2: Truyền vào một mảng rỗng

Bạn có thể truyền vào một mảng rỗng cho method

$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->setMethods([])
    ->getMock();
8:

$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->setMethods([])
    ->getMock();

Điều này sẽ tạo ra 1 mock object giống hoàn toàn với cách bạn không gọi method

$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->setMethods([])
    ->getMock();
8. Các method trong object này:

  • Tất cả đều là stub,
  • Tất cả trả về null theo mặc định,
  • Dễ dàng override.
TH2: Truyền vào một mảng rỗng

Bạn có thể truyền vào một mảng rỗng cho method

$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->setMethods([])
    ->getMock();
8:

$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->setMethods(null)
    ->getMock();

Điều này sẽ tạo ra 1 mock object giống hoàn toàn với cách bạn không gọi method

$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->setMethods([])
    ->getMock();
8. Các method trong object này:

  • TH3: Truyền vào null
  • Bạn cũng có thể truyền vào
    $authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
        ->setMethods(null)
        ->getMock();
    
    2:
  • Trường hợp này sẽ tạo ra 1 mock object, trong đó các methods:
Tất cả đều là mock,
$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->setMethods(['authorizeAndCapture', 'foobar'])
    ->getMock();

Chạy code thực tế trong phương thức ban đầu khi được gọi,

  • Không cho phép override return value.

    • Tất cả đều là stub,
    • Tất cả trả về null theo mặc định,
    • Dễ dàng override.
  • TH2: Truyền vào một mảng rỗng

    • TH3: Truyền vào null
    • Bạn cũng có thể truyền vào
      $authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
          ->setMethods(null)
          ->getMock();
      
      2:
    • Trường hợp này sẽ tạo ra 1 mock object, trong đó các methods:

Tất cả đều là mock,

Chạy code thực tế trong phương thức ban đầu khi được gọi,

Không cho phép override return value.



namespace App;

class BadCode
{
    protected $user;

    public function __construct(array $user)
    {
        $this->user = $user;
    }

    public function authorize($password)
    {
        if ($this->checkPassword($password)) {
            return true;
        }

        return false;
    }

    protected function checkPassword($password)
    {
        if (empty($this->user['password']) || $this->user['password'] !== $password) {
            echo 'YOU SHALL NOT PASS';
            exit;
        }

        return true;
    }
}

TH4: Truyền vào một mảng chứa tên các method

Trong trường hợp này, mock object được tạo ra có các method có đặc điểm của 3 trường hợp trước:

Với các method bạn đưa ra trong mảng:

protected function checkPassword($password)
{
    if (empty($this->user['password']) || $this->user['password'] !== $password) {
        echo 'YOU SHALL NOT PASS';
        $this->callExit();
    }

    return true;
}

protected function callExit()
{
    exit;
}

Với các method còn lại:

Điều này có nghĩa là trong mock object

$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->setMethods(null)
    ->getMock();
3 thì
$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->setMethods(null)
    ->getMock();
4 và
$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->setMethods(null)
    ->getMock();
5 sẽ trả về null theo mặc định hoặc bạn có thể override giá trị trả về, còn tất cả các method khác trong đối tượng này sẽ chạy code ban đầu.



namespace Tests;

class BadCodeTest extends TestCase
{
    public function testAuthorizeExitsWhenPasswordNotSet()
    {
        $user = ['username' => 'jtreminio'];
        $password = 'foo';

        $badCode = $this->getMockBuilder(App\BadCode::class)
            ->setConstructorArgs([$user])
            ->setMethods(['callExit'])
            ->getMock();

        $badCode->expects($this->once())
            ->method('callExit');

        $this->expectOutputString('YOU SHALL NOT PASS');

        $badCode->authorize($password);
    }
}

Tại sao lại cần mock methods?

Tôi sẽ bắt đầu với 1 ví dụ rất đơn giản mà bạn có thể gặp nhiều trong đời lập trình:

Một class đơn giản thể hiện 1 vấn đề đơn giản: Nếu mật khẩu của user không được set, sẽ echo ra error cho người dùng và dừng script.

$badCode->expects($this->once())
    ->method('callExit');

Vấn đề với với class này đó là nó gọi

$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->setMethods(null)
    ->getMock();
6 làm cho code PHP hiện đang chạy sẽ bị dừng, bao gồm cả các unit tests bạn đang chạy. Có gì đó sai sai!

Nếu bạn cố gắng định nghĩa lại giá trị trả về của



namespace App;

class BadCode
{
    protected $user;

    public function __construct(array $user)
    {
        $this->user = $user;
    }

    public function authorize($password)
    {
        if ($this->checkPassword($password)) {
            return true;
        }

        return false;
    }

    protected function checkPassword($password)
    {
        if (empty($this->user['password']) || $this->user['password'] !== $password) {
            echo 'YOU SHALL NOT PASS';
            exit;
        }

        return true;
    }
}
1 với


namespace App;

class BadCode
{
    protected $user;

    public function __construct(array $user)
    {
        $this->user = $user;
    }

    public function authorize($password)
    {
        if ($this->checkPassword($password)) {
            return true;
        }

        return false;
    }

    protected function checkPassword($password)
    {
        if (empty($this->user['password']) || $this->user['password'] !== $password) {
            echo 'YOU SHALL NOT PASS';
            exit;
        }

        return true;
    }
}
4 PHPUnit sẽ bỏ qua nó và tiếp tục với test. Nhớ rằng, mock methods không cho phép override giá trị trả về.

Xử lý Bad Constructors

Thỉnh thoảng bạn đọc qua những đoạn code cũ và có những constuctor mà nó đi rất phức tạp không phải chỉ là việc khởi tạo giá trị cho các thuộc tính.

Miško Hevery đã đưa là một quy tắc giải thích cho việc tại sao constructor chỉ nên làm một việc đơn giản đó là khởi tạo thuộc tính cho đối tượng theo các tham số truyền vào.

Một ví dụ của constructor có thiết kế không tốt:



namespace App;

class BadCode
{
    protected $user;

    public function __construct(array $user)
    {
        $this->user = $user;
    }

    public function authorize($password)
    {
        if ($this->checkPassword($password)) {
            return true;
        }

        return false;
    }

    protected function checkPassword($password)
    {
        if (empty($this->user['password']) || $this->user['password'] !== $password) {
            echo 'YOU SHALL NOT PASS';
            exit;
        }

        return true;
    }
}
5:



namespace App;

class NaughtyConstructor
{
    public $html;

    public function __construct($url)
    {
        $this->html = file_get_contents($url);
    }

    public function getMetaTags()
    {
        $mime = 'text/plain';
        $filename = "data://{$mime};base64," . base64_encode($this->html);

        return get_meta_tags($filename);
    }

    public function getTitle()
    {
        preg_match("#(.+)#siU", $this->html, $matches);

        return !empty($matches[1]) ? $matches[1] : false;
    }
}

Cấu trúc của class này bắt chước nhiều class khác mà bạn có thể gặp. Để sử dụng nó, bạn có thể gọi như sau:

$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->getMock();
0

Nếu không cuộn xuống dưới, bạn có thể chỉ ra vấn đề lớn nhất khi test đoạn code này không?

Câu trả lời: Vì bạn đã tạo ra một dependency vào



namespace App;

class BadCode
{
    protected $user;

    public function __construct(array $user)
    {
        $this->user = $user;
    }

    public function authorize($password)
    {
        if ($this->checkPassword($password)) {
            return true;
        }

        return false;
    }

    protected function checkPassword($password)
    {
        if (empty($this->user['password']) || $this->user['password'] !== $password) {
            echo 'YOU SHALL NOT PASS';
            exit;
        }

        return true;
    }
}
6 trong constructor, bạn phải online mới test được class này. Tests không nên phụ thuộc vào bất cứ thứ gì bên ngoài.

Tạo một unit test đơn giản cho code hiện tại



namespace App;

class BadCode
{
    protected $user;

    public function __construct(array $user)
    {
        $this->user = $user;
    }

    public function authorize($password)
    {
        if ($this->checkPassword($password)) {
            return true;
        }

        return false;
    }

    protected function checkPassword($password)
    {
        if (empty($this->user['password']) || $this->user['password'] !== $password) {
            echo 'YOU SHALL NOT PASS';
            exit;
        }

        return true;
    }
}
7:

$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->getMock();
1

Trước khi chạy test case đơn giản này, tôi bật chế độ máy bay trên laptop để ngắt kết nối Internet. Và sau đó chạy test:

$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->getMock();
2

Sau một khoảng thời gian khá dài chờ đợi, tôi nhận được kết quả đã được báo trước: Failed!

Internet Dependency!

Có nhiều cách để làm cho class này tốt hơn, nhưng hiện tại, với mục đích của bài này, tôi muốn giả sử rằng class đặc biệt này không thể bị thay đổi, chúng ta chỉ được viết unit test cho nó mà không được thay đổi gì trong class.

Nếu muốn thay đổi class, một số cách có thể là:

  • Truyền HTML như là tham số của constructor (vd:
    
    
    namespace App;
    
    class BadCode
    {
        protected $user;
    
        public function __construct(array $user)
        {
            $this->user = $user;
        }
    
        public function authorize($password)
        {
            if ($this->checkPassword($password)) {
                return true;
            }
    
            return false;
        }
    
        protected function checkPassword($password)
        {
            if (empty($this->user['password']) || $this->user['password'] !== $password) {
                echo 'YOU SHALL NOT PASS';
                exit;
            }
    
            return true;
        }
    }
    
    8)
  • Di chuyển lời gọi
    
    
    namespace App;
    
    class BadCode
    {
        protected $user;
    
        public function __construct(array $user)
        {
            $this->user = $user;
        }
    
        public function authorize($password)
        {
            if ($this->checkPassword($password)) {
                return true;
            }
    
            return false;
        }
    
        protected function checkPassword($password)
        {
            if (empty($this->user['password']) || $this->user['password'] !== $password) {
                echo 'YOU SHALL NOT PASS';
                exit;
            }
    
            return true;
        }
    }
    
    6 ra ngoài constructor và sử dụng stub method.

Ở đây chúng ta cần mock constructor:

Thay thế dòng đầu tiên trong unit test,

protected function checkPassword($password)
{
    if (empty($this->user['password']) || $this->user['password'] !== $password) {
        echo 'YOU SHALL NOT PASS';
        $this->callExit();
    }

    return true;
}

protected function callExit()
{
    exit;
}
0, với PHPUnit
$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->setMethods([])
    ->getMock();
7:

$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->getMock();
3

Nếu bạn còn nhớ, bất cứ method nào bạn khai báo trong

$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->setMethods([])
    ->getMock();
8 nó sẽ trở thành stub, trả về null theo mặc định.

Nhưng trường hợp này thì không. Tại sao?

Bạn không thể stub constructor. Một stub method trả về null theo mặc định. Khi bạn khởi tạo một đối tượng với

protected function checkPassword($password)
{
    if (empty($this->user['password']) || $this->user['password'] !== $password) {
        echo 'YOU SHALL NOT PASS';
        $this->callExit();
    }

    return true;
}

protected function callExit()
{
    exit;
}
3 PHP trả về một instance của class. Vì vậy, nó sẽ không có ý nghĩa gì nếu bạn thay đổi và trả về null thay vì một đối tượng mới đúng không?

PHPUnit có một giải pháp là

protected function checkPassword($password)
{
    if (empty($this->user['password']) || $this->user['password'] !== $password) {
        echo 'YOU SHALL NOT PASS';
        $this->callExit();
    }

    return true;
}

protected function callExit()
{
    exit;
}
4:

$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->getMock();
4

Lưu ý, truyền

protected function checkPassword($password)
{
    if (empty($this->user['password']) || $this->user['password'] !== $password) {
        echo 'YOU SHALL NOT PASS';
        $this->callExit();
    }

    return true;
}

protected function callExit()
{
    exit;
}
5 vào method
$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->setMethods([])
    ->getMock();
8 nhìn có vẻ không cần thiết lắm, nhưng nhớ lại nếu bạn không gọi
$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->setMethods([])
    ->getMock();
8 hoặc truyền một mảng rỗng
protected function checkPassword($password)
{
    if (empty($this->user['password']) || $this->user['password'] !== $password) {
        echo 'YOU SHALL NOT PASS';
        $this->callExit();
    }

    return true;
}

protected function callExit()
{
    exit;
}
8 và
$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->setMethods([])
    ->getMock();
8 thì tất cả các methods trong đối tượng sẽ trở thành stub và trả về null. Điều đó không phải là điều chúng ta mong muốn.

Chạy lại PHPUnit và... vẫn failed.

Dĩ nhiên là nó fail bởi



namespace Tests;

class BadCodeTest extends TestCase
{
    public function testAuthorizeExitsWhenPasswordNotSet()
    {
        $user = ['username' => 'jtreminio'];
        $password = 'foo';

        $badCode = $this->getMockBuilder(App\BadCode::class)
            ->setConstructorArgs([$user])
            ->setMethods(['callExit'])
            ->getMock();

        $badCode->expects($this->once())
            ->method('callExit');

        $this->expectOutputString('YOU SHALL NOT PASS');

        $badCode->authorize($password);
    }
}
0 đang trống do chúng ta đã disable constructor.

Điều này mang đến một điểm thú vị khác: điều gì xảy ra nên chúng ta cố gắng test 1 website mà chúng ta không có quyền điều khiển? Chúng ta có thể test HTML của website đó trong vài tuần nhưng rồi một ngày họ thay đổi code, cấu trúc web và không còn thẻ meta author mà chúng ta cần nữa, test failed. Đây là một điểm nữa trong việc tránh có các sự phụ thuộc bên ngoài trong unit tests.

Giải pháp ở đây đơn giản là sử dụng một đoạn HTML mẫu trong test.

Bây giờ, test của bạn như sau:

$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->getMock();
5

Chúng ta đã xong một mục tiêu quan trọng trong unit test: loại bỏ phụ thuộc bên ngoài.

Chạy lại phpunit và passed!

Chúng ta còn có thể thêm một test khác:

$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->getMock();
6

Green bar!

Tổng kết

Hôm nay bạn đã học về mảnh ghép cuối cùng trong vấn đề mock và stub: mock methods.

Các định nghĩa khó hiểu về mock objects, stub methods và mock methods có thể làm cho bạn nản chí lúc đầu, nhưng tôi tự tin rằng một khi bạn tìm ra sự khác biệt giữa ba khái niệm này, và khi bạn cần mock methods thay vì stub methods hoặc ngược lại, bạn sẽ trở thành 1 tester giỏi hơn, 1 developer giỏi hơn.