메인 콘텐츠로 건너뛰기

Pest란

Pest는 PHPUnit 위에 구축된 테스트 프레임워크로, Laravel 11 이후의 신규 프로젝트에 기본으로 채용되어 있습니다. PHPUnit의 풍부한 어서션군을 그대로 이용하면서, 클로저 기반의 간결한 구문으로 테스트를 기술할 수 있습니다. PHPUnit과의 주요 차이:
관점PestPHPUnit
테스트 정의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);
});

RefreshDatabaseLazilyRefreshDatabase

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의 사용법을 확인합니다.
마지막 수정일 2026년 7월 13일