+11

PHPUnit - Bạn đã hiểu đúng về Stub, Mock, Spy, Fake, Dummy chưa?

Test Doubles in PHPUnit

Bài này mình muốn đi vào phân tích 1 số đối tượng Mock khi viết UnitTest, mọi người thường nhập nhằng giữa các khái niệm này. Khi cần thì tạo Mock mà ít quan tâm đến các practice cụ thể của nó nên áp dụng vào trường hợp nào. Điều này thực ra mà nói thì không ảnh hưởng đến chất lượng sản phẩm, nhưng nó cũng gây ra 1 số phiền toái nhất định trong quá trình phát triển sản phẩm. Có thể nhiều người cho rằng khi Test thì lãng phí tài nguyên 1 chút cũng không sao vì xét cho cùng chỉ Developer mới làm việc với chúng. Tuy thế viết test ngon hơn cũng là 1 thang đo đánh gía trình độ người lập trình viên, nếu chú ý thêm 1 chút mà để code ngon hơn, ngắn gọn hơn thì tại sao lại không ?

Why We Need Them?

Đầu tiên nhắc lại 1 chút lý thuyết, về cái mọi người thường hay gọi là Mock - nhưng thực ra tên quy ước chuẩn của nó là Test Double. Vậy thì Test Doubles dùng để làm gì?

Code có tính phụ thuộc, không có chức năng nào gói gọn trong 1 Unit, Code depending on other Code và hơn thế nó còn phụ thuộc đến những thứ không nằm trong Code của ta như database, biến toàn cục hay http request,... Những thành phần mà 1 Unit cần đến như vậy gọi là dependency. Ta không thể kiểm soát 100 % các dependencies này có hoạt động như kỳ vọng hay không? Test các dependencies thường rất khó khăn do bị phụ thuộc vào những đoạn Code nằm ngoài dự đoán hay thậm chí có rất nhiều trường hợp là bất khả thi (internet trục trặc, môi trường bị lỗi, ...)

Để test những Unit phụ thuộc vào các dependencies khác, bạn cần giả định được trạng thái của các dependencies này để đảm bảo Input hoặc Output của nó nằm trong phạm vi tính toán của mình. 1 trong những phương án nổi tiếng để thực hiện điều này là thông qua Dependencies Injection.

VD:

Kết quả trả về 1 request từ API luôn có giá trị là 1 chuỗi JSON định sẵn cố định {key: value}

vardump($this->httpRepository->getRequest());
// Kết quả phải luôn là
// {key: value}

Test Doubles chính là những đối tượng giả định như vậy và nó không cần thiết phải đầy đủ chức năng của 1 đối tượng thật.

Về mặt lý thuyết có 5 loại doubles:

  • Dummy Object
  • Test Stubs
  • Test spies
  • Test Mocks
  • Test Fakes

PHPUnit cung cấp 1 cách chính thống Dummy, Stubs và Mocks. Spies có thể tạo ra dựa vào các mocking API còn Test Fakes thì không. Về khái niệm thì Test Fakes và Test Stub cũng tương đối giống nhau. Nếu muốn hiểu chi tiết hơn nữa xin hãy vào đây: https://martinfowler.com/articles/mocksArentStubs.html

Dummy Object

Đây là loại test double đơn giản nhất. Sử dụng khi chúng ta chỉ cần giả định 1 dependency cho đủ quân số mà không cần nó thực hiện bất cứ chức năng nào

Cùng xem ví dụ sau:

<?php  
/**
* Baz
*/
class Baz
{
    public $foo;
    public $bar;

    function __construct(Foo $foo, Bar $bar)
    {
        $this->foo = $foo;
        $this->bar = $bar;
    }

    public function processFoo()
    {
        return $this->foo->process();
    }

    public function mergeBar()
    {
        if ($this->bar->getStatus() == 'merge-ready') {
            $this->bar->merge();

            return true;
        }

        return false;
    }
}

?>

Để test các unit trong class Baz, ta bắt buộc phải pass Foo và Bar object vào constructs() của Baz.

<?php  
declare(strict_types=1);

use PHPUnit\Framework\TestCase;

/**
 * @covers Bar
 */
final class BarTest extends TestCase
{
    public function testMergeBarCorrectly()
    {
        $foo = $this->getMockBuilder('Foo')->getMock();
        $bar = $this->getMockBuilder('Bar')
            ->setMethods(['getStatus', 'merge'])
            ->getMock();
        $bar->expects($this->once())
            ->method('getStatus')
            ->will($this->returnValue('merge-ready'));

        $baz = new Baz($foo, $bar);
        $this->assertTrue($baz->mergeBar(), 'Baz::mergeBar did not correctly merge');
    }
}

?>

Trong trường hợp này $foo đóng vai trò của Dummy Object, test này không quan tâm $foo làm nhiệm vụ gì, đơn giản nó có mặt để đủ arguments cho hàm constructs() của class Baz.

Test Stubs

1 Stub là 1 object mà ta khi gọi tới 1 method nào đó của object, ta định trước kết quả của method đó. Ở chính ví dụ phía trên, $bar chính là 1 Stub Object, mỗi khi method getStatus() được gọi nó sẽ trả về kết quả mà ta đã quy định trước. Mặc định PHPUnit không thể giả lập các protected và private method, nếu muốn bạn sẽ phải sử dụng Reflection API để tạo ra 1 copy của đối tượng cần giả lập và thiết lập lại scope của method (những framework như Mockery hỗ trợ sẵn điều này).

<?php  
/**
* Foo
*/
class Foo
{
    protected $message;

