Trong bài trước, chúng ta đã thực hành nhiều hơn các unit test và tìm hiểu về khái niệm data provider trong việc sử dụng bộ input cho 1 unit test.
Đến bài này, chúng ta sẽ tìm hiểu phương pháp test các method private hoặc protected.

Giới thiệu

Nếu bạn đã đọc phần thứ hai của loạt bài này, bạn sẽ nhận thấy rằng chúng ta tạo đối tượng thuộc lớp cần test thông qua toán tử new thông thường. Bạn có thể tự hỏi là làm thế nào để test các phương thức private hay protected nếu bạn không thể gọi các method đó trực tiếp thông qua đối tượng đã tạo ra $url->someProtectedMethod().

Thường thì câu trả lời sẽ là: “Bạn không trực tiếp test các phương thức private hay protected”. Vì bất cứ điều gì ngoài các phương thức, thuộc tính public, chỉ có thể truy cập được trong phạm vi của lớp, chúng ta giả định rằng các phương thức public của lớp sẽ tương tác với phương thức private / proteced, do đó, cuối cùng bạn đã gián tiếp test các phương thức này.

Tất nhiên, luôn luôn có những ngoại lệ: Điều gì sẽ xảy ra nếu bạn đang test một lớp trừu tượng có các phương thức protect nhưng nó không được trực tiếp sử dụng trong lớp đó?

Điều gì sẽ xảy ra nếu bạn muốn test các kịch bản khác nhau cho một phương thức cụ thể trong khi bạn không thể áp dụng các kịch bản đó thông qua các public method?

Sau đây chúng ta sẽ tìm hiểu quá trình.

Stupid User class

Tạo một tệp mới tại ./phpunit-tut/src/User.php có nội dung sau:

Ghi chú: Code của lớp User này không tốt. Việc sử dụng md5() để hash mật khẩu là điều nên tránh bằng mọi giá! Trong thực tế, nó là một class được implement khá tồi. Nhưng nó cung cấp một ví dụ rất đơn giản cho việc testing.

<?php

namespace App;

class User
{
    const MIN_PASS_LENGTH = 4;

    private $user = ;

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

    public function getUser()
    {
        return $this->user;
    }

    public function setPassword($password)
    {
        if (strlen($password) < self::MIN_PASS_LENGTH) {
            return false;
        }

        $this->user['password'] = $this->cryptPassword($password);

        return true;
    }

    private function cryptPassword($password)
    {
        return md5($password);
    }
}

Unit test của chúng ta sẽ khởi tạo một đối tượng User mới user = new User($details);

Bạn có thể gọi phương thức ::setPassword(), nhưng không thể gọi ::cryptPassword(), nhưng trong trường hợp này bạn không cần phải làm điều đó. Thực tế là việc method public gọi đến method private đã đủ để nói rằng “Method này đã được test”, ít nhất là với đoạn code cụ thể này.

Như vậy thì bạn sẽ tạo unit test cho method này như thế nào? Bạn có thể thấy method khởi tạo và ::setPassword() đều yêu cầu 1 tham số được truyền vào. PHPUnit không đòi hỏi phép thuật nào đặc biệt để làm việc với các tham số của method, như bạn sẽ sớm thấy.

Tạo unit test cho class User

Tạo file test ./phpunit-tut/tests/UserTest.php

<?php
namespace Tests;

use PHPUnitFrameworkTestCase;
use AppUser;

class UserTest extends TestCase
{
    //
}

Chúng ta cần xác định xem sẽ test cái gì trước khi đi xa hơn. Class src/User.php là rất đơn giản, nên chúng ta có thể có 2 kịch bản đơn giản:

  • ::setPassword() returns true khi password được thiết lập,
  • ::getUser() trả về array chứa password mới và password này sẽ được so sánh với kết quả mong đợi.

Chúng ta sẽ bắt đầu với ::setPassword() trả về true:

<?php
namespace Tests;

use PHPUnitFrameworkTestCase;
use AppUser;

class UserTest extends TestCase
{
    public function testSetPasswordReturnsTrueWhenPasswordSuccessfullySet()
    {
        $details = ;

        $user = new User($details);
    }
}

