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

程式技術
sharkHead

繼上回簡單介紹 PHP 測試框架 - Pest (上) 後,讓我們緊接著繼續看看 Pest 的其他實用功能。

使用 Datasets 測試多組資料

如果你想要在一個流程測試中,測試多筆資料,你會怎麼做呢?

雖然我們可以把測試拆開,為每筆資料都寫一個測試,但因為測試邏輯與流程是一樣的,如果每一筆資料都寫一個測試,會讓程式碼顯得冗長且非常多餘。

遇到這樣的情況,可以考慮使用 Datasets。

我們可以在匿名函數中放入一個參數 $email,並呼叫 with() 方法代入一個有多筆信箱資料的陣列。

it('can store a contact', function ($firstName) {
    login()->post('/contacts', [
        'first_name' => $firstName, // 代入 Datasets 資料
        'last_name' => faker()->lastName,
        'email' => faker()->email,
        // ...
    ])
    ->assertRedirect('/contacts')
    ->assertSessionHas('success', 'Contact created');
})->with([
    'Allen',
    '"Lisa"',
]);

此時這個測試就會被執行兩次,而這兩次都會測試不同的信箱資料。

執行測試之後也會顯示為兩個測試。

 PASS  Tests\Feature\Contacts\StoreTest
✓ it can store a contact with (Allen)
✓ it can store a contact with ('"Lisa"')

Datasets 也可以帶入多個參數。

it('can store a contact', function ($firstName, $email) {
    login()->post('/contacts', [
        'first_name' => $firstName,
        'last_name' => faker()->lastName,
        'email' => $email,
        // ...
    ])
    ->assertRedirect('/contacts')
    ->assertSessionHas('success', 'Contact created');
})->with([
    ['John', faker()->email],
    ['Hellen', '"test"@email.com'],
]);

可以使用參數拆包的方式,如果沒有在 Datasets 設定資料,就使用預設的資料。

it('can store a contact', function (array $data) {
    login()->post('/contacts', [
        ...[
            'first_name' => faker()->firstName,
            'last_name' => faker()->lastName,
            'email' => faker()->email,
            // ...
        ],
        ...$data
    ])
    ->assertRedirect('/contacts')
    ->assertSessionHas('success', 'Contact created');
})->with([
    [[]],
    [['email' => faker()->email, 'first_name' => 'John']],
    [['email' => '"test"@email.com', 'first_name' => 'Hellen']],
]);

可以在 Datasets 中加上 key 值。

it('can store a contact', function (array $data) {
    login()->post('/contacts', [
        ...[
            'first_name' => faker()->firstName,
            'last_name' => faker()->lastName,
            'email' => faker()->email,
            // ...
        ],
        ...$data
    ])
    ->assertRedirect('/contacts')
    ->assertSessionHas('success', 'Contact created');
})->with([
    'empty' => [[]],
    'data 1' => [['email' => faker()->email, 'first_name' => 'John']],
    'data 2' => [['email' => '"test"@email.com', 'first_name' => 'Hellen']],
]);

這樣測試結果就會顯示你所設定的 key 名稱。

 PASS  Tests\Feature\Contacts\StoreTest
✓ it can store a contact with data set "empty"
✓ it can store a contact with data set "data 1"
✓ it can store a contact with data set "data 2"

設定 Shared Datasets

可以使用 Laravel 中 artisan 的指令 pest:dataset 生成一個 Shared Datasets。

php artisan pest:dataset Emails

這個指令會生成一個 tests/Datasets/Emails.php,我們可以在其中定義一個 Datasets。

dataset('valid emails', function () {
    return ['email A', 'email B']
});

之後就可以在測試中使用這個 Shared Datasets。

it('can store a contact', function ($email) {
    login()->post('/contacts', [
        'first_name' => faker()->firstName,
        'last_name' => faker()->lastName,
        'email' => $email,
        // ...
    ])
    ->assertRedirect('/contacts')
    ->assertSessionHas('success', 'Contact created');
})->with('valid emails'); // 使用 Shared Datasets 資料

Combined Datasets

你可以組合多個 Datasets,測試更多種組合資料。

例如我想測試一個上傳圖片後顯示圖片規格的功能,除了測試圖片類型,例如 jpg、png 或是 webp,每個類型也想測試不同的大小尺寸。

use Illuminate\Support\Facades\Storage;

