Testing

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. Every page in this section has ended with tests, and Bookshelf’s suite now has 98 of them, all in Pest 4. This page collects the tools those tests use and measures the suite as a whole. Pest and PHPUnit themselves, assertions, data providers and test doubles in plain PHP, are on Testing with PHPUnit and Pest.

The testing environment

php artisan test runs Pest (or PHPUnit) with Laravel’s output. The environment comes from phpunit.xml, which the skeleton fills with settings that keep tests fast and isolated:

<php>
    <env name="APP_ENV" value="testing"/>
    <env name="BCRYPT_ROUNDS" value="4"/>
    <env name="CACHE_STORE" value="array"/>
    <env name="DB_CONNECTION" value="sqlite"/>
    <env name="DB_DATABASE" value=":memory:"/>
    <env name="MAIL_MAILER" value="array"/>
    <env name="QUEUE_CONNECTION" value="sync"/>
    <env name="SESSION_DRIVER" value="array"/>
    <env name="BROADCAST_CONNECTION" value="null"/>
    <env name="PULSE_ENABLED" value="false"/>
    <env name="TELESCOPE_ENABLED" value="false"/>
</php>

An .env.testing file, if present, is loaded instead of .env when tests run. It holds values that differ by machine, such as a real PostgreSQL database for tests that need its features. In-memory SQLite is fast, but it is not the production database. The PostgreSQL-only behaviour on Database and Query Builder and Query Performance and N+1 would pass or fail differently on it. A CI job that runs the suite against the production database engine catches that.

Running, in parallel, with coverage

$ php artisan test
  Tests:    98 passed (285 assertions)
  Duration: 3.88s
$ php artisan test --parallel --processes=4
  Tests:    98 passed (285 assertions)
  Duration: 1.64s
$ php artisan test --profile
  Top 10 slowest tests:
  Tests\Feature\AuthenticationTest > it rejects a wrong password and thr 1.03s
  Tests\Feature\LivewireTest > it updates the lazy count when a review i 0.32s
  Tests\Feature\ApiTokenTest > it limits a token to its abilities        0.25s
  …
                                                       (55.98% of 3.78s) 2.11s

Four processes cut the run from 3.88 to 1.64 seconds. Each process gets its own database (:memory: here, or a _test_1, _test_2 copy of a real one). Tests that share files, ports or a Redis database must use a per-process name, from ParallelTesting::token().

The profile found one test taking a whole second. It sends five wrong passwords, and SessionGuard pads every failed attempt() to 200 ms with a Timebox, so the response time does not show whether the email exists. That is the defence working, and it costs tests time. config(['auth.timebox_duration' => 0]) in that test, or a lower value in phpunit.xml, removes the wait.

Coverage needs Xdebug or pcov. The host PHP had neither, and PECL is blocked in this environment, so pcov was built from its GitHub repository and loaded through PHP_INI_SCAN_DIR. php -d extension=… on artisan test is not enough, because the command starts Pest in a child process that does not inherit -d:

$ PHP_INI_SCAN_DIR="/etc/php/8.3/cli/conf.d:$PWD/pcov-ini" php artisan test --coverage --min=60
  …
  Notifications/LoanOverdue ................................... 24..36 / 20.0%
  Services/CatalogueClient ............................................ 100.0%
  ValueObjects/Money ...................................... 13, 22..32 / 20.0%
  ────────────────────────────────────────────────────────────────────────────
                                                                 Total: 67.0 %

The uncovered lines 24 to 36 of LoanOverdue are its toMail() method. Every notification test used Notification::fake(), which records the notification without building the message. That is the gap in which the AnonymousNotifiable::$name error on Mail and Notifications went unnoticed. Coverage is most useful for finding untested methods like this, not as a target. --min=60 fails the run below 60% so the number cannot slide unnoticed.

HTTP, views and console

A feature test sends a request through the whole application, middleware included, and asserts on the response. Six tests that cover the main kinds:

<?php
// tests/Feature/TestingShowcaseTest.php
use App\Jobs\SyncBookWithCatalogue;
use App\Models\{Author, Book, Loan, User};
use App\Services\CatalogueClient;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

