簡單介紹 PHP 測試框架 Pest (上)

程式技術
sharkHead

Pest 是由 Laravel 團隊中 Nuno Maduro 所開發的 PHP 測試框架,是建構於 PHPUnit 上再包裝版本,除了完全兼容 PHPUnit,Pest 還提供更多優雅且實用的測試方法。

前陣子發現 Laracasts 上原來有 Pest From Scatch 的課程,就決定找個時間補完並看看自己是不是還有什麼 Pest 功能是我沒注意到的。

上週看完課程之後整理了一些筆記,如果對 Pest 這個 PHP 測試框架有興趣並想使用看看的朋友,希望此系列文章會是不錯的入門點。

此篇文章會以在 Laravel 專案中使用 Pest 的情境為主

我自己用 Pest 寫了一段時間的測試,最愛 Pest 的地方就是寫測試名稱時不用打一堆底線 😂,至於為什麼可以不用底線,稍後本篇文章就會提到。

// 寫完整的測試名稱有時候需要用到一大堆底線
public function test_the_application_returns_a_successful_response()
{
    // ...
}

安裝與設定 Pest

透過 composer 安裝 Pest。

composer require pestphp/pest --dev --with-all-dependencies

如果你是使用 Laravel 專案,需要再安裝 pest-plugin-laravel 這個套件,並執行 artisan 指令 pest:install 生成一個 Pest 的設定檔案 tests/Pest.php

composer require pestphp/pest-plugin-laravel --dev

php artisan pest:install

Pest 是建構在 PHPUnit 上的,因此原本 PHPUnit 的測試寫法,Pest 完全兼容,並不需要修改舊有的測試

以下為原本 PHPUnit 的測試寫法。

namespace Tests\Feature;

use Tests\TestCase;
use Models\User;

class ExampleTest extends TestCase
{
    /**
     * A basic test example.
     *
     * @return void
     */
    public function test_the_application_returns_a_successful_response()
    {
        $response = $this->get('/');

        $response->assertStatus(200);
    }
}

如果使用 Pest 的寫法修改上面的測試。

use function Pest\Laravel\get;

test('the application returns a successful response', function () {
    get('/')->assertStatus(200);
});

可以發現測試名稱不用再打很多 _,是不是很棒呢?

在 Laravel 專案中你一樣可以使用 artisan 指令啟動測試。

php artisan test

Pest 提供的 Lifecycle Hook 方法

在 PHPUnit 中,我們可以使用 setUp() 方法,在測試開始前進行一些前置作業。

而 Pest 提供更靈活的 lifecycle hook 方法,讓我們可以在測試的各個階段進行一些額外的操作。

簡單舉個例子,在測試中可以使用 actingAs() 方法模擬登入用戶的操作。

$this->actingAs($this->user);

等等,這個 $this->user 是怎麼來的 ?

在原本的測試寫法中,可以在建立一個 protect 方法 setUp(),並在其中設定 $ths->user

protected function setUp(): void
{
    parent::setUp();

    $ths->user = User::factory()->create();
}

在 Pest 中你可以使用下面幾個 lifecycle hook 方法,讓你在測試的各個階段,進行一些額外操作。

beforeAll(fn () => var_dump('Before All'));
beforeEach(fn () => var_dump('Before Each'));
afterEach(fn () => var_dump('After Each'));
afterAll(fn () => var_dump('After All'));

嘗試在一個 Pest 檔案的測試上方貼上這段程式碼,並執行該檔案的測試。

beforeAll(fn () => var_dump('Before All'));
beforeEach(fn () => var_dump('Before Each'));
afterEach(fn () => var_dump('After Each'));
afterAll(fn () => var_dump('After All'));

test('can view contents', function () {
    // ...
});

test('can view user information', function () {
    // ...
});

你會發現印出來的訊息如下所示。

string(10) "Before All"
string(11) "Before Each"
string(10) "After Each"
string(11) "Before Each"
string(10) "After Each"
string(9) "After All"

很明顯 beforeAll() 會在該檔案全部測試執行前先執行,beforeEach()afterEach() 則是在每個測試的前後執行,而 afterAll() 會在該檔案的測試全部結束之後執行。

需要注意的是,beforeAll() 沒有辦法使用 Laravel 所提供的方法,因為 Laravel 此時還沒有完成啟動 (boot)。