    protected function bar($evn)
    {
        $this->message = "PROTECTED BAR";

        if($evn == 'dev') {
            $this->message = "DEVELOPER BAR";
        }
    }
}

?>

Sử dụng Reflection để giả lập hàm bar()

<?php  
declare(strict_types=1);

use PHPUnit\Framework\TestCase;

/**
 * @covers Foo
 */
final class FooTest extends TestCase
{
    public function testProtectedBar()
    {
        $reflectedMethod = new ReflectionMethod('Foo', 'bar');
        $reflectedMethod->setAccessible(true);
        $foo = new Foo();
        $reflectedMethod->invoke($foo, 'dev');

        $this->assertAttributeEquals(
            'PROTECTED BAR',
            'message',
            $reflectedMethod,
            'Did not get expected message'
        );
    }
}

?>

Khi viết test, bạn cũng có thể dựa vào số lượng stubs như một dấu hiệu liệu code của bạn có đủ tốt hay không? Khi số lượng stubs tăng lên nhanh chóng, cũng có nghĩa là Unit đó đang phụ thuộc quá nhiều vào các dependencies hoặc Unit đó đang thực hiện quá nhiều nhiệm vụ. Điều đó đồng nghĩa với việc đã đến lúc xem xét re-factor lại cấu trúc code.

PHPUnit hỗ trợ thiết lập các kỳ vọng đối với Stub khi thực thi test. Các kỳ vọng (expectations) này có thể là số lần method được gọi đến, thứ tự method được gọi đến, kết quả trả về dựa trên Input đầu vào. Chi tiết hơn xin tham khảo ở PHPUnit documentation (PHPUnit documentation.

Test Spies

Spy là 1 Stub nhưng không giả lập kết quả trả về, đồng nghĩa bạn chỉ quan tâm đến method đó có được gọi hay không và bao nhiêu lần chứ không phải kết quả của nó.

<?php  
/**
* Alpha
*/
class Alpha
{
    protected $beta;

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

    public function cromulate($deltas)
    {
        foreach ($deltas as $delta) {
            $this->beta->process($delta);
        }
    }
}

?>

Test Class không quan tâm giá trị trả về của hàm process()

<?php  
declare(strict_types=1);

use PHPUnit\Framework\TestCase;

/**
 * @covers Alpha
 */
final class AlphaTest extends TestCase
{
    public function testExpectedTimesBetaProcessCalled()
    {
        $beta = $this->getMockBuilder('Beta')
            ->setMethods(['process'])
            ->getMock();
        $beta->expects($this->at(2))->method('process');
        $deltas = ['Hello', 'Ohaiyo Gozaimasu', 'Xin chao'];

        $alpha = new Alpha($beta);
        $alpha->cromulate($deltas);
    }
}
?>

Mock Object

Mock khác hẳn với những loại test double ở trên, đối tượng này hoạt động y hệt như method thực sự mà nó giả lập lại. Nói cách khác, 1 mock sẽ thực sự chạy và bạn không thể quy định kết quả trả về cho nó. Mock Object thường được sử dụng khi bạn muốn test 1 chức năng, có các method liên quan đến nhau hoặc trong trường hợp bạn muốn trông đợi 1 vài assertion khi method đó thực thi.

Để hiểu về Mock Object hãy nghiên cứu ví dụ sau: Viết 1 chương trình demo đơn giản, nếu user nhập đúng password thì authorize, không thì echo ra thông báo lỗi và kết thúc chương trình.

<?php  
/**
* Auth
*/
class Auth
{
    protected $user;

    public function __construct($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 dont have permission';
            $this->callExit();
        }

        return true;
    }

    protected function callExit()
    {
        exit;
    }
}

?>

Ở đây, mình tạo ra Mock Object giả lập lại class Auth, điều đặc biệt là Object này tồn tại 1 stub method là exit(). Các method khác sẽ hoạt động như bình thường, riêng method exit() thay vì gọi hàm exit() của PHP sẽ trả về null, đảm bảo cho UnitTest vẫn hoạt động trơn tru thay vì bị ngắt ngay lúc hàm exit được gọi. Khi gọi $mockObject->authorize($password);, code chạy như lúc thực thi chương trình thật và trả về kết quả là dòng thông báo Không có quyền truy cập.

<?php  
declare(strict_types=1);

use PHPUnit\Framework\TestCase;

/**
 * @covers Auth
 */
final class AuthTest extends TestCase
{
    public function testAuthorizeWhenWrongPassword()
    {
        $user = array('username' => 'kopitop');
        $password = 'wrongpassword';

        $mockObject = $this->getMockBuilder('Auth')
            ->setConstructorArgs(array($user))
            ->setMethods(array('callExit'))
            ->getMock();

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

        $this->expectOutputString('You dont have permission');

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

?>

Kết luận

Như vậy PHPUnit cung cấp đến 4 loaị test double mà mỗi loại được dùng trong những case khác nhau, đảm bảo cho Code ngắn gọn và sử dụng lượng tài nguyên ít nhất có thể. Sẽ là sai lầm nếu tạo ra 1 Mock Object với đầy đủ hành vi cho 1 test chỉ cần dùng đến Dummy Object nhanh gọn nhẹ. 1 dự án lớn có thể có hàng trăm hoặc nhiều hơn số lượng Test và việc chờ đợi chạy test không phải lúc nào cũng vui vẻ khi code đã chạy ổn thì tối ưu được chỗ nào hay chỗ đó.

References

The Grumpy Programmers Phpunit Cookbook - Chris Hartjes https://martinfowler.com/articles/mocksArentStubs.html https://phpunit.de/manual/current/en/test-doubles.html http://php.net/manual/en/book.reflection.php


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í