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>
Source: Laravel — Testing Environment
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
Source: Laravel — Running Tests in Parallel and Profiling Tests
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();
});
Source: Laravel — HTTP Tests and Console Tests
All six pass. Two of them failed first, and both failures are worth knowing:
-
$this->component(BookCard::class, …)threwUndefined variable $slot. The component’s template echoes{{ $slot }}, andcomponent()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 thedateTimecolumn stored whole seconds, so the reloadeddue_atwas 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 |
|---|---|---|
|
pushed jobs, chains, batches |
|
|
dispatched events, listener wiring |
|
|
sent and queued mail, notifications per channel |
|
|
outgoing requests, with sequences and failures |
|
|
files written to a disk |
|
|
commands run |
|
|
an API user with abilities |
|
|
AI agent prompts |
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', '');
Source: Livewire — Testing
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)));
Source: Inertia — Testing
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()andwithoutEvents()were removed in Laravel 10.Queue::fake()andEvent::fake()replace them. -
Carbon::setTestNow()still works.freezeTime(),freezeSecond()andtravel()are the test helpers. -
Installing
brianium/paratestby hand for--parallelis no longer needed with Pest, which depends on it. -
A test database configured in
phpunit.xmlas MySQL is often slower than in-memory SQLite, and less faithful than the production engine. Pick per test file what each test needs.