Testing with Pest and PHPUnit

Feature versus unit tests, RefreshDatabase, factories and states, HTTP assertions, fakes for mail and queues, and browser testing.

Feature tests and database state

// tests/Pest.php
uses(RefreshDatabase::class)->in('Feature');

// tests/Feature/BookApiTest.php
it('creates a book for an authenticated user', function () {
    $user = User::factory()->create();

    $this->actingAs($user)
        ->postJson('/api/books', ['title' => 'The Dispossessed', 'author_id' => $user->id])
        ->assertCreated()
        ->assertJsonPath('data.title', 'The Dispossessed');

    expect(Book::where('title', 'The Dispossessed')->exists())->toBeTrue();
});

it('rejects a blank title', function () {
    $this->actingAs(User::factory()->create())
        ->postJson('/api/books', ['title' => ''])
        ->assertStatus(422)
        ->assertJsonValidationErrors('title');
});
  • RefreshDatabase migrates once and wraps each test in a transaction, so a large schema costs seconds, once.
  • Test against a real database engine. A test suite that runs on SQLite will not catch a PostgreSQL-only constraint or a case-sensitivity assumption.
  • Assert the response and the database. A 201 with nothing persisted is a real bug that response-only tests miss.
💡
Factories are the centre of a maintainable suite: define them for every model, add named states (->published(), ->suspended()) instead of overriding attributes inline, and let the test read as the scenario it describes.

Fakes, mocks and time

it('queues a receipt after payment', function () {
    Queue::fake();
    Mail::fake();
    Event::fake([OrderPlaced::class]);

    $order = Order::factory()->create();

    $this->postJson("/api/orders/{$order->id}/pay")->assertOk();

    Queue::assertPushed(SendInvoice::class, fn ($job) => $job->order->is($order));
    Event::assertDispatched(OrderPlaced::class);
});

it('expires a listing after 30 days', function () {
    $this->travelTo(now()->addDays(31));
    expect(Listing::factory()->create(['expires_at' => now()])->isExpired())->toBeTrue();
    $this->travelBack();
});
FakeReplacesAssert
Queue::fake()Job dispatchassertPushed, assertNotPushed
Mail::fake()MailerassertQueued, assertSent
Event::fake()Event dispatcherassertDispatched
Storage::fake('s3')FilesystemassertExists
Http::fake()Outbound HTTPassertSent with a URL pattern

Prefer Http::fake() over mocking a client class - it intercepts at the transport layer, so redirects, retries and timeouts behave like production. Assert the URL and body the way the vendor documents them, since that is the contract you actually depend on.

Unit tests and browser tests

// a pure unit test: no container, no database
it('applies a volume discount', function () {
    $pricing = new Pricing;
    expect($pricing->total(cents: 100000, quantity: 50))->toBe(85000);
});

// Dusk: a real browser, for the journeys HTTP tests cannot express
class CheckoutTest extends DuskTestCase
{
    public function test_a_customer_can_check_out(): void
    {
        $this->browse(function (Browser $browser) {
            $browser->loginAs(User::factory()->create())
                ->visit('/books/1')
                ->press('Add to cart')
                ->waitForText('Item added')
                ->visit('/checkout')
                ->type('card', '4242424242424242')
                ->press('Pay')
                ->waitForText('Thank you')
                ->assertPathIs('/orders/1');
        });
    }
}
  • Keep the suite fast enough to run on every commit: unit tests in milliseconds, feature tests in seconds, browser tests in a separate pipeline that runs before release.
  • Coverage percentage is not a goal. A test that asserts a getter returns what the constructor set protects nothing.
  • Close the loop with php artisan test --parallel once the suite grows past a few hundred tests.

FAQ

Pest or PHPUnit?
Pest is a thin layer over PHPUnit with a more readable syntax and useful plugins such as architecture tests. Migration is incremental, and existing PHPUnit classes keep working in the same suite.
Why does RefreshDatabase make my suite slow?
Usually a large migration set replayed per test class, or un-migrated schema. Cache the schema with php artisan migrate --seed in CI plus --parallel, and check whether a test accidentally disables transactions.

Building APIs with Sanctum The service container, providers and facades

Last refreshed 2026-09-18.