it('can show supported image formats and options', function ($path, $options) {
    Storage::fake()->put($path, file_get_contents(__DIR__ . "/../../fixtures/{$path}"))

    $response = $this->get(route('image', ['path' => $path, ...$options]));
    $response->assertOk();

    expect($response->streamedContent())->not->toBeEmpty()->toBeString();
})->with([
    ['example.jpg', ['w' => 10, 'h' => 10, 'fit' => 'crop']],
    ['example.jpg', ['w' => 40, 'h' => 40, 'fit' => 'crop']],
    ['example.jpg', ['w' => 50, 'h' => 50, 'fit' => 'crop']],
    ['example.png', ['w' => 10, 'h' => 10, 'fit' => 'crop']],
    ['example.png', ['w' => 40, 'h' => 40, 'fit' => 'crop']],
    ['example.png', ['w' => 50, 'h' => 50, 'fit' => 'crop']],
    ['example.webp', ['w' => 10, 'h' => 10, 'fit' => 'crop']],
    ['example.webp', ['w' => 40, 'h' => 40, 'fit' => 'crop']],
    ['example.webp', ['w' => 50, 'h' => 50, 'fit' => 'crop']],
]);

上面的寫法重複度有些高,為了測試 10、40 與 50 的尺寸,我們每個圖片都要特地寫三種尺寸,這時候就可以考慮使用組合 Datasets。

use Illuminate\Support\Facades\Storage;

it('can show supported image formats and options', function ($path, $options) {
    Storage::fake()->put($path, file_get_contents(__DIR__ . "/../../fixtures/{$path}"))

    $response = $this->get(route('image', ['path' => $path, ...$options]));
    $response->assertOk();

    expect($response->streamedContent())->not->toBeEmpty()->toBeString();
})->with([
    'example.jpg',
    'example.png',
    'example.webp',
])->with([
    ['w' => 40, 'h' => 40, 'fit' => 'crop'],
    ['w' => 50, 'h' => 50, 'fit' => 'crop'],
    ['w' => 10, 'h' => 10, 'fit' => 'crop'],
]);

顯示的測試結果如下所示。

 PASS  Tests\Feature\Image\ShowTest
✓ it can show supported image formats and options with ('example.jpg') / (array(40, 40, 'crop'))
✓ it can show supported image formats and options with ('example.jpg') / (array(50, 50, 'crop'))
✓ it can show supported image formats and options with ('example.jpg') / (array(10, 10, 'crop'))
✓ it can show supported image formats and options with ('example.png') / (array(40, 40, 'crop'))
✓ it can show supported image formats and options with ('example.png') / (array(50, 50, 'crop'))
✓ it can show supported image formats and options with ('example.png') / (array(10, 10, 'crop'))
✓ it can show supported image formats and options with ('example.webp') / (array(40, 40, 'crop'))
✓ it can show supported image formats and options with ('example.webp') / (array(50, 50, 'crop'))
✓ it can show supported image formats and options with ('example.webp') / (array(10, 10, 'crop'))

使用 Group 幫測試建立群組

你可以使用 group() 將多個測試放進一個群組中。

it('can validate an email', function () {
    $rule = new IsValidEmailAddress();

    expect($rule->passes('email', '[email protected]'))->toBeTrue();
})->group('validation');

這樣執行測試時就可以使用 --group 來執行指定群組的測試。

group() 是可以跨檔案的,你可以把位於不同檔案的測試放進同一個群組中。

php artisan test --group=validation

如果檔案內全部測試都屬於同一個群組,我們在檔案的上方使用下面的語法,這樣該檔案中的全部測試都會被加入 validation 群組中。

uses()->group('validation');

如果不想要執行某群組的測試,可以在指令中使用 --exclude

php artisan test --exclude=validation

斷定是否會拋出例外 (Exception)

如果要測試一個流程在遭遇問題時是否會拋出例外 (Exception),斷定中也有 expectException() 的方法可以使用。

it('can validate an email', function () {
    // 如果預計這個測試會拋出例外,我們可以使用這個方法來進行斷定
    // 注意這個方法必須放在測試中的最上方
    $this->expectException(InvalidArgumentException::class);

    $rule = new IsValidEmailAddress();

    $rule->passes('email', 123);
});

Pest 為這種情況提供一個更直覺的方法 throws()

it('can validate an email', function () {
    $rule = new IsValidEmailAddress();

    $rule->passes('email', 123);
})
->throws(InvalidArgumentException::class);

除了斷定丟出哪一種例外,也可以對例外的錯誤訊息進行斷定,就算例外的類型對上了,只要訊息不對,斷定還是會失敗。

it('can validate an email', function () {
    $rule = new IsValidEmailAddress();

    $rule->passes('email', 123);
})
->throws(InvalidArgumentException::class, 'The value must be a string');

使用 Skip 根據情況跳過測試

Pest 中可以使用 skip() 方法跳過測試。

