Pest란
Pest 는 PHPUnit 위에 구축된 테스트 프레임워크로, Laravel 11 이후의 신규 프로젝트에 기본으로 채용되어 있습니다. PHPUnit의 풍부한 어서션군을 그대로 이용하면서, 클로저 기반의 간결한 구문으로 테스트를 기술할 수 있습니다.
PHPUnit과의 주요 차이:
관점 Pest PHPUnit 테스트 정의 test() / it() 클로저메서드로 정의하는 클래스 어서션 Expectation API (expect()->toBe()) $this->assert*()데이터셋 dataset() / with()@dataProvider훅 beforeEach() / afterEach()setUp() / tearDown()네스트 describe()로 그룹화클래스로 분리
Pest의 테스트는 PHPUnit으로 실행되므로, 기존의 PHPUnit 테스트와 공존할 수 있습니다. php artisan test 또는 vendor/bin/pest로 실행합니다.
describe / it / test의 사용 구분
test()
가장 심플한 정의. 테스트명을 그대로 설명문으로서 사용합니다.
test ( 'ユーザーはメールアドレスでログインできる' , function () {
$user = User :: factory () -> create ();
$response = $this -> post ( '/login' , [
'email' => $user -> email ,
'password' => 'password' ,
]);
$response -> assertRedirect ( '/dashboard' );
});
it()
“it should…”와 같은 영어적인 문체로 자연 언어에 가까운 기술을 할 수 있습니다.
it ( '認証されていないユーザーをログインページにリダイレクトする' , function () {
$response = $this -> get ( '/dashboard' );
$response -> assertRedirect ( '/login' );
});
describe()
관련된 테스트를 그룹화합니다. beforeEach()의 공유나 파일 내에서의 논리적인 정리에 사용합니다.
describe ( '注文管理' , function () {
beforeEach ( function () {
$this -> user = User :: factory () -> create ();
$this -> actingAs ( $this -> user );
});
it ( '注文一覧を取得できる' , function () {
Order :: factory ( 3 ) -> for ( $this -> user ) -> create ();
$response = $this -> get ( '/orders' );
$response -> assertOk () -> assertJsonCount ( 3 , 'data' );
});
it ( '他のユーザーの注文は取得できない' , function () {
$other = User :: factory () -> create ();
Order :: factory () -> for ( $other ) -> create ();
$response = $this -> get ( '/orders' );
$response -> assertOk () -> assertJsonCount ( 0 , 'data' );
});
});
describe()는 네스트할 수 있습니다. 다만 지나치게 깊은 네스트는 테스트를 읽기 어렵게 하므로, 2계층까지를 기준으로 합시다.
Expectation API
expect()는 Pest 독자의 어서션 구문입니다. 메서드 체인으로 조건을 쌓아 올릴 수 있습니다.
기본적인 어서션
expect ( $value ) -> toBe ( 42 ); // 厳密な等価(===)
expect ( $value ) -> toEqual ([ 'a' => 1 ]); // 緩やかな等価(==)
expect ( $value ) -> toBeTrue ();
expect ( $value ) -> toBeFalse ();
expect ( $value ) -> toBeNull ();
expect ( $value ) -> not -> toBeNull ();
expect ( $string ) -> toContain ( 'Laravel' );
expect ( $array ) -> toHaveCount ( 3 );
expect ( $array ) -> toHaveKey ( 'email' );
expect ( $array ) -> toContain ( 'admin' );
expect ( $number ) -> toBeGreaterThan ( 0 );
expect ( $number ) -> toBeLessThanOrEqual ( 100 );
모델에 대한 어서션
expect ( $user ) -> toBeInstanceOf ( User :: class );
expect ( $user -> email ) -> toMatchRegex ( '/ ^ . + @. + \. . +$ /' );
// データベースへの保存を確認
expect ( User :: where ( 'email' , '[email protected] ' ) -> exists ()) -> toBeTrue ();
and() 체인
expect ( $response -> status ()) -> toBe ( 200 )
-> and ( $response -> json ( 'name' )) -> toBe ( 'Laravel' )
-> and ( $response -> json ( 'version' )) -> toBeGreaterThan ( 12 );
each()로 배열의 각 요소를 검증
$users = User :: factory ( 3 ) -> create ();
expect ( $users ) -> each ( function ( $user ) {
$user -> toBeInstanceOf ( User :: class )
-> email -> not -> toBeNull ();
});
데이터셋을 사용한 파라미터화 테스트
같은 로직을 여러 입력값으로 테스트하려면 dataset() 또는 인라인 with()를 사용합니다.
인라인 데이터셋
it ( '無効なメールアドレスはバリデーションエラーになる' , function ( string $email ) {
$response = $this -> post ( '/register' , [ 'email' => $email ]);
$response -> assertInvalid ( 'email' );
}) -> with ([
'プレーンテキスト' => [ 'not-an-email' ],
'ドメインなし' => [ 'user@' ],
'空文字' => [ '' ],
]);
이름 붙은 데이터셋
tests/Datasets 디렉토리에 데이터셋을 정의합니다.
// tests/Datasets/InvalidEmails.php
dataset ( 'invalid_emails' , [
'plain' => [ 'not-an-email' ],
'no-domain' => [ 'user@' ],
'empty' => [ '' ],
'spaces' => [ 'user @example.com' ],
]);
it ( '無効なメールアドレスはバリデーションエラーになる' , function ( string $email ) {
$response = $this -> post ( '/register' , [ 'email' => $email ]);
$response -> assertInvalid ( 'email' );
}) -> with ( 'invalid_emails' );
클로저를 사용한 동적 데이터셋
it ( 'ユーザープランごとに異なる制限がある' , function ( string $plan , int $limit ) {
$user = User :: factory () -> create ([ 'plan' => $plan ]);
expect ( $user -> requestLimit ()) -> toBe ( $limit );
}) -> with ([
[ 'free' , 100 ],
[ 'pro' , 1000 ],
[ 'enterprise' , 10000 ],
]);
Mockery를 사용한 유닛 테스트
서비스의 모크
use App\Services\ PaymentGateway ;
use Mockery\ MockInterface ;
test ( '決済サービスが呼び出される' , function () {
$mock = $this -> mock ( PaymentGateway :: class , function ( MockInterface $mock ) {
$mock -> expects ( 'charge' )
-> with ( 1000 , 'jpy' )
-> andReturn ([ 'status' => 'succeeded' ]);
});
$result = app ( PaymentGateway :: class ) -> charge ( 1000 , 'jpy' );
expect ( $result [ 'status' ]) -> toBe ( 'succeeded' );
});
스파이
스파이는 모크와 달리, 구현을 호출하면서 호출 기록을 남깁니다.
use App\Services\ NotificationService ;
test ( '通知サービスが呼び出されたか確認する' , function () {
$spy = $this -> spy ( NotificationService :: class );
$this -> post ( '/orders' , [ 'amount' => 1000 ]);
$spy -> shouldHaveReceived ( 'send' ) -> once () -> with ( 'order.created' );
});
부분 모크
일부 메서드만 모크하고, 다른 것은 실제 구현을 사용합니다.
use App\Services\ ReportService ;
use Mockery\ MockInterface ;
test ( 'レポートのファイル書き込みをスキップする' , function () {
$mock = $this -> partialMock ( ReportService :: class , function ( MockInterface $mock ) {
$mock -> expects ( 'writeFile' ) -> andReturnNull ();
});
$result = $mock -> generate ();
expect ( $result ) -> not -> toBeNull ();
});
HTTP 페이크에 의한 외부 API 호출의 테스트
Http::fake()를 사용하면 실제 HTTP 요청을 보내지 않고 응답을 스텁할 수 있습니다.
기본적인 페이크
use Illuminate\Support\Facades\ Http ;
test ( '外部APIからユーザー情報を取得する' , function () {
Http :: fake ([
'https://api.example.com/users/*' => Http :: response ([
'id' => 1 ,
'name' => 'Laravel User' ,
], 200 ),
]);
$result = app ( UserApiClient :: class ) -> find ( 1 );
expect ( $result [ 'name' ]) -> toBe ( 'Laravel User' );
Http :: assertSent ( fn ( $request ) => $request -> url () === 'https://api.example.com/users/1' );
});
에러 응답의 테스트
test ( 'APIがエラーを返したときに例外が発生する' , function () {
Http :: fake ([
'api.example.com/*' => Http :: response ([], 503 ),
]);
expect ( fn () => app ( UserApiClient :: class ) -> find ( 1 ))
-> toThrow ( \App\Exceptions\ ApiUnavailableException :: class );
});
네트워크 장애의 시뮬레이션
use Illuminate\Http\Client\ ConnectionException ;
test ( '接続エラーをハンドリングする' , function () {
Http :: fake ( fn () => throw new ConnectionException ( 'Connection refused' ));
$result = app ( UserApiClient :: class ) -> findWithFallback ( 1 );
expect ( $result ) -> toBeNull ();
});
이벤트·메일·알림의 페이크
Event::fake()
use Illuminate\Support\Facades\ Event ;
use App\Events\ OrderCreated ;
test ( '注文作成時にイベントが発行される' , function () {
Event :: fake ();
$this -> post ( '/orders' , [ 'product_id' => 1 , 'amount' => 1000 ]);
Event :: assertDispatched ( OrderCreated :: class , function ( $event ) {
return $event -> order -> amount === 1000 ;
});
});
특정 이벤트만 페이크하고, 다른 것은 실제로 처리시킬 수도 있습니다.
Event :: fake ([ OrderCreated :: class ]);
Mail::fake()
use Illuminate\Support\Facades\ Mail ;
use App\Mail\ WelcomeEmail ;
test ( 'ユーザー登録時にウェルカムメールが送信される' , function () {
Mail :: fake ();
$this -> post ( '/register' , [
'name' => 'Test User' ,
'email' => '[email protected] ' ,
'password' => 'password' ,
'password_confirmation' => 'password' ,
]);
Mail :: assertSent ( WelcomeEmail :: class , function ( $mail ) {
return $mail -> hasTo ( '[email protected] ' );
});
});
Notification::fake()
use Illuminate\Support\Facades\ Notification ;
use App\Notifications\ OrderShipped ;
test ( '出荷時に通知が送られる' , function () {
Notification :: fake ();
$user = User :: factory () -> create ();
$order = Order :: factory () -> for ( $user ) -> create ();
$this -> post ( "/orders/{ $order -> id }/ship" );
Notification :: assertSentTo ( $user , OrderShipped :: class , function ( $notification ) use ( $order ) {
return $notification -> order -> id === $order -> id ;
});
});
Artisan 커맨드의 테스트
use Illuminate\Support\Facades\ Artisan ;
test ( '不要なデータを削除するコマンドが動作する' , function () {
$expired = Order :: factory () -> create ([ 'expires_at' => now () -> subDay ()]);
$valid = Order :: factory () -> create ([ 'expires_at' => now () -> addDay ()]);
$this -> artisan ( 'orders:cleanup' )
-> assertSuccessful ()
-> expectsOutput ( 'Cleaned up 1 expired order(s).' );
expect ( Order :: find ( $expired -> id )) -> toBeNull ();
expect ( Order :: find ( $valid -> id )) -> not -> toBeNull ();
});
대화형 커맨드의 테스트
test ( '対話型コマンドで確認後に処理が実行される' , function () {
$this -> artisan ( 'reports:generate' )
-> expectsQuestion ( '実行しますか?' , 'yes' )
-> expectsOutput ( 'レポートを生成しました。' )
-> assertExitCode ( 0 );
});
RefreshDatabase와 LazilyRefreshDatabase
RefreshDatabase
각 테스트 후에 데이터베이스를 롤백하고, 클린한 상태를 유지합니다. 테스트 스위트 개시 시에 마이그레이션을 실행합니다.
// tests/Pest.php
uses ( RefreshDatabase :: class ) -> in ( 'Feature' );
use Illuminate\Foundation\Testing\ RefreshDatabase ;
test ( 'ユーザーを作成できる' , function () {
$user = User :: factory () -> create ([ 'name' => 'Laravel' ]);
expect ( User :: count ()) -> toBe ( 1 );
expect ( $user -> name ) -> toBe ( 'Laravel' );
});
LazilyRefreshDatabase
RefreshDatabase는 모든 테스트에서 마이그레이션을 확인하지만, LazilyRefreshDatabase는 데이터베이스를 실제로 변경하는 테스트가 실행될 때까지 마이그레이션을 지연시킵니다. 데이터베이스를 만지지 않는 테스트가 많은 경우에 고속화할 수 있습니다.
// tests/Pest.php
uses ( LazilyRefreshDatabase :: class ) -> in ( 'Feature' );
대부분의 프로젝트에서는 RefreshDatabase가 안전한 선택지입니다. LazilyRefreshDatabase는 테스트 스위트가 커지고 데이터베이스 조작이 없는 테스트가 많은 경우에 검토합시다.
관점 RefreshDatabaseLazilyRefreshDatabase마이그레이션 실행 타이밍 테스트 스위트 개시 시 최초의 DB 조작 시 DB를 만지지 않는 테스트의 오버헤드 있음 없음 권장 장면 일반적인 케이스 테스트 수가 많고 DB를 사용하지 않는 테스트가 많은 경우
코드 커버리지의 계측
Xdebug 또는 PCOV가 필요합니다.
# カバレッジをターミナルに表示
php artisan test --coverage
# 最低カバレッジを強制(下回るとCIが失敗)
php artisan test --coverage --min=80
# HTMLレポートを出力
vendor/bin/pest --coverage-html=coverage/
# 低速なテストを確認
php artisan test --profile
코드 커버리지의 계측은 테스트 실행 시간을 대폭 증가시킵니다. CI 환경에서는 전용 잡으로 분리하거나, 풀 리퀘스트 시에만 실행하도록 설정하는 것을 추천합니다.
관련 페이지
테스트 입문 Laravel에서의 테스트의 기본적인 작성법과 php artisan test의 사용법을 확인합니다.