// error: Target class [config] does not exist
beforeAll(fn () => var_dump(config('app.name')));

// correct
beforeEach(fn () => var_dump(config('app.name')));

回覆剛剛的提問,$this->user 是怎麼來的?

我們可以使用 Pest 提供的 lifecycle hook 方法在 beforeEach() 中設定這個屬性。

beforeEach(function () {
    $ths->user = User::factory()->create();
});

使用 RefreshDatabase Trait 重置資料庫

通常在測試時,我們很常使用 RefreshDatabase 這個 Trait 來重置整個資料庫,方便進行測試。

在 Pest 中,因為已經沒有 class 宣告,我們無法使用 trait 的方式引入 RefreshDatabase,所以我們必須換個方式,使用 uses()

use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

test('can view contents', function () {
    // ...
});

如果你的每個測試都需要 uses(RefreshDatabase::class),你可以考慮在 tests/TestCase.php 中引入 RefreshDatabase

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;

abstract class TestCase extends BaseTestCase
{
    use CreatesApplication;

    use RefreshDatabase;
}

為了有更好的測試速度,你可以使用 LazilyRefreshDatabase,當測試動到資料庫時,才會重置資料庫。

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;

abstract class TestCase extends BaseTestCase
{
    use CreatesApplication;

    use LazilyRefreshDatabase;
}

定義自己的方法

剛剛提到我們可以使用 actingAs() 在測試中模擬某個用戶登入。

這個方法很常被拿來使用,但每次使用前都要先建立一個 user 並傳入 actingAs(),久而久之,你的測試可能很常重複下面這段程式碼。

$user = User::factory()->create();

$this->actingAs($user);

我們可以在 tests/Pest.php 中自己定義一個 login() 方法,並將上面的流程放進去。

use App\Models\User;

function login($user = null) {
    return test()->actingAs($user ?? User::factory()->create());
}

之後我們就可以在測試中重複使用剛剛定義的登入方法,貫徹 DRY 原則。

test('can view contents', function () {
    login()->get('/contents')->assertStatus(200);
});

使用 Faker 產生假資料

Faker 可以用來生產假資料,除了常被用做 database 的 seeding 之外,測試中也同樣很常被使用。

Pest 有提供一個 faker plugin,讓我們可以在 Pest 中使用 Faker,首先使用下面的方式安裝。

composer require pestphp/pest-plugin-faker --dev

Faker 在 Pest 中的使用範例如下,假設測試能否通過 Post 請求新增一個聯絡人。

use function Pest\Faker\faker;

it('can store a contact', function () {
    login()->post('/contacts', [
        'first_name' => faker()->firstName,
        'last_name' => faker()->lastName,
        'email' => faker()->email,
        'phone' => faker()->e163PhoneNumber,
        'address' => 'No. 22, Fake Rd',
        'city' => 'Fake City',
        'region' => 'Fake Region',
        'country' => faker()->randomElement(['us', 'tw']),
        'postal_code' => faker()->postcode,
    ])
    ->assertRedirect('/contacts')
    ->assertSessionHas('success', 'Contact created');
});

優雅的 Expect API

斷定 (Assert) 是測試中最關鍵的部分,用來確定我們想測試的流程是否符合我們的預期。

一般我們斷定一個值時,會使用 PHPUnit 提供的各種 assert 方法。

$this->assertEqual($name, 'Allen');

Pest 提供一種更為優雅且更為清楚的斷定 API:expect()

expect(true)->toBeTrue();

使用剛剛的新增聯絡人範例,看看 expect() 提供哪些方法。

use function Pest\Faker\faker;

it('can store a contact', function () {
    login()->post('/contacts', [
        'first_name' => faker()->firstName,
        'last_name' => faker()->lastName,
        'email' => faker()->email,
        'phone' => faker()->e164PhoneNumber,
        'address' => 'No. 23, Fake Rd',
        'city' => 'Fake City',
        'region' => 'Fake Region',
        'country' => faker()->randomElement(['us', 'tw']),
        'postal_code' => faker()->postcode,
    ])
    ->assertRedirect('/contacts')
    ->assertSessionHas('success', 'Contact created');

    $contact = Contact::latest()->first();

    expect($contact->first_name)->toBeString(); // pass
    expect($contact->first_name)->toBeEmpty(); // fail
    expect($contact->first_name)->toBeString()->not->toBeEmpty(); // pass
    expect($contact->email)->toBeString()->toContain('@'); // pass
    expect($contact->phone)->toBeString()->toStartWith('+'); // pass
    expect($contact->address)->toBe('No. 23, Fake Rd'); // pass
    expect($contact->country)->toBeIn(['us', 'tw']); // pass
});