test('a failed login redirects back with errors and no session', function () {
    $this->from('/login')
        ->post('/login', ['email' => 'not-an-email'])
        ->assertRedirect('/login')
        ->assertSessionHasErrors(['email', 'password'])
        ->assertSessionHasInput('email', 'not-an-email');

    $this->assertGuest();
});

test('an authenticated member reaches the dashboard', function () {
    $member = User::factory()->create(['name' => 'Ada']);

    $this->actingAs($member)->get('/dashboard')->assertOk()->assertSeeText('Hello Ada');
    $this->assertAuthenticatedAs($member);
});

test('views and components render on their own', function () {
    $this->view('notes')->assertSee('name="_token"', escape: false);

    $book = Book::factory()->create(['title' => 'Kindred', 'published_year' => 1979]);
    $this->blade('<x-book-card :book="$book">on loan</x-book-card>', ['book' => $book])   // the template echoes $slot
        ->assertSee('Kindred')->assertSee((now()->year - 1979).' years old')->assertSee('on loan');
});

test('the import command asks before saving', function () {
    $csv = tempnam(sys_get_temp_dir(), 'csv');
    file_put_contents($csv, "Kindred,Octavia E. Butler,1979\nDune,Frank Herbert,1965\n");

    $this->artisan('books:import', ['file' => $csv])
        ->expectsConfirmation('Import 2 books?', 'no')
        ->expectsOutputToContain('Nothing imported.')
        ->assertSuccessful();
    $this->assertDatabaseCount('books', 0);

    $this->artisan('books:import', ['file' => $csv])->expectsConfirmation('Import 2 books?', 'yes')->assertSuccessful();
    $this->assertDatabaseHas('books', ['title' => 'Dune', 'published_year' => 1965]);
});

test('the sync job uses the catalogue client', function () {
    $book = Book::factory()->create(['isbn' => '978-0-8070-8305-4']);
    $this->mock(CatalogueClient::class)->shouldReceive('lookup')->once()->with('9780807083054')->andReturn(['pages' => 264]);

    app()->call([new SyncBookWithCatalogue($book), 'handle']);

    expect($book->fresh()->meta['pages'])->toBe(264);
});

test('a loan is overdue the day after it is due', function () {
    $this->freezeSecond();                                    // the column stores whole seconds
    $loan = Loan::factory()->create(['status' => 'active', 'due_at' => now()->plus(days: 21)]);

    $this->travel(21)->days();
    expect($loan->fresh()->due_at->isPast())->toBeFalse();

    $this->travel(1)->days();
    expect($loan->fresh()->due_at->isPast())->toBeTrue();
});

All six pass. Two of them failed first, and both failures are worth knowing:

  • $this->component(BookCard::class, …) threw Undefined variable $slot. The component’s template echoes {{ $slot }}, and component() renders the class without one. $this->blade('<x-book-card …>…</x-book-card>') renders it the way a page does, slot included.

  • With freezeTime(), the loan was already past on its due second. now() kept its microseconds, but the dateTime column stored whole seconds, so the reloaded due_at was a fraction of a second before the frozen "now". freezeSecond() freezes on a whole second. The same trap catches any comparison between a value from the database and a frozen clock.

$this->mock() binds a Mockery double into the container, so anything resolved from it, here the handle() method through app()->call(), receives the double. $this->spy() records calls without setting expectations first. $this->partialMock() replaces only the listed methods.

JSON APIs and file uploads

assertJsonPath() checks one value. For the whole shape of a response, assertJson() also accepts a closure that receives an Illuminate\Testing\Fluent\AssertableJson. has('data', 2, fn ...) asserts the count and scopes the closure to the first item, where() takes a value or a closure, whereType() and whereAllType() check types (string, integer, double, boolean, array, null, joined with |), each() runs the same assertions on every item, and missing()/missingAll() assert that a key is absent. Two tests on Bookshelf’s version 1 API:

<?php
// tests/Feature/JsonAndUploadTest.php
use Illuminate\Testing\Fluent\AssertableJson;