Bây giờ chúng ta sẽ định nghĩa ra tham số bắt buộc cho ::setPassword() method và gọi nó:

public function testSetPasswordReturnsTrueWhenPasswordSuccessfullySet()
{
    $details = ;

    $user = new User($details);

    $password = 'fubar';

    $result = $user->setPassword($password);
}

Chúng ta mong muốn $result sẽ bằng true:

public function testSetPasswordReturnsTrueWhenPasswordSuccessfullySet()
{
    $details = ;

    $user = new User($details);

    $password = 'fubar';

    $result = $user->setPassword($password);

    $this->assertTrue($result);
}

Nếu bạn chạy phpunit bây giờ, bạn sẽ nhận được 1 green bar rất đẹp.

Bây giờ chúng ta có thể tập trung vào test ::getUser, phương thức chỉ có 1 hàm, do đó nó rất dễ dàng để test đúng không? Thực sự thì… không đúng lắm. Bạn thấy đấy, tất cả lý do để test ::getUser() là để cho chúng ta truy cập vào các method private và lấy ra thuộc tính $user. Chúng ta muốn xác nhận rằng $user có giá trị như mong muốn.
Điều này có nghĩa là bằng việc test ::getUser() chúng ta cũng đang test ::__construct(), ::setPassword()cryptPassword().

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

public function testGetUserReturnsUserWithExpectedValues()
{
    $details = ;

    $user = new User($details);

    $password = 'fubar';

    $user->setPassword($password);
}

Điều duy nhất chúng ta có thể thực sự test trong kịch bản này đó là password được tạo bởi ::cryptPassword() khớp với giá trị mong đợi.
Chúng ta không bắt giá trị của ::setPassword() bởi vì ta đang giả sử rằng nó đã passed test. Nếu không giả sử như thế chúng ta sẽ không biết được chắc chắn được bước tiết theo.

Chúng ta biết rằng mật khẩu raw ban đầu là fubar, nó được hash bên trong ::setPassword() sử dụng md5(). Do đó chúng ta có thể xác định giá trị mong muốn đó là:

$expectedPasswordResult = '5185e8b8fd8a71fc80545e144f91faf2';`

Sau đó, chúng ta gọi hàm ::getUser() để lấy user trong trạng thái hiện tại

$currentUser = $user->getUser();

Và chúng ta đang mong đợi ::getUser() trả về 1 mảng, và muốn so sánh giá trị của phần tử password trong mảng với giá trị mong muốn. Sử dụng assertEquals() chúng ta có method test hoàn chỉnh như sau:

public function testGetUserReturnsUserWithExpectedValues()
{
    $details = ;

    $user = new User($details);

    $password = 'fubar';

    $user->setPassword($password);

    $expectedPasswordResult = '5185e8b8fd8a71fc80545e144f91faf2';

    $currentUser = $user->getUser();

    $this->assertEquals($expectedPasswordResult, $currentUser['password']);
}

Done 👍OK (9 tests, 9 assertions)

Test trực tiếp các method private/protected

Điều gì xảy ra nếu chúng ta muốn test thêm nhiều kịch bản cho phương thức protected mà không phải gián tiếp thông qua public API?

Điều này có thể thực hiện khá dễ dàng bằng cách sử dụng ReflectionClass:

/**
 * Call protected/private method of a class.
 *
 * @param object &$object    Instantiated object that we will run method on.
 * @param string $methodName Method name to call
 * @param array  $parameters Array of parameters to pass into method.
 *
 * @return mixed Method return.
 */
public function invokeMethod(&$object, $methodName, array $parameters = array())
{
    $reflection = new ReflectionClass(get_class($object));
    $method = $reflection->getMethod($methodName);
    $method->setAccessible(true);

    return $method->invokeArgs($object, $parameters);
}

Sử dụng invokeMethod() bạn có thể dễ dàng gọi các hàm private hay protected một cách trực tiếp.
Để sử dụng nó, chỉ đơn giản gọi:

$this->invokeMethod($user, 'cryptPassword', ['passwordToCrypt']);