it('can validate an email', function () {
    $rule = new IsValidEmailAddress();

    expect($rule->passes('email', '[email protected]'))->toBeTrue();
})
// skip 接受一個字串用來顯示為什麼跳過的訊息
->skip('we don\'t need this test anymore');

這樣執行測試時,該測試就會被跳過,並在測試結果上顯示為什麼跳過的訊息。

 WARN  Tests\Feature\ExampleTest
- it can validate an email → we don't need this test anymore

skip() 可以傳入一個判斷式,可以根據情況來決定要不要執行這個測試。

it('can validate an email', function () {
    $rule = new IsValidEmailAddress();

    expect($rule->passes('email', '[email protected]'))->toBeTrue();
})
// 可以用來根據環境來決定是否跳過測試
->skip(getenv('SKIP_TESTS') ?? false, 'we don\'t need this test in this condition');

注意 skip() 的判斷是在 Laravel 啟動之前,所以沒有辦法直接使用 Laravel 提供的方法。

it('can validate an email', function () {
    $rule = new IsValidEmailAddress();

    expect($rule->passes('email', '[email protected]'))->toBeTrue();
})
// error
->skip(config('app.name') === 'foo', 'we don\'t need this test in this condition');

想使用 Laravel 提供的方法,就必須將其放入匿名函數中。

it('can validate an email', function () {
    $rule = new IsValidEmailAddress();

    expect($rule->passes('email', '[email protected]'))->toBeTrue();
})
// make config() be executed after laravel booted
->skip(fn () => config('app.name') === 'foo', 'we don\'t need this test in this condition');

查看測試覆蓋度 (Code Coverage)

Laravel 在 test 指令中可以使用 --coverage 顯示程式碼的測試覆蓋度。

測試覆蓋程度這個功能由 PHP 的擴展 Xdebug 所提供,所以使用之前必須確認有安裝 Xdebug。

pecl install xdebug

並在 Xdebug 的設定檔案中開啟 coverage mode。

xdebug.mode=coverage

如果你是使用 Laravel Sail 開發環境的話,需要在 .env 檔案中加上一個變數,重新建立容器之後,容器中的 Xdebug 就會開啟 coverage mode。

SAIL_XDEBUG_MODE=coverage

設定好之後就可以在執行測試時,使用 --coverage 來顯示測試覆蓋度。

php artisan test --coverage

你可以自己定義最低的測試覆蓋程度應該是多少百分比,低於這個百分比,指令就會拋出 exit(1) 的錯誤,可以在 CI (Continuous Integration) 中用來中斷布署流程的進行。

php artisan test --coverage --min=40

指令執行後的顯示結果如下 (前半段省略)。

...
  Notifications/PostComment  ................................................................................. 100.0 %
  Policies/CommentPolicy  .................................................................................... 100.0 %
  Policies/Policy  ........................................................................................... 100.0 %
  Policies/PostPolicy  ....................................................................................... 100.0 %
  Policies/UserPolicy  ....................................................................................... 100.0 %
  Providers/AppServiceProvider  .............................................................................. 100.0 %
  Providers/AuthServiceProvider  ............................................................................. 100.0 %
  Providers/BroadcastServiceProvider ........................................................................... 0.0 %
  Providers/EventServiceProvider  ............................................................................ 100.0 %
  Providers/RouteServiceProvider  ............................................................................ 100.0 %
  Rules/MatchOldPassword  .................................................................................... 100.0 %
  Rules/Recaptcha .............................................................................................. 0.0 %
  Services/FileService  ...................................................................................... 100.0 %
  Services/FormatTransferService 17 ........................................................................... 83.3 %
  Services/PostService 46, 57..75, 62..109 .................................................................... 40.5 %
  Services/SettingService  ................................................................................... 100.0 %
  View/Components/AppLayout  ................................................................................. 100.0 %
  helpers 3, 30..34 ........................................................................................... 50.0 %

  Total Coverage .............................................................................................. 80.6 %

測試覆蓋度可以讓你更清楚的知道還有哪邊的程式碼沒有測試到,是個相當方便且重要的功能。

結語

Pest 的一些功能介紹就到這邊 (其實上述官方文件都有寫到,真要說還不如看文件),相信各位閱讀後會發現 Pest 測試方式與 PHPUnit 大同小異,只是 Pest 提供更多實用的方法協助我們進行測試,因此在轉換上幾乎沒有任何負擔!

使用程式寫出我們想要的功能固然有趣,但能確保功能沒有問題的測試也同樣令人著迷。希望大家都能更愉快地寫出更棒的測試。

參考資料

sharkHead
written by
sharkHead

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

0 則留言