test('the book list has the expected shape and no catalogue fields for guests', function () {
    $this->getJson('/api/v1/books?sort=-published_year')
        ->assertOk()
        ->assertJson(fn (AssertableJson $json) => $json
            ->has('data', 2, fn (AssertableJson $book) => $book      // scoped to data.0
                ->where('title', 'Wild Seed')
                ->where('published_year', fn (int $year) => $year > 1970)
                ->whereType('isbn', 'string|null')
                ->missingAll(['meta', 'created_at'])                   // librarian-only fields
                ->etc())
            ->has('data', fn (AssertableJson $books) => $books
                ->each(fn (AssertableJson $book) => $book->whereAllType([
                    'id' => 'integer', 'title' => 'string', 'author.name' => 'string',
                ])->etc()))
            ->hasAll(['meta', 'links']));
});

test('without etc() an unexpected attribute fails the test', function () {
    $this->getJson('/api/v1/books/1')
        ->assertJson(fn (AssertableJson $json) => $json
            ->has('data', fn (AssertableJson $book) => $book
                ->where('id', 1)
                ->where('title', 'Kindred')));
})->throws(\PHPUnit\Framework\AssertionFailedError::class, 'Unexpected properties were found in scope [data].');

Both pass. The second shows the rule that makes fluent assertions useful for an API: a scope that does not end with etc() fails when the JSON has a key the test did not mention. The resource’s isbn, published_year, author and links made it fail with Unexpected properties were found in scope [data]. A new field added to BookResource, such as a librarian-only meta that leaks to guests, then breaks a test instead of shipping unnoticed. The check applies only to the scopes the test enters: keys inside author are not checked unless the test scopes into it with has('author', fn ...). missingAll(['meta', 'created_at']) states the guest view outright, which reads better than relying on the absence of etc().

File uploads are tested with UploadedFile::fake(), which writes a real temporary file (an image for image(), with the width and height given, drawn with GD), and Storage::fake(), which swaps the disk for a local one under storage/framework/testing/disks and empties it on every call (with a per-process directory under --parallel). The fake disk also answers temporaryUrl(), which the cover controller calls. size() sets the size the file reports, in kilobytes, without writing that many bytes, and create() makes a file of any MIME type:

<?php
use App\Enums\Role;
use App\Models\{Book, User};
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum;

test('a cover upload is stored on the fake disk', function () {
    Storage::fake('s3');
    Sanctum::actingAs(User::factory()->create(['role' => Role::Librarian]));
    $book = Book::first();

    $response = $this->postJson("/api/books/{$book->id}/cover", [
        'cover' => UploadedFile::fake()->image('scan.jpg', 800, 1000)->size(300),
    ]);

    $response->assertCreated()->assertJsonPath('path', fn (string $path) => str_starts_with($path, 'covers/'));
    Storage::disk('s3')->assertExists($response->json('path'));
    Storage::disk('s3')->assertMissing('covers/scan.jpg');                // the client's name is never used
    expect(Storage::disk('s3')->allFiles())->toHaveCount(1);
});

test('an oversized or too narrow cover is rejected and nothing is stored', function () {
    Storage::fake('s3');
    Sanctum::actingAs(User::factory()->create(['role' => Role::Librarian]));

    $this->postJson('/api/books/1/cover', ['cover' => UploadedFile::fake()->image('big.jpg', 800, 1000)->size(3000)])
        ->assertJsonValidationErrors(['cover' => 'The cover field must not be greater than 2000 kilobytes.']);
    $this->postJson('/api/books/1/cover', ['cover' => UploadedFile::fake()->image('thin.png', 150, 225)])
        ->assertJsonValidationErrors(['cover' => 'The cover field has invalid image dimensions.']);
    $this->postJson('/api/books/1/cover', ['cover' => UploadedFile::fake()->create('cover.pdf', 100, 'application/pdf')])
        ->assertJsonValidationErrors('cover');

    Storage::disk('s3')->assertDirectoryEmpty('/');
});

