+4

PHP Unit Test 601: Mock Methods và Constructor Overriding

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à mock objectstub method. 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ề mock objectsstub methods. Có 1 khái niệm khác cũng khá quan trọng bạn cần phải biết đó là: mock methods.

Mock Object

mock object 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

stub method là 1 phương thức được bao gồm bên trong 1 mock object, phương thức này trả về null theo mặc định, nhưng có thể thay đổi dễ dàng.

Mock Method

mock method 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, mock methods 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 getMockBuilder() 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 setMethods().

/**
 * 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 setMethods()

Đâ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 setMethods():

$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 setMethods(). 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.
TH3: Truyền vào null

Bạn cũng có thể truyền vào null:

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

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.
TH4: Truyền vào một mảng chứa tên các method
$authorizeNet = $this->getMockBuilder(AuthorizeNetAIM::class)
    ->setMethods(['authorizeAndCapture', 'foobar'])
    ->getMock();

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:

    • Tất cả đều là stub,
    • Tất cả trả về null theo mặc định,
    • Dễ dàng override.
  • Với các method còn lại:

    • 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.

Điều này có nghĩa là trong mock object $authorizeNet thì ::authorizeAndCapture()::foobar() 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.

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:

<?php

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;
    }
}

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.

Vấn đề với với class này đó là nó gọi exit 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!

Một giải pháp tối ưu đó là không gọi exit trong đoạn code đó nữa. Bạn nên cân nhắc trả về value thay vì gọi exit. Nếu không thể làm điều này, có 1 giải pháp khác đó là wrap exit trong 1 method khác và stub 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;
}

Bây giờ class này đã có thể test được, bạn chỉ cần stub method callExit().

Đây là unit test của chúng ta:

<?php

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);
    }
}

Bằng cách truyền vào mảng ['callExit'] vào method setMethods(), bạn đã tạo ra 1 mock object với cả stub và mock method. Trong ví dụ này, callExit() là method duy nhất được stub, còn tất cả các method khác là mock.

Khi code chạy đến đoạn if (empty($this->user['password']) || $this->user['password'] !== $password) { và gọi callExit(), unit test của bạn sẽ không bị dừng vì callExit() đã được stub thay vì chạy code thật bên trong nó.

Nếu bạn stub nhiều method trong 1 mock object, bạn có thể gọi expects() nhiều lần. Những lần gọi đó được xem như soft assertion và nó không vi phạm mục tiêu chỉ 1 assertion trong 1 unit test. Nếu bạn bỏ đi dòng:

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

code của chúng ta vẫn sẽ pass. Tuy nhiên, ý nghĩa của expects() ở đây là chúng ta muốn chắc chắn method callExit() sẽ được gọi 1 lần bên trong method checkPassword(), nếu trong tương lại code không gọi được method callExit() thì unit test của chúng ta sẽ bị fail ngay và chúng ta sẽ biết ngay được chỗ nào gây ra lỗi.

Nếu bạn cố gắng định nghĩa lại giá trị trả về của checkPassword() với ->will($this->returnValue('RETURN VALUE HERE!')); 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:

src/NaughtyConstructor.php:

<?php

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("#<title>(.+)</title>#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:

$naughty = new NaughtyConstructor('http://jtreminio.com');
$metaTags = $naughty->getMetaTags();
$title = $naughty->getTitle();

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 file_get_contents() 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 tests/NaughtConstructorTest.php:

<?php

namespace Tests;

use PHPUnit\Framework\TestCase;
use App\NaughtyConstructor;

class NaughtyConstructorTest extends TestCase
{
    public function testGetMetaTagsReturnsArrayOfProperties()
    {
        $naughty = new NaughtyConstructor('http://jtreminio.com');

        $result = $naughty->getMetaTags();

        $expectedAuthor = 'Juan Treminio';

        $this->assertEquals(
            $expectedAuthor,
            $result['author']
        );
    }
}

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:

./vendor/bin/phpunit tests/NaughtyConstructorTest.php

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: $naughty = new NaughtyConstructor($html);)
  • Di chuyển lời gọi file_get_contents() 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, $naughty = new NaughtyConstructor('http://jtreminio.com');, với PHPUnit getMockBuilder():

$naughty = $this->getMockBuilder(NaughtyConstructor::class)
    ->setMethods(['__construct'])
    ->setConstructorArgs(['http://jtreminio.com'])
    ->getMock();

Nếu bạn còn nhớ, bất cứ method nào bạn khai báo trong setMethods() 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 new 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à disableOriginalConstructor():

$naughty = $this->getMockBuilder(NaughtyConstructor::class)
    ->setMethods(['__construct'])
    ->setConstructorArgs(['http://jtreminio.com'])
    ->disableOriginalConstructor()
    ->getMock();

Lưu ý, truyền __construct vào method setMethods() 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 setMethods() hoặc truyền một mảng rỗng []setMethods() 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 $this->html đ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:

<?php

namespace Tests;

use PHPUnit\Framework\TestCase;
use App\NaughtyConstructor;

class NaughtyConstructorTest extends TestCase
{
    public function testGetMetaTagsReturnsArrayOfProperties()
    {
        $naughty = $this->getMockBuilder(NaughtyConstructor::class)
            ->setMethods(['__construct'])
            ->setConstructorArgs(array('http://jtreminio.com'))
            ->disableOriginalConstructor()
            ->getMock();

        $naughty->html = $this->getHtml();

        $result = $naughty->getMetaTags();

        $expectedAuthor = 'Juan Treminio';

        $this->assertEquals(
            $expectedAuthor,
            $result['author']
        );
    }

    protected function getHtml()
    {
        return '
             <!DOCTYPE html>
                <html lang="en">
                <head>
                    <meta name="viewport" content="width=1, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no"/>
                    <meta name="description" content="Dallas PHP/MySQL Web Developer"/>
                    <meta name="author" content="Juan Treminio"/>
                    <meta name="generator" content="PieCrust 1.0.0-dev"/>
                    <meta name="template-engine" content="Twig"/>
                    <title>Juan Treminio - Dallas PHP/MySQL Web Developer &mdash; Blog</title>
                </head>
                <body>
                </body>
                </html>
        ';
    }
}

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:

public function testGetTitleReturnsExpectedTitle()
{
    $naughty = $this->getMockBuilder(NaughtyConstructor::class)
        ->setMethods(['__construct'])
        ->setConstructorArgs(['http://jtreminio.com'])
        ->disableOriginalConstructor()
        ->getMock();

    $naughty->html = $this->getHtml();

    $result = $naughty->getTitle();

    $expectedTitle = 'Juan Treminio - Dallas PHP/MySQL Web Developer &mdash; Blog';

    $this->assertEquals(
        $expectedTitle,
        $result 
    );
}

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.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí