PHP Unit Test 201: Làm quen với Test case, Assertions và data provider

Trong bài đầu tiên của series này, chúng ta đã đi qua cách cài đặt và cấu hình PHPUnit cho 1 project PHP, một số conventions khi thực hiện Unit test trong PHP và trải nghiệm với unit test đầu tiên. Trong bài này, chúng ta sẽ tìm hiểu một số khái niệm quan trọng trong unit test và đi vào thực hành nhiều hơn.

ASSERTIONS

Assertion là gì? Theo định nghĩa từ Wikipedia:

In computer programming, an assertion is a statement that a predicate (Boolean-valued function, a true–false expression) is expected to always be true at that point in the code

Assertion chỉ đơn giản là 1 câu lệnh nhằm mục đích xác nhận một khẳng định là luôn đúng tại đoạn code đó. Hiểu theo cách khác, Assertion định nghĩa điều bạn muốn nó xảy ra (VD: Tôi muốn hàm này trả về false => tôi assert return value là false, tôi muốn hàm kia trả về mảng có chứa 5 phần tử => tôi assert array size = 5, tôi muốn kết quả thu được lơn hơn 100,...)

Assertion trả về true thì sẽ pass unit test, ngược lại sẽ fail.

Trong ví dụ đầu tiên:

public function testTrueIsTrue()
{
    $foo = true;
    $this->assertTrue($foo);
}

Chúng ta đã assert rằng true là true ( if (true == true) ). Không có gì đặc biệt ở đây, điều bạn thấy là điều bạn nhận được với assertions.

Nếu chúng ta assert rằng false là true, chúng ta sẽ nhận được 1 test fail:

public function testTrueIsTrue()
{
    $foo = false;
    $this->assertTrue($foo);
}

Nhưng nếu chúng ta muốn assert rằng false là false ( if (false == false) ) thì sao?

public function testFalseIsFalse()
{
    $foo = false;
    $this->assertFalse($foo);
}

Unit test này được pass vì câu lệnh assertion của chúng ta trả về true, mặc dù cho phương thức được gọi là assertFalse().

PHPUnit cung cấp rất nhiều assertions được liệt kê tại đây. Bạn không phải sử dụng tất cả. Phần lớn bạn sẽ sử dụng các assertions assertArrayHasKey(), assertEquals(), assertFalse(), assertSame()assertTrue(), chúng ta sẽ tập trung vào các assertion này trước. Các hàm PHPUnit Assertions cũng giống như 1 hàm bình thường và giá trị trả về là true hoặc false, bạn hoàn toàn có thể tự viết các assertion, tôi sẽ đề cập phần này sau.

Unit test (có ích) đầu tiên

Unit test có ích đầu tiên của chúng ta sẽ là unit test cho một hàm chuyển đổi từ string thông thường sang dạng url slug, ví dụ hàm này sẽ biến đổi chuỗi: "This string will be sluggified" sang "this-string-will-be-sluggified".

Tạo file src/URL.php:

<?php
namespace App;

class URL
{
    public function sluggify($string, $separator = '-', $maxLength = 96)
    {
        $title = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $string);
        $title = preg_replace('%[^-/+|\w ]%', '', $title);
        $title = strtolower(trim(substr($title, 0, $maxLength), '-'));
        $title = preg_replace('/[\/_|+ -]+/', $separator, $title);

        return $title;
    }
}

Tôi thấy đoạn code này là ví dụ rất tốt để thực hành unit test đầu tiên vì nó dễ hiểu và có rất nhiều trường hợp có thể gây ra lỗi.

Chúng ta bắt đầu viết test với file tests/URLTest.php:

<?php
namespace Tests;

use PHPUnit\Framework\TestCase;

class URLTest extends TestCase
{
    //
}

Class URLTest chưa có method test nào cả nên khi chạy phpunit sẽ báo warning:

Chúng ta chạy phpunit ngay sau khi tạo file test và bộ khung của nó để chắc chắn rằng chúng ta không bị lẫn lộn trong tên file hay tên test class. Điều này sẽ giúp tránh các trường hợp không mong đợi trong tương lai khi bộ test tất cả đều pass nhưng bạn nhận ra PHPUnit đã không thực sự chạy file test của bạn có thể do sai sót cách đặt tên.

Bước tiếp theo, chúng ta sẽ test trường hợp đầu tiên: chúng ta muốn xác nhận rằng App\URL::sluggify() sẽ trả về 1 string đã được biến đổi sang dạn slug, method test sẽ được viết như sau:

<?php
namespace Tests;

use PHPUnit\Framework\TestCase;

class URLTest extends TestCase
{
    public function testSluggifyReturnsSluggifiedString()
    {

    }
}