定義自己的 Expect 的斷定方法

在之前的 expect() 範例中,我們使用下方的方式來判斷一個信箱地址的格式是否正確。

expect($contact->email)->toBeString()->toContain('@');

但這個方式直觀來看,很難第一眼了解這行程式碼的目的是什麼。

Pest 提供為 expect() 客製方法的功能,我們可以在 tests/Pest.php 中為 expect() 加上新的方法。

expect()->extend('toBeEmailAddress', function () {
    expect($this->value)->toBeString()->toContain('@');
});

之後我們就可以在測試中使用 toBeEmailAddress()

expect($contact->email)->toBeEmailAddress();

不過上面的判斷方法其實還有很多漏洞,以下面的字串為例。

fakeEmail@

這個字串很明顯就不是正常的信箱地址,但是確實可以通過我們上面所設定的斷定規則。

為了更精確的判斷,我們可以在 expect() 的客製方法中使用 PHP 的原生方法來進行判斷,如果錯誤就拋出例外 (Exception)。

請注意每個測試中,至少要有一個斷定。

expect()->extend('toBeEmailAddress', function () {
    // 至少放一個斷定在方法中,避免單獨使用此方法導致測試中沒有斷定的錯誤
    expect($this->value)->toBeString();

    if (! (bool) filter_var($this->value, FILTER_VALIDATE_EMAIL)) {
        throw new ExpectationFailedException('Email address is not valid');
    }
});

Expect 的簡潔寫法

在剛剛新增聯絡人的例子中,我們用了很多 expect() 去確認 ORM Model 中的值。

expect($contact->first_name)->toBeString(); // pass
expect($contact->first_name)->toBeEmpty(); // fail
expect($contact->first_name)->toBeString()->not->toBeEmpty(); // pass
expect($contact->email)->toEmailAddress(); // pass
expect($contact->phone)->toBeString()->toStartWith('+'); // pass
expect($contact->address)->toBe('No. 23, Fake Rd'); // pass
expect($contact->country)->toBeIn(['us', 'tw']); // pass

這裡其實可以使用 expect() 提供的 and() 方法,將多次斷定串連在一起。

expect($contact->first_name)->toBeString() // pass
    ->and($contact->first_name)->toBeEmpty() // fail
    ->and($contact->first_name)->toBeString()->not->toBeEmpty() // pass
    ->and($contact->email)->toEmailAddress() // pass
    ->and($contact->phone)->toBeString()->toStartWith('+') // pass
    ->and($contact->address)->toBe('No. 23, Fake Rd') // pass
    ->and($contact->country)->toBeIn(['us', 'tw']); // pass

除了 and() 方法,Pest 還提供另外一種更簡潔的寫法。

expect($contact)
    ->first_name->toBeString() // pass
    ->last_name->toBeEmpty() // fail
    ->first_name->toBeString()->not->toBeEmpty() // pass
    ->email->toEmailAddress() // pass
    ->phone->toBeString()->toStartWith('+') // pass
    ->address->toBe('No. 23, Fake Rd') // pass
    ->country->toBeIn(['us', 'tw']); // pass

Pest 會檢查傳入的物件是否具有該屬性,如果有的話就會生成與其屬性同名稱的 Expectation 物件,讓你可以對其使用各種斷定方法,是不是很方便呢?

需要注意的事情,Pest 無法為特定名稱生成 Expectation 物件。

如果你的屬性名稱為 value,那麼這種簡潔的寫法就會失效。

本次 Pest 使用介紹就到這邊先告一個段落,敬請期待下一回!

參考資料

sharkHead
written by
sharkHead

後端打工仔,在下班後喜歡研究各種不同的技術。稍微擅長 PHP,並偶爾涉獵前端開發。個性就像動態語言般隨興,但渴望做事能像囉嗦的靜態語言那樣嚴謹。

1 則留言
訪客 2023 年 06 月 23 日

看了你的文章搞懂了 test基本語句。我被困在phpunit世界 因為我看的laracast導師視頻使用phpunit 而我laravel使用pest 謝謝!!