All four tests pass, and one assertion had to change first. The cover rule on File Storage and Images is File::image()->max('2mb'), and the test expected the error to say 2048 kilobytes. It says 2000: the File rule reads size strings in decimal units, so 2mb is 2,000 KB, not 2,048. A fake file’s MIME type comes from its name, or from the type passed to create() (the cover.pdf above is rejected because it is not an image), not from its content, which is why the content-sniffing test on that page builds a real UploadedFile instead. assertExists() and assertMissing() take a path or an array of paths; assertDirectoryEmpty() fails if anything was written.

The database

RefreshDatabase migrates once per run and wraps each test in a transaction that is rolled back. On :memory: SQLite, it migrates for every process. LazilyRefreshDatabase migrates only when a test first touches the database, which helps suites with many database-free tests. DatabaseTransactions only rolls back, for a schema managed elsewhere. Factories with states and relationships (Migrations, Seeders and Factories) build the data. assertDatabaseHas, assertDatabaseMissing, assertDatabaseCount, assertModelExists and assertSoftDeleted check the result.

A transaction per test has one blind spot: code that commits on purpose, or a dispatch held with afterCommit. Inside the test’s transaction, afterCommit callbacks still run, because Laravel treats the test transaction as the outer level, so a missing afterCommit does not show in tests. That is why the rollback behaviour on Queues and Jobs was measured with scripts, not tests.

The fakes

Each facade with side effects has a fake that records instead of acting. The earlier pages use them with their features:

Fake Records Shown on

Queue::fake(), Bus::fake()

pushed jobs, chains, batches

Queues and Jobs

Event::fake([...])

dispatched events, listener wiring

Events and Listeners

Mail::fake(), Notification::fake()

sent and queued mail, notifications per channel

Mail and Notifications

Http::fake(), preventStrayRequests()

outgoing requests, with sequences and failures

HTTP Client

Storage::fake('s3')

files written to a disk

File Storage and Images

Process::fake()

commands run

Processes, Concurrency and Context

Sanctum::actingAs(), Passport::actingAs()

an API user with abilities

API Authentication: Sanctum and Passport

ReadingAdvisor::fake()

AI agent prompts

AI SDK, MCP and Boost

A fake proves that the code asked for the side effect. It does not prove that the side effect works: whether the mail renders, the job serializes, or the HTTP request is accepted. The runs on the earlier pages found errors in exactly those places (a notification’s toMail(), a fake file’s MIME type, a pattern that did not match), which is the case for a few tests against the real thing as well.

Livewire and Inertia

Livewire::test() mounts a component and drives it like a browser would, without one. This is from Livewire:

<?php
Livewire::test('book-search')
    ->assertSee('Kindred')
    ->set('search', 'dispo')
    ->assertSee('The Dispossessed')
    ->assertDontSee('Kindred')
    ->call('clear')
    ->assertSet('search', '');

For Inertia pages, assertInertia checks the component name and props, including deferred ones. This test is from the React starter kit on Inertia and Starter Kits:

<?php
$this->get('/books?search=kin')
    ->assertInertia(fn (Assert $page) => $page
        ->component('books/index')
        ->where('filters.search', 'kin')
        ->has('books', 1)
        ->where('books.0.title', 'Kindred')
        ->missing('stats')                               // deferred: not in the first response
        ->loadDeferredProps(fn (Assert $reload) => $reload
            ->where('stats.books', 2)));

Both check what the server sends. The JavaScript in the browser is tested with Dusk or Pest’s browser plugin (Browser Testing with Dusk).

Where older books and tutorials are outdated

  • PHPUnit classes for every test (Stauffer ch. 12) still work. The installer has offered Pest as the default since Laravel 11, and the starter kits ship Pest tests.

  • $this->expectsJobs() and withoutEvents() were removed in Laravel 10. Queue::fake() and Event::fake() replace them.

  • Carbon::setTestNow() still works. freezeTime(), freezeSecond() and travel() are the test helpers.

  • Installing brianium/paratest by hand for --parallel is no longer needed with Pest, which depends on it.

  • A test database configured in phpunit.xml as MySQL is often slower than in-memory SQLite, and less faithful than the production engine. Pick per test file what each test needs.