Với phiên bản PHPUnit 6.2 đang dùng, PHPUnit sẽ báo warning nếu method của bạn không chưa bất cứ assertion nào:

Tiếp theo, chúng ta sẽ bắt đầu viết phần thân cho test method. Mong muốn của chúng ta trong test case này là:

"This string will be sluggified" sẽ được chuyển thành "this-string-will-be-sluggified".

<?php
namespace Tests;

use PHPUnit\Framework\TestCase;

class URLTest extends TestCase
{
    public function testSluggifyReturnsSluggifiedString()
    {
        $originalString = 'This string will be sluggified';
        $expectedResult = 'this-string-will-be-sluggified';
    }
}

Để test method App\URL::sluggify(), chúng ta cần khởi tạo 1 đối tượng của class URL:

<?php
namespace Tests;

use PHPUnit\Framework\TestCase;
use App\URL;

class URLTest extends TestCase
{
    public function testSluggifyReturnsSluggifiedString()
    {
        $originalString = 'This string will be sluggified';
        $expectedResult = 'this-string-will-be-sluggified';
        
        $url = new URL();
    }
}

Bây giờ, chúng ta sẽ lấy ra kết quả từ phương thức ::sluggify():

<?php
namespace Tests;

use PHPUnit\Framework\TestCase;
use App\URL;

class URLTest extends TestCase
{
    public function testSluggifyReturnsSluggifiedString()
    {
        $originalString = 'This string will be sluggified';
        $expectedResult = 'this-string-will-be-sluggified';
        
        $url = new URL();
        $result = $url->sluggify($originalString);
    }
}

Bước cuối cùng đó là assert rằng $result đúng như mong muốn của chúng ta đã được khai báo trong biến $expectedResult. Assertion phù hợp nhất ở đây là assertEquals():

<?php
namespace Tests;

use PHPUnit\Framework\TestCase;
use App\URL;

class URLTest extends TestCase
{
    public function testSluggifyReturnsSluggifiedString()
    {
        $originalString = 'This string will be sluggified';
        $expectedResult = 'this-string-will-be-sluggified';
        
        $url = new URL();
        $result = $url->sluggify($originalString);
        
        $this->assertEquals($expectedResult, $result);
    }
}

Chạy lại PHPUnit và xem kết quả

Các kịch bản test khác

