超絶マイナーキラキラクッキリテストフレームワークPest
Pestとは?
PestとはLarastanやCollisionなどでお馴染みのnunomaduroさんが携わっているPHPUnitの代替となる新しいテスティングフレームワークです。
PHPUnitのAssertionsが使え、更に独自で追加されたExpectationsによってよりシンプルに書くことが出来るようになっています。
Jestの派生
Jestを使った経験がある人であればPestはほとんどマスターしたと言えます。
beforeEach()
やtest()
, toBe()
, expect()
そしてnot
全て使えます。
Jestの.
が->
になった程度の違いくらいしか感じないでしょう。
BDDフレームワーク
JestやPestはPHPUnitなどのxUnit系とは異なり、BDDを意識して作られたフレームワークです。
BDDの詳細な説明は省略しますが、「振る舞い」を検証することに主題を置いています。
モックを多用し、テストが単一クラスのみしか依存しなくなる状況にする手法は「振る舞い」というよりは「動作」検証になるでしょう。
「振る舞い」なので外部から観測できない部分に対してテストコードは書きません。(プライベートメソッドなど)
つまりpublicなメソッド(API)に対してテストを書いていきます。
この部分の話はちょこっと別記事に書いていまして、これからも少しずつ知見を深めてアウトプットしていく予定です。(なので省略します)
使い方
インストール
composerにインストールやり方で特に変わりはありません。
composer require pestphp/pest-plugin-laravel --dev
インストール後にartisan
を使用して別途インストールする必要があります。
php artisan pest:install
これはtests/Pest.php
を作成する作業であり、このPest.php
はTestCase::class
やCreatesApplication::class
, RefreshDatabase::class
を書いてディレクトリごとに適応したり、全体的に使うヘルパーメソッドを定義するグローバルな空間になります。
testとit
Pestにはクラスを使う必要がなく、全て関数で完結します。
class PostTest extends TestCase
{
public function test_something(): void
{
...
}
}
PHPUnitで上のようなクラスが必要であったテストも関数で簡単に書くことができます。
test('test_something', function() {
...
});
test('test_something')->toBe(...);
また、このtest()
関数以外にit()
関数を使うことができます。
動作上はどちらでも変わりません。it()
だと実行時テスト名に勝手にit
が補完されるだけです。英語でテスト名を書いてく分には良いですが、日本語で書くルールがあるプロジェクトではtest()
で書いていく方が無難だと思います。
usesとbeforeEach
少し前述しましたが、Pest.php
でテストを実行する前に行う動作を指定できます。
Laravelのテストではお馴染みのTestCase
, CreatesApplication
, RefreshDatabase
をUnitディレクトリとFeatureディレクトリに適用させる場合はuses
とin
を使います。
uses(TestCase::class, CreatesApplication::class, RefreshDatabase::class)
->in('Unit', 'Feature')
外部プロセス依存になるモック等はbeforeEach()
で指定すると適用することができます。
uses(TestCase::class, CreatesApplication::class, RefreshDatabase::class)
->beforeEach(function() {
Bus::fake(SampleJob::class)
})
->in('Unit', 'Feature')
AssertionsとExpectations
Assertions
PHPUnitでお馴染みのAssertionsが使用できます。
ただPestでAssertionsは補助的な意味合いが強く、Expectationsを使うことによって真価が発揮されるものだと思っているので、なるべくExpectationsを使う方が良いと思います。
Laravel特有のassertDatabaseHas
やassertStatus
は頼るしかないので、変にやりくりしようとせずにAssertで済ませましょう。
Expectations
Jestではお馴染みのExpectationsです。公式ドキュメントからの引用ですが
test('expect true to be true', function () {
// assertion
$this->assertTrue(true);
// expectation
expect(true)->toBe(true);
});
とexpectの場合はメソッドチェーンで書くことができます。
つまり、1つの要素を様々な観点からAssertionする時に何行にも渡って似たような文を書かないといけませんでしたが、それがなくなることになります。
$a = 1;
$this->assertEqulas($a, 1);
$this->assertInt($a);
// ...
expect($a)
->toBe(1)
->toBeInt();
// Done!
一つの要素から枝分かれしていることが瞬時に分かるため、テストの可読性が大きく向上することになります。
テストはメンテナンスされていくべきものであり(工数があれば本番コード同等と扱うべきもの)、テストは使い捨てではないため、可読性は重要です。Pestはその手伝いをしてくれることになるでしょう。
細かいExpectationsは公式ドキュメントをお読みください。ここで説明しきれないほど様々な種類のExpectationsが用意されています。
High Order Tests
基本的にメソッドチェーンができます。
例えば、あるPostについていくつか検証したい時は以下のように続けて検査することができます。
$post = Post::factory()->create();
$refreshPost = $post->refresh();
expect($refreshPost)
->title->toBe($requestData['title'])
->author->toBe($requestData['author'])
->body->toBe($requestData['body'])
->date->toBe($requestData['date']);
メソッドチェーンはテストメソッドの it()
や test()
にも有効です。
公式Docからの引用です。
test('the user has the correct values')
->expect(fn() => Auth::user())
->first_name->toEqual('Nuno')
->last_name->toEqual('Maduro')
->withTitle('Mr')->toEqual('Mr Nuno Maduro');
Exceptions
PHPUnitでは $this->expectException()
を使って例外を検査しますが、Pestではメソッドチェーンで書きます。(以下公式Doc引用)
it('throws exception', function () {
throw new Exception('Something happened.');
})->throws(Exception::class);
Datasets
PHPUnitのところのData Providerに近いものとなります。
基本的にはテストメソッドit()
やtest()
にチェーンで繋げて、引数に渡す要素を設定します。(公式Docから引用)
it('has emails', function ($name, $email) {
expect($email)->not->toBeEmpty();
})->with([
['Nuno', 'enunomaduro@gmail.com'],
['Other', 'other@example.com']
]);
また、別ファイルにdataset()
で定義して、それを利用することができます。(テストメソッドとテストデータの距離が遠くなって個人的には可読性が落ちるので好きではないです)
公式Docから引用
<?php
dataset('emails', [
'enunomaduro@gmail.com',
'other@example.com'
]);
Custom Factory
RequestデータやJsonカラムなどがある場合、事前データが膨大になってテストメソッドの見通しが悪くなり、可読性が落ちたり、単純にいちいち要素を指定して事前データを作成するのが面倒だと感じるときがあります。
その際にカスタムファクトリーを作っています。
<?php
namespace Tests\Factories;
use App\Models\BlogPost;
use Carbon\Carbon;
class BlogPostRequestDataFactory
{
private string $title = 'Title';
private string $author = 'Author';
private string $body = 'Body';
private string $date = '2021-01-01';
public static function new(): self
{
return new self();
}
public function create(array $extra = []): array
{
return $extra + [
'title' => $this->title,
'author' => $this->author,
'body' => $this->body,
'date' => $this->date,
];
}
public function withTitle(string $title): self
{
$clone = clone $this;
$clone->title = $title;
return $clone;
}
public function withDate(string|Carbon $date): self
{
$clone = clone $this;
$clone->date = $date instanceof Carbon
? $date->format('Y-m-d')
: $date;
return $clone;
}
public function withPost(BlogPost $post): self
{
$clone = clone $this;
$this->title = $post->title;
$this->author = $post->author;
$this->body = $post->body;
$this->date = $post->date->format('Y-m-d');
return $clone;
}
}
基本的にbeforeEach()
、setUp()
で new()
しておき、インスタンス変数として格納しておき、それにwithXXXメソッドで属性を保管しつつ、create()するというのが主な使い方になります。
Time Freeze
別途プラグインがあります。Carbon::setTestNow()
のイメージです。
composer require spatie/pest-plugin-test-time --dev
テストを行う際に年月日時刻はできれば特定の年月日時刻にしておきたいです。
Carbonのように扱え、更に設定した時刻を簡単に操作出来るので便利です。
公式Docから引用
testTime()->freeze('2021-01-02 12:34:56');
testTime()->addHour();
testTime()->subMinute()->addSeconds(2);
Validation Test
以前Requestクラスにルールの検証としてdataProviderを使っていましたが、assertSessionHasErrors()
を使えばスマートに検証ができます。
Errorが出るものを指定します。
例えば、title => required, string
だったら、データがなかった時にerrorが出るので、assertSessionHasErrors(['title'])
とすればPassする
$post = Post::factory()->create();
post(action([BlogPostAdminController::class, 'update'], $post->slug), [])
->assertSessionHasErrors(['title', 'author', 'body', 'date']);
dumpSession()
を使ってセッション情報をdumpし、assertSessionHasErrors
の引数arrayに連想配列でkey とメッセージを指定すると分かりやすい。
post(action([BlogPostAdminController::class, 'update'], $post->slug), [
'title' => $post->title
'author' => $post->author,
'body' => $post->body,
'date' => '01/01/2021',
])->dumpSession()->assertSessionHasErrors([
'date' => 'The date does not match the format Y-m-d.'
])
Json Test
APIのテストを書いている時にレスポンスをある程度検証したい場合があります。ただ assertJson
だとイマイチ使いづらい・・・。ってなった時にClosureで指定して上げると楽になります。
it('xxx', function() {
$post = BlogPost::factory()->count(5)->create();
$returnedPosts = getJson(action([BlogPostController::class, 'index']))
->assertSuccessful()
->assertJsonCount(5, 'data')
->assertJson(function(AssertableJson $json) {
$json->has('data', 5)
->has('data.0', function(AssertableJson $json) {
$json
->where('id', $posts[0]->id)
->where('slug', $posts[0]->slug)
->has('date', $posts[0]->date->toDateTimeLocalString)
->whereAllType([
'id' => 'integer',
'slug' => 'string',
'date' => 'string',
])
->etc();
})
})
});
Custom Expectations
ほかのテストでも長いExpectationsを書くのは面倒です。
その場合用の自分で定義できるExpectationsがあります。
extend
で指定するだけで自分だけのExpectationsが作成できます。自分だけのExpectationsを見つけて、最強のExpectationsを育てろ!
expect(5)->toBeInTheRange(1, 10);
expect()->extend('toBeInTheRange', function(int $min, int $max) {
return $this
->toBeGreaterThanOrEqual($min)
->toBeLessThanOrEqual($max);
});
IDEプラグイン
プラグインダウンロード数だけ見るとめちゃくちゃマイナーなことがわかりますね・・・。
参考
Pest
公式になります。
Testing-Laravel
テストTipsで発見があります。
次に読むと良いもの
Unit Testing Principles, Practices, and Patterns
テストとは何たるかを学べるのでオススメです。(ガチのオススメ)
宣伝
今後の予定
今後はLaravelにおけるテストガイドラインをある程度まとめて出そうと考えています。(PHPUnitですが・・・)
Twitterでテスト方法模索ツイートしてるときもあるので、興味があればフォローしてください(フォローは怪しくない限り返します)