Unit test khởi đầu của chúng ta đã pass, thật tuyệt. Tuy nhiên, có 1 vấn đề là chúng ta mới chỉ test 1 string chỉ chứa các ký tự A-Z và dấu cách. Điều gì sẽ xảy ra nếu string có chứa số hay các ký tự đặc biệt ( ([email protected]#$%^&*()_+))? Và với các ký tự không phải English thì sao? Trường hợp string rỗng thì như thế nào? Có quá nhiều trường hợp cần phải test. Một bộ test suite chuẩn khi nó bao phủ được tất cả các khả năng có thể xảy ra, vì vậy chúng ta sẽ viết test cho một số kịch bản khác:

<?php
namespace Tests;

use PHPUnit\Framework\TestCase;
use App\URL;

class URLTest extends TestCase
{
    public function testSluggifyReturnsSluggifiedString()
    {
        $originalString = 'This string will be sluggified';
        $expectedResult = 'this-string-will-be-sluggified';

        $url = new URL();
        $result = $url->sluggify($originalString);

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

    public function testSluggifyReturnsExpectedForStringsContainingNumbers()
    {
        $originalString = 'This1 string2 will3 be 44 sluggified10';
        $expectedResult = 'this1-string2-will3-be-44-sluggified10';

        $url = new URL();

        $result = $url->sluggify($originalString);

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

    public function testSluggifyReturnsExpectedForStringsContainingSpecialCharacters()
    {
        $originalString = 'This! @string#$ %$will ()be "sluggified';
        $expectedResult = 'this-string-will-be-sluggified';

        $url = new URL();

        $result = $url->sluggify($originalString);

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

    public function testSluggifyReturnsExpectedForStringsContainingNonEnglishCharacters()
    {
        $originalString = "Tänk efter nu – förr'n vi föser dig bort";
        $expectedResult = 'tank-efter-nu-forrn-vi-foser-dig-bort';

        $url = new URL();

        $result = $url->sluggify($originalString);

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

    public function testSluggifyReturnsExpectedForEmptyStrings()
    {
        $originalString = '';
        $expectedResult = '';

        $url = new URL();

        $result = $url->sluggify($originalString);

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

Ghi chú: do hàm App\URL::sluggify() sử dụng hàm iconv của PHP nên nó phụ thuộc vào locale CTYPE của hệ thống (issue), nếu unit test bị fail bạn nên kiểm tra locale của hệ thống có hỗ trợ UTF-8 hay không. VD đối với Linux (Debian/Ubuntu): Kiểm tra locale CTYPE hiện tại:

→ $ echo $LC_CTYPE
C

Chạy PHPUnit với CTYPE là C => Fail

Danh sách các locale hệ thống hỗ trợ

→ $ locale -a 
C
C.UTF-8
POSIX

Thay đổi locale => Pass

Vấn đề trùng lặp code

Bạn có thể dễ dàng nhận thấy code test của chúng ta đang có vấn để lớn là trùng lặp code.

May mắn là PHPUnit đã có hỗ trợ công cụ để chúng ta khắc phục điều này.

Annotations trong PHPUnit

Annotations là các flag đặc biệt được khai báo trong docblocks của method:

<?php
/**
 * @annotationName Annotation value
 */
public function testFoo()
{
    //
}

PHPUnit cung cấp rất nhiều annotations hữu ích, nhưng trước hết chúng ta đề hãy cập đến @dataProvider.

@dataProvider

PHPUnit định nghĩa data providers như sau:

A test method can accept arbitrary arguments. These arguments are to be provided by a data provider method.

Tạm dịch là: 1 test method có thể chấp nhận nhiều input khác nhau. Các tham số này được cung cấp bởi method data provider.

Hiểu đơn giản, 1 method data provider có thể được sử dụng để tạo ra nhiều tập input để truyền vào 1 test method, khắc phục vấn đề phải tạo nhiều method test như chúng ta đã thực hiện ở trên.

Thay vì tạo ra nhiều phương thức test, bạn chỉ cần tạo ra một phương thức duy nhất với các tham số tương ứng với dữ liệu biến đổi giữa các phép thử, và tạo một phương thức cung cấp dữ liệu để test. VD:

/**
 * @dataProvider providerTestFoo
 */
public function testFoo($variableOne, $variableTwo)
{
    //
}

public function providerTestFoo()
{
    return [
        ['test 1, variable one', 'test 1, variable two'],
        ['test 2, variable one', 'test 2, variable two'],
        ['test 3, variable one', 'test 3, variable two'],
        ['test 4, variable one', 'test 4, variable two'],
        ['test 5, variable one', 'test 5, variable two'],
    );
}

Một method data provider trả về 1 mảng các tập input. Trong ví dụ trên, chúng ta có 5 tập input đầu vào để test. Mỗi tập là 1 mảng các các giá trị được truyền vào test method theo thứ tự trong mảng. Ví dụ với tập input đầu:

['test 1, variable one', 'test 1, variable two']

Nó sẽ được truyền vào method testFoo($variableOne, $variableTwo) với $variableOnetest 1, variable one$variableTwotest 1, variable two.

Bây giờ, chúng ta sẽ áp dụng vào unit test lần trước. Sửa lại file tests/URLTest.php:

<?php
namespace Tests;

use PHPUnit\Framework\TestCase;
use App\URL;

class URLTest extends TestCase
{
    /**
     * @param string $originalString String to be sluggified
     * @param string $expectedResult What we expect our slug result to be
     *
     * @dataProvider providerTestSluggifyReturnsSluggifiedString
     */
    public function testSluggifyReturnsSluggifiedString($originalString, $expectedResult)
    {
        $url = new URL();

        $result = $url->sluggify($originalString);

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

    public function providerTestSluggifyReturnsSluggifiedString()
    {
        return [
            ['This string will be sluggified', 'this-string-will-be-sluggified'],
            ['THIS STRING WILL BE SLUGGIFIED', 'this-string-will-be-sluggified'],
            ['This1 string2 will3 be 44 sluggified10', 'this1-string2-will3-be-44-sluggified10'],
            ['This! @string#$ %$will ()be "sluggified', 'this-string-will-be-sluggified'],
            ["Tänk efter nu – förr'n vi föser dig bort", 'tank-efter-nu-forrn-vi-foser-dig-bort'],
            ['', ''],
        ];
    }
}

Chạy lại PHPUnit:

Huzzah!


Kết luận

Trong bài này, chúng ta đã tìm hiểu về assertions, thực hành các unit test thực sự và tìm hiểu về @dataProvider annotation.

Vẫn còn khá nhiều thứ cần phải học, nhưng trước hết bạn nên thực hành test các code đơn giản (không có phụ thuộc bên ngoài method).

Trong bài tiếp theo, chúng ta sẽ học cách test các method có các dependency bên ngoài, tìm hiểu các khái niệm mocks, stubs và sự khác nhau của chúng, tìm hiểu tại sao nên hạn chế sử dụng static method và sự hữu ích của dependency injection.

Hẹn gặp lại các bạn trong bài tiếp theo.