
Laravel Testing
- 273 installs
- 60 repo stars
- Updated May 16, 2026
- asyrafhussin/agent-skills
Write and run Laravel feature, unit, and HTTP tests; mock facades, factories, and database state before merging PHP API or web app changes.
About
Laravel-focused testing skill for PHPUnit or Pest suites covering HTTP APIs, Eloquent models, middleware, jobs, and service layers with factories, mocks, and database refresh patterns before production deploys.
- Feature and unit test structure
- Model factories and database isolation
- HTTP and JSON API endpoint tests
- Mocking facades, queues, and events
- CI-friendly Laravel test conventions
Laravel Testing by the numbers
- 273 all-time installs (skills.sh)
- Ranked #32 of 65 PHP & Laravel skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/asyrafhussin/agent-skills --skill laravel-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 273 |
|---|---|
| repo stars | ★ 60 |
| Last updated | May 16, 2026 |
| Repository | asyrafhussin/agent-skills ↗ |
What it does
Write and run Laravel feature, unit, and HTTP tests; mock facades, factories, and database state before merging PHP API or web app changes.
Files
Laravel 13 Testing — Pest PHP 4 & PHPUnit 12
Supports both Pest PHP 4 and PHPUnit 12. See Framework Detection below.
>
PHPUnit version note: Laravel 13 ships withphpunit/phpunit: ^12.5.12in its defaultcomposer.json. All patterns in this skill are compatible with PHPUnit 11, 12, and 13.
Comprehensive testing guide for Laravel 13 applications. Contains 24 rules across 6 categories for writing fast, readable, and reliable tests. Supports both Pest PHP 4 and PHPUnit 12 (Laravel 13 default).
Framework Detection
Before writing or reviewing any test code, detect which testing framework the project uses:
Step 1 — Check composer.json
# Look for these in require-dev:
# "pestphp/pest" → Pest
# "phpunit/phpunit" (without pest) → PHPUnit- If
pestphp/pestis present → use Pest syntax - If only
phpunit/phpunitis present → use PHPUnit syntax - If both are present → Pest takes priority (Pest runs on top of PHPUnit)
Step 2 — Check for tests/Pest.php
- If
tests/Pest.phpexists → Pest is configured, use Pest syntax
Step 3 — If still unclear, ask the user
"I couldn't detect the testing framework. Does this project use Pest PHP or PHPUnit?"
---
Syntax Reference
Test Declaration
| Pest | PHPUnit | |
|---|---|---|
| Test function | test('...', fn() => ...) | public function test_...(): void |
| Readable name | it('...', fn() => ...) | #[Test] public function it_...() |
| Grouping | describe('...', fn() => ...) | Test class name / nested classes |
| Trait application | uses(RefreshDatabase::class) | use RefreshDatabase; inside class |
| Before each | beforeEach(fn() => ...) | protected function setUp(): void |
| After each | afterEach(fn() => ...) | protected function tearDown(): void |
| Parameterised | ->with([...]) | #[DataProvider] attribute |
| Global setup | uses(...)->in('Feature') in Pest.php | Base TestCase class |
Core assertions (identical in both frameworks)
assertStatus, assertJson, assertJsonPath, assertDatabaseHas, assertModelExists, actingAs, Mail::fake(), Queue::fake(), Event::fake(), Notification::fake(), Storage::fake() — all work the same in Pest and PHPUnit.
---
When to Apply
Reference these guidelines when:
- Writing feature or unit tests for Laravel
- Testing HTTP endpoints and API responses
- Creating factories and test data
- Asserting database state after operations
- Faking Mail, Queue, Notification, or Event facades
- Testing authenticated routes and API tokens
- Organising tests with describe blocks, datasets, or test classes
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | HTTP & Feature Tests | CRITICAL | http- |
| 2 | Model Factories | CRITICAL | factory- |
| 3 | Database Assertions | HIGH | db- |
| 4 | Faking Services | HIGH | fake- |
| 5 | Authentication Testing | HIGH | auth- |
| 6 | Test Organisation Patterns | MEDIUM | pest- |
Quick Reference
1. HTTP & Feature Tests (CRITICAL)
http-test-structure- Arrange/Act/Assert with factories — Pest + PHPUnit exampleshttp-assert-response- assertStatus, assertJson, assertRedirect, assertJsonMissinghttp-assert-json-fluent- Fluent assertJson with AssertableJson closurehttp-refresh-database- RefreshDatabase vs DatabaseTransactions — when to use each
2. Model Factories (CRITICAL)
factory-define- Define factories with typed fake data and PHP 8.3 syntaxfactory-states- Factory states for distinct test scenariosfactory-sequences- sequence() for varied data across multiple recordsfactory-relationships- has(), for(), recycle(), afterCreating()
3. Database Assertions (HIGH)
db-assert-has- assertDatabaseHas, assertModelExists for presence checksdb-assert-missing- assertDatabaseMissing, assertModelMissing for deletiondb-assert-soft-deletes- assertSoftDeleted, trashed() factory state
4. Faking Services (HIGH)
fake-mail- Mail::fake(), assertSent vs assertQueued, assertNothingSentfake-queue- Queue::fake(), assertPushed, assertPushedOnfake-notification- Notification::fake(), assertSentTo, assertCountfake-event- Event::fake(), assertDispatched, assertNotDispatchedfake-storage- Storage::fake(), UploadedFile::fake(), assertExistsfake-ai-agent- Agent::fake(), assertPrompted, preventStrayPrompts (Laravel 13+)fake-ai-media- Image::fake(), Audio::fake(), Transcription::fake() (Laravel 13+)fake-ai-data- Embeddings::fake(), Reranking::fake(), Files::fake(), Stores::fake() (Laravel 13+)
5. Authentication Testing (HIGH)
auth-acting-as- actingAs() for session/web authenticated testsauth-sanctum- Sanctum::actingAs() for API token authentication
6. Test Organisation Patterns (MEDIUM)
pest-describe-it- describe()/it() (Pest) or test class organisation (PHPUnit)pest-datasets- with() datasets (Pest) or #[DataProvider] (PHPUnit)pest-hooks- beforeEach/afterEach (Pest) or setUp/tearDown (PHPUnit)
Essential Patterns
Pest
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('authenticated user can create a post', function () {
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/posts', ['title' => 'Hello World', 'body' => 'Content.'])
->assertStatus(201)
->assertJsonPath('data.title', 'Hello World');
$this->assertDatabaseHas('posts', ['title' => 'Hello World', 'user_id' => $user->id]);
});PHPUnit
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostControllerTest extends TestCase
{
use RefreshDatabase;
public function test_authenticated_user_can_create_a_post(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/posts', ['title' => 'Hello World', 'body' => 'Content.'])
->assertStatus(201)
->assertJsonPath('data.title', 'Hello World');
$this->assertDatabaseHas('posts', ['title' => 'Hello World', 'user_id' => $user->id]);
}
}How to Use
Read individual rule files for detailed explanations and code examples.
Each rule file contains:
- YAML frontmatter with metadata (title, impact, tags)
- Brief explanation of why it matters
- Bad Example with explanation
- Good Example with both Pest and PHPUnit where syntax differs
- Laravel 13 specific context and references
Full Compiled Document
For the complete guide with all rules expanded: AGENTS.md
Laravel 13 Testing — Pest PHP 4 & PHPUnit 12 — Complete Guide
Version: 1.1.0 Laravel Version: 13.x PHP Version: 8.3+ Pest Version: 4.x | PHPUnit Version: 12.x (Laravel 13 default: ^12.5.12; PHPUnit 11 and 13 also compatible) Organization: Laravel Community Date: March 2026
Overview
Comprehensive testing guide for Laravel 13 applications using Pest PHP 4. Contains 24 rules across 6 categories covering HTTP feature tests, model factories, database assertions, facade faking (including AI SDK), authentication testing, and Pest-specific patterns. All examples use PHP 8.3 syntax and Laravel 13 APIs.
Key Features
- Pest PHP 4 syntax throughout (test(), it(), describe())
- RefreshDatabase and DatabaseTransactions guidance
- Model factory states, sequences, and relationship helpers
- Full facade faking: Mail, Queue, Notification, Event, Storage
- actingAs() and Sanctum::actingAs() for authentication
- assertJson with fluent AssertableJson closures
- Parameterised testing with Pest datasets
- Lifecycle hooks: beforeEach/afterEach/uses()
Categories
1. HTTP & Feature Tests (CRITICAL) — Feature test structure, HTTP assertions, and response validation 2. Model Factories (CRITICAL) — Creating test data with factories, states, sequences, and relationships 3. Database Assertions (HIGH) — Asserting database state after operations 4. Faking Services (HIGH) — Faking Mail, Queue, Notification, and Event facades 5. Authentication Testing (HIGH) — Testing authenticated routes with actingAs and Sanctum 6. Test Organisation Patterns (MEDIUM) — Pest (describe/it/datasets/hooks) and PHPUnit (test classes/dataProvider/setUp) patterns
Framework Detection
Before writing or reviewing any test code, detect which framework the project uses:
1. Check composer.json require-dev:
pestphp/pestfound → use Pest syntax- Only
phpunit/phpunit→ use PHPUnit syntax - Both present → Pest takes priority (Pest runs on PHPUnit)
2. tests/Pest.php exists → Pest is configured 3. Neither found → ask the user: "Does this project use Pest PHP or PHPUnit?"
Syntax Comparison
| Pest | PHPUnit | |
|---|---|---|
| Test function | test('...', fn() => ...) | public function test_...(): void |
| Readable name | it('...', fn() => ...) | #[Test] public function it_...() |
| Grouping | describe('...', fn() => ...) | Test class / nested class |
| Trait application | uses(RefreshDatabase::class) | use RefreshDatabase; in class |
| Before each | beforeEach(fn() => ...) | protected function setUp(): void |
| After each | afterEach(fn() => ...) | protected function tearDown(): void |
| Parameterised | ->with([...]) | #[DataProvider] static method |
| Global setup | uses(...)->in('Feature') in Pest.php | Extend base TestCase class |
Note: All HTTP assertions (assertStatus,assertJson,assertDatabaseHas,actingAs,Mail::fake(), etc.) work identically in both frameworks — only the test wrapper syntax differs.
References
- Laravel 13 Testing
- Laravel HTTP Tests
- Laravel Database Testing
- Laravel Eloquent Factories
- Laravel Mocking
- Pest PHP Docs
---
1. HTTP & Feature Tests (CRITICAL)
Impact: CRITICAL Description: Feature test structure, HTTP assertions, and response validation. These patterns determine whether tests are reliable, readable, and capable of catching real regressions.
Rules in this category: 4
---
Feature Test Structure with Arrange/Act/Assert
Impact: CRITICAL (Produces readable, maintainable, and deterministic tests)
Structure every feature test with three clear phases: Arrange (set up state with factories), Act (make the HTTP request), Assert (verify the response and side effects). Test behavior and outcomes — not implementation details.
Bad Example
<?php
// No factory — raw DB insert is fragile and bypasses model logic
test('create post', function () {
DB::table('users')->insert(['name' => 'Alice', 'email' => 'a@a.com', 'password' => 'pass']);
DB::table('posts')->insert(['title' => 'Test', 'user_id' => 1]);
// Asserts internal implementation detail (method was called)
$mock = Mockery::mock(PostRepository::class);
$mock->shouldReceive('save')->once();
app()->instance(PostRepository::class, $mock);
$this->post('/posts', ['title' => 'Test']);
});Good Example
<?php
use App\Models\User;
use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('authenticated user can create a post', function () {
// Arrange — factories handle all setup
$user = User::factory()->create();
// Act — one HTTP call
$response = $this->actingAs($user)
->postJson('/api/posts', [
'title' => 'Hello World',
'body' => 'Some content.',
]);
// Assert — behavior and outcomes, not internals
$response->assertStatus(201)
->assertJsonPath('data.title', 'Hello World');
$this->assertDatabaseHas('posts', [
'title' => 'Hello World',
'user_id' => $user->id,
]);
});Why It Matters
- Readability: Clear phases make intent obvious to every team member
- Deterministic: Factories + RefreshDatabase eliminate test pollution
- Behavioral focus: Testing outcomes rather than internals means refactoring is safe
---
HTTP Response Assertions
Impact: CRITICAL (Catches regressions in status codes, redirects, and response shape)
Use the full range of HTTP assertion methods to verify status codes, JSON content, redirects, and validation errors. Always assert the specific status code.
Bad Example
<?php
// Only checks 200 — misses 201, 422, 403 distinctions
test('store post', function () {
$response = $this->post('/posts', ['title' => 'x']);
$response->assertOk(); // passes even when 201 is expected
});Good Example
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('creating a post returns 201 with resource data', function () {
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/posts', ['title' => 'Hello', 'body' => 'World'])
->assertStatus(201)
->assertJsonStructure([
'data' => ['id', 'title', 'body', 'created_at'],
]);
});
test('creating a post without title returns 422', function () {
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/posts', ['body' => 'No title'])
->assertUnprocessable()
->assertJsonValidationErrors(['title']);
});
test('web form redirects after store', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/posts', ['title' => 'Hello', 'body' => 'World'])
->assertRedirect(route('posts.index'))
->assertSessionHas('success');
});
test('guest cannot create a post', function () {
$this->postJson('/api/posts', ['title' => 'Hi'])
->assertUnauthorized();
});Key Assertion Methods
| Method | Status |
|---|---|
assertOk() | 200 |
assertCreated() | 201 |
assertNoContent() | 204 |
assertUnauthorized() | 401 |
assertForbidden() | 403 |
assertNotFound() | 404 |
assertUnprocessable() | 422 |
assertJsonPath(key, value) | Specific JSON path |
assertJsonValidationErrors(array) | Validation errors |
assertRedirect(url) | Redirect location |
---
Fluent JSON Assertions with AssertableJson
Impact: HIGH (Enables precise, readable assertions on complex JSON structures)
Pass a closure to assertJson() to receive an AssertableJson instance for fluent, precise assertions on nested JSON.
Bad Example
<?php
// Brittle — breaks if any extra key is added
test('post list', function () {
$posts = Post::factory()->count(3)->create();
$this->getJson('/api/posts')
->assertJson($posts->toArray()); // fails if response wraps in 'data'
});Good Example
<?php
use Illuminate\Testing\Fluent\AssertableJson;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('post show returns resource shape', function () {
$user = User::factory()->create(['name' => 'Alice']);
$post = Post::factory()->for($user)->create(['title' => 'Hello World']);
$this->getJson("/api/posts/{$post->id}")
->assertOk()
->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.title', 'Hello World')
->where('data.author.name', 'Alice')
->has('data.id')
->missing('data.password') // sensitive field must never leak
);
});
test('post index returns paginated posts', function () {
Post::factory()->count(5)->create();
$this->getJson('/api/posts')
->assertOk()
->assertJson(fn (AssertableJson $json) => $json
->has('data', 5)
->has('data.0', fn ($item) =>
$item->hasAll(['id', 'title', 'created_at'])
->missing('body')
)
->has('meta')
->where('meta.total', 5)
);
});---
RefreshDatabase vs DatabaseTransactions
Impact: HIGH (Prevents test pollution and ensures a clean database state)
Use RefreshDatabase for most feature tests. Apply it via uses() per file or globally in Pest.php.
Good Example
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('basic feature test', function () {
$user = User::factory()->create();
expect(User::count())->toBe(1);
});Apply globally in `Pest.php`:
<?php
// tests/Pest.php
uses(
Tests\TestCase::class,
Illuminate\Foundation\Testing\RefreshDatabase::class,
)->in('Feature');---
2. Model Factories (CRITICAL)
Impact: CRITICAL Description: Creating test data with factories, states, sequences, and relationships. Factories are the foundation of reliable, readable test data setup.
Rules in this category: 4
---
Define Factories with Typed Fake Data
Impact: CRITICAL (Provides clean, realistic test data without manual setup)
Define model factories using Faker. Use PHP 8.3 typed return types. Every model used in tests must have a factory.
Good Example
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => Hash::make('password'),
'remember_token' => Str::random(10),
];
}
}Usage:
$user = User::factory()->create(); // persisted
$user = User::factory()->make(); // not persisted
$users = User::factory()->count(5)->create(); // 5 records---
Factory States for Test Scenarios
Impact: HIGH (Eliminates repetitive attribute overrides and makes test scenarios self-documenting)
Define named states for distinct model configurations. Chain states to build complex scenarios.
Good Example
<?php
class UserFactory extends Factory
{
public function definition(): array
{
return [/* ... */];
}
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
public function suspended(): static
{
return $this->state(fn (array $attributes) => [
'status' => 'suspended',
'suspended_at' => now(),
]);
}
public function admin(): static
{
return $this->state(fn (array $attributes) => [
'role' => 'admin',
]);
}
}Usage:
User::factory()->suspended()->create();
User::factory()->admin()->unverified()->create();---
Factory Sequences for Varied Records
Impact: HIGH (Creates realistic varied datasets without repetitive factory calls)
Use sequence() to assign alternating values when creating multiple records.
Good Example
<?php
Post::factory()
->count(4)
->sequence(
['status' => 'published'],
['status' => 'draft'],
['status' => 'published'],
['status' => 'archived'],
)
->create();
// With index for dynamic values
Post::factory()
->count(3)
->sequence(fn (Sequence $sequence) => [
'title' => "Post {$sequence->index}",
'created_at' => now()->subDays($sequence->index),
])
->create();---
Factory Relationship Helpers
Impact: HIGH (Reduces setup boilerplate and correctly wires related models)
Use has(), for(), recycle(), and afterCreating() to set up relationships cleanly.
Good Example
<?php
// BelongsTo
$post = Post::factory()->for($user)->create();
// HasMany
$user = User::factory()
->has(Post::factory()->count(3))
->create();
// Reuse existing model — no extra records
$posts = Post::factory()
->count(5)
->recycle($user)
->create();
// afterCreating() in factory
public function withTags(int $count = 3): static
{
return $this->afterCreating(function (Post $post) use ($count) {
$tags = Tag::factory()->count($count)->create();
$post->tags()->attach($tags);
});
}---
3. Database Assertions (HIGH)
Impact: HIGH Description: Asserting database state after operations to verify persistence and deletion.
Rules in this category: 3
---
assertDatabaseHas and assertModelExists
Impact: HIGH (Verifies that operations correctly persist data to the database)
<?php
// assertDatabaseHas — verify row exists
$this->assertDatabaseHas('posts', [
'title' => 'Hello World',
'user_id' => $user->id,
]);
// assertModelExists — cleaner when you have the instance
$this->assertModelExists($user);
$this->assertModelExists($user->profile);---
assertDatabaseMissing and assertModelMissing
Impact: HIGH (Confirms that delete operations actually remove data)
<?php
// assertModelMissing — after hard delete
$this->actingAs($user)
->deleteJson("/api/posts/{$post->id}")
->assertNoContent();
$this->assertModelMissing($post);
// assertDatabaseMissing — check by attribute
$this->assertDatabaseMissing('tags', ['name' => 'laravel']);
$this->assertDatabaseMissing('post_tag', ['tag_id' => $tag->id]);---
Asserting Soft Deletes
Impact: MEDIUM (Verifies soft-delete behaviour)
<?php
// assertSoftDeleted — row exists but deleted_at is set
$this->actingAs($user)
->deleteJson("/api/posts/{$post->id}")
->assertNoContent();
$this->assertSoftDeleted($post);
// assertNotSoftDeleted — after restore
$this->assertNotSoftDeleted($post);
// trashed() factory state — pre-soft-deleted record
$post = Post::factory()->trashed()->create();
// Verify trashed posts excluded from feed
Post::factory()->count(2)->create();
Post::factory()->trashed()->create();
$this->getJson('/api/feed')
->assertJsonCount(2, 'data');---
4. Faking Services (HIGH)
Impact: HIGH Description: Faking Mail, Queue, Notification, and Event facades to prevent real side effects and assert dispatch behaviour.
Rules in this category: 4
---
Faking Mail with Mail::fake()
Impact: HIGH (Prevents real emails from being sent and enables assertion on mail behaviour)
<?php
use App\Mail\OrderShipped;
use Illuminate\Support\Facades\Mail;
test('order confirmation is emailed to buyer', function () {
Mail::fake();
$user = User::factory()->create();
$this->actingAs($user)->postJson('/api/orders', ['product_id' => 1]);
Mail::assertSent(OrderShipped::class);
Mail::assertSent(OrderShipped::class, $user->email);
// Mail::assertNothingSent(); — use on non-triggering actions
});
// Inspect mailable attributes
Mail::assertSent(OrderShipped::class, fn (OrderShipped $mail) =>
$mail->order->number === 'ORD-999' &&
$mail->hasTo($user->email)
);Key methods: assertSent, assertNotSent, assertNothingSent, assertSentTimes, assertSentCount
---
Faking Queues with Queue::fake()
Impact: HIGH (Prevents real job execution and enables assertion on dispatch behaviour)
<?php
use App\Jobs\ShipOrder;
use Illuminate\Support\Facades\Queue;
test('placing order dispatches ship job', function () {
Queue::fake();
$this->actingAs($user)->postJson('/api/orders', ['product_id' => 1]);
Queue::assertPushed(ShipOrder::class);
Queue::assertPushedOn('billing', SendInvoice::class);
// Queue::assertNothingPushed(); — use on read operations
});
// Inspect job properties
Queue::assertPushed(ShipOrder::class, fn (ShipOrder $job) =>
$job->order->id === $order->id
);Key methods: assertPushed, assertPushedOn, assertPushedTimes, assertNotPushed, assertNothingPushed, assertCount
---
Faking Notifications with Notification::fake()
Impact: HIGH (Prevents real notifications from being sent)
<?php
use App\Notifications\OrderShipped;
use Illuminate\Support\Facades\Notification;
test('user is notified when order ships', function () {
Notification::fake();
$order->ship();
Notification::assertSentTo($user, OrderShipped::class);
Notification::assertNotSentTo($otherUser, OrderShipped::class);
// Notification::assertNothingSent(); — use on non-triggering actions
});
// Inspect notification payload
Notification::assertSentTo(
$user,
OrderShipped::class,
fn (OrderShipped $notification) => $notification->order->number === 'ORD-001'
);Key methods: assertSentTo, assertNotSentTo, assertNothingSent, assertSentTimes, assertCount
---
Faking Events with Event::fake()
Impact: HIGH (Isolates event dispatch from listener side effects)
<?php
use App\Events\OrderShipped;
use Illuminate\Support\Facades\Event;
test('shipping dispatches OrderShipped event', function () {
Event::fake();
$order->ship();
Event::assertDispatched(OrderShipped::class);
Event::assertDispatchedOnce(OrderShipped::class);
// Event::assertNothingDispatched(); — use on read operations
});
// Inspect event payload
Event::assertDispatched(
OrderShipped::class,
fn (OrderShipped $event) => $event->order->number === 'ORD-123'
);Key methods: assertDispatched, assertDispatchedOnce, assertNotDispatched, assertNothingDispatched
---
Faking Storage with Storage::fake()
Impact: HIGH (Prevents real file writes during tests and enables assertion on file upload behaviour)
<?php
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
test('user can upload an avatar', function () {
Storage::fake('avatars');
$user = User::factory()->create();
$file = UploadedFile::fake()->image('avatar.jpg', 200, 200);
$this->actingAs($user)
->postJson('/api/avatar', ['avatar' => $file])
->assertOk();
Storage::disk('avatars')->assertExists("users/{$user->id}/avatar.jpg");
});
// Assert file is not stored when upload is rejected
test('oversized image is rejected', function () {
Storage::fake('avatars');
$user = User::factory()->create();
$file = UploadedFile::fake()->image('big.jpg')->size(5001); // exceeds limit
$this->actingAs($user)
->postJson('/api/avatar', ['avatar' => $file])
->assertUnprocessable();
Storage::disk('avatars')->assertMissing("users/{$user->id}/big.jpg");
});UploadedFile helpers:
UploadedFile::fake()->image('photo.jpg') // JPEG image
UploadedFile::fake()->image('photo.png', 400, 300) // with dimensions
UploadedFile::fake()->create('doc.pdf', 1024) // 1024 KB file
UploadedFile::fake()->create('doc.pdf', 1024, 'application/pdf') // with MIMEKey assertions: assertExists($path), assertMissing($path), assertDirectoryEmpty($dir)
---
5. Authentication Testing (HIGH)
Impact: HIGH Description: Testing authenticated routes with actingAs() and Sanctum::actingAs().
Rules in this category: 2
---
Authentication Testing with actingAs()
Impact: HIGH (Enables correct testing of authenticated and role-restricted routes)
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// Authenticated
test('user can access dashboard', function () {
$user = User::factory()->create();
$this->actingAs($user)->get('/dashboard')->assertOk();
});
// Always test the guest path too
test('guest is redirected to login', function () {
$this->get('/dashboard')->assertRedirect(route('login'));
});
// Policy: owner can, others cannot
test('user cannot edit another users post', function () {
$owner = User::factory()->create();
$other = User::factory()->create();
$post = Post::factory()->for($owner)->create();
$this->actingAs($other)
->patchJson("/api/posts/{$post->id}", ['title' => 'Hijacked'])
->assertForbidden();
});---
API Authentication Testing with Sanctum
Impact: HIGH (Enables correct testing of token-authenticated API endpoints)
<?php
use Laravel\Sanctum\Sanctum;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// Basic Sanctum auth
test('authenticated user can get profile', function () {
$user = User::factory()->create();
Sanctum::actingAs($user);
$this->getJson('/api/user')->assertOk();
});
// With specific abilities
test('read-only token cannot create', function () {
$user = User::factory()->create();
Sanctum::actingAs($user, ['posts:read']);
$this->postJson('/api/posts', ['title' => 'Hello'])
->assertForbidden();
});
// Unauthenticated
test('missing token returns 401', function () {
$this->getJson('/api/user')->assertUnauthorized();
});---
6. Test Organisation Patterns (MEDIUM)
Impact: MEDIUM Description: Pest (describe/it/datasets/beforeEach) and PHPUnit (test classes/dataProvider/setUp) patterns for readable, organised, and maintainable test files.
Rules in this category: 3
---
Organise Tests with describe() and it()
Impact: MEDIUM (Groups related tests and produces readable test output)
<?php
uses(RefreshDatabase::class);
describe('PostController', function () {
describe('store', function () {
it('creates a post when authenticated', function () {
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/posts', ['title' => 'Hello', 'body' => 'World'])
->assertStatus(201);
});
it('returns 422 when title is missing', function () {
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/posts', ['body' => 'No title'])
->assertUnprocessable()
->assertJsonValidationErrors(['title']);
});
it('returns 401 for guests', function () {
$this->postJson('/api/posts', ['title' => 'Hello'])
->assertUnauthorized();
});
});
describe('destroy', function () {
it('deletes own post', function () {
$user = User::factory()->create();
$post = Post::factory()->for($user)->create();
$this->actingAs($user)->deleteJson("/api/posts/{$post->id}")->assertNoContent();
$this->assertModelMissing($post);
});
it('returns 403 for others posts', function () {
$owner = User::factory()->create();
$other = User::factory()->create();
$post = Post::factory()->for($owner)->create();
$this->actingAs($other)->deleteJson("/api/posts/{$post->id}")->assertForbidden();
});
});
});---
Parameterised Testing with Pest Datasets
Impact: MEDIUM (Replaces repeated tests with a single parameterised test)
<?php
uses(RefreshDatabase::class);
it('rejects invalid post titles', function (mixed $title) {
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/posts', ['title' => $title, 'body' => 'Content'])
->assertUnprocessable()
->assertJsonValidationErrors(['title']);
})->with([
'empty string' => [''],
'null value' => [null],
'too long (256 chars)' => [str_repeat('a', 256)],
]);
// Named dataset — reusable across tests
dataset('invalid_statuses', [
'draft' => ['draft'],
'archived' => ['archived'],
]);
it('rejects invalid status', function (string $status) {
$this->patchJson('/api/posts/1', ['status' => $status])
->assertUnprocessable();
})->with('invalid_statuses');---
Lifecycle Hooks with beforeEach, afterEach, and uses()
Impact: MEDIUM (Eliminates repeated setup code)
<?php
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
Mail::fake();
});
test('user can create post', function () {
$this->actingAs($this->user)
->postJson('/api/posts', ['title' => 'Hello', 'body' => 'World'])
->assertStatus(201);
});
test('user can delete post', function () {
$post = Post::factory()->for($this->user)->create();
$this->actingAs($this->user)
->deleteJson("/api/posts/{$post->id}")
->assertNoContent();
$this->assertModelMissing($post);
});Global setup in `Pest.php`:
<?php
// tests/Pest.php
uses(
Tests\TestCase::class,
Illuminate\Foundation\Testing\RefreshDatabase::class,
)->in('Feature');
uses(Tests\TestCase::class)->in('Unit');---
title: Fake AI Agent Responses impact: HIGH impactDescription: Test agent interactions without real API calls tags: testing, ai, agent, fake, assert, laravel-ai-sdk ---
Fake AI Agent Responses
Impact: HIGH (Test agent interactions without real API calls)
Use Agent::fake() to prevent real AI provider calls in tests. Fake responses can be static strings, arrays, or dynamic closures. Assert that agents were prompted with expected inputs.
Bad Example — Pest
<?php
use App\Ai\Agents\SalesCoach;
test('coach analyzes transcript', function () {
// Real API call — slow, expensive, non-deterministic
$response = SalesCoach::make()->prompt('Analyze this...');
// Different response every run, costs money, requires API key in CI
expect((string) $response)->toContain('feedback');
});Good Example — Pest
<?php
use App\Ai\Agents\SalesCoach;
use Laravel\Ai\Prompts\AgentPrompt;
test('coach analyzes transcript', function () {
SalesCoach::fake(['Great job on the opening.']);
$response = SalesCoach::make()->prompt('Analyze this transcript...');
expect((string) $response)->toBe('Great job on the opening.');
SalesCoach::assertPrompted('Analyze this transcript...');
});
test('coach responds based on prompt content', function () {
SalesCoach::fake(function (AgentPrompt $prompt) {
return 'Response for: ' . $prompt->prompt;
});
$response = SalesCoach::make()->prompt('Q4 sales data');
SalesCoach::assertPrompted(fn (AgentPrompt $prompt) => $prompt->contains('Q4'));
SalesCoach::assertNotPrompted('Missing prompt');
});
test('no unexpected agent calls', function () {
SalesCoach::fake()->preventStrayPrompts();
SalesCoach::make()->prompt('Expected call');
// Any unfaked agent call would throw
});Good Example — PHPUnit
<?php
namespace Tests\Feature;
use App\Ai\Agents\SalesCoach;
use Laravel\Ai\Prompts\AgentPrompt;
use Tests\TestCase;
class SalesCoachTest extends TestCase
{
public function test_coach_analyzes_transcript(): void
{
SalesCoach::fake(['Great job on the opening.']);
$response = SalesCoach::make()->prompt('Analyze this transcript...');
$this->assertEquals('Great job on the opening.', (string) $response);
SalesCoach::assertPrompted('Analyze this transcript...');
}
public function test_no_unexpected_agent_calls(): void
{
SalesCoach::fake()->preventStrayPrompts();
SalesCoach::make()->prompt('Expected call');
}
}Structured output agents auto-generate fake data matching the schema — no manual setup needed.
Why It Matters
- Fast: No API calls — tests run in milliseconds
- Deterministic: Same response every run
- Free: No API costs in CI/CD pipelines
- Safe:
preventStrayPrompts()catches accidental real calls
Reference: Laravel AI SDK — Testing Agents
---
title: Fake AI Image, Audio, and Transcription impact: HIGH impactDescription: Test media generation without real API calls tags: testing, ai, image, audio, transcription, fake, assert, laravel-ai-sdk ---
Fake AI Image, Audio, and Transcription
Impact: HIGH (Test media generation without real API calls)
Use Image::fake(), Audio::fake(), and Transcription::fake() to prevent real generation calls. Assert prompts, aspect ratios, voices, and diarization.
Bad Example — Pest
<?php
use Laravel\Ai\Image;
test('generates product image', function () {
// Real API call — slow, expensive, different image every run
$image = Image::of('Product photo')->landscape()->generate();
expect((string) $image)->not->toBeEmpty();
});Good Example — Pest
<?php
use Laravel\Ai\Audio;
use Laravel\Ai\Image;
use Laravel\Ai\Transcription;
use Laravel\Ai\Prompts\AudioPrompt;
use Laravel\Ai\Prompts\ImagePrompt;
use Laravel\Ai\Prompts\TranscriptionPrompt;
test('generates landscape product image', function () {
Image::fake();
Image::of('A sunset over the ocean')->landscape()->generate();
Image::assertGenerated(function (ImagePrompt $prompt) {
return $prompt->contains('sunset') && $prompt->isLandscape();
});
});
test('generates welcome audio', function () {
Audio::fake();
Audio::of('Welcome to our app.')->female()->generate();
Audio::assertGenerated(function (AudioPrompt $prompt) {
return $prompt->contains('Welcome') && $prompt->isFemale();
});
});
test('transcribes uploaded audio with diarization', function () {
Transcription::fake(['Meeting transcript text here.']);
$transcript = Transcription::fromStorage('meeting.mp3')
->diarize()
->generate();
expect((string) $transcript)->toBe('Meeting transcript text here.');
Transcription::assertGenerated(function (TranscriptionPrompt $prompt) {
return $prompt->isDiarized();
});
});
test('no unexpected media generation', function () {
Image::fake()->preventStrayImages();
Audio::fake()->preventStrayAudio();
Transcription::fake()->preventStrayTranscriptions();
});Good Example — PHPUnit
<?php
namespace Tests\Feature;
use Laravel\Ai\Image;
use Laravel\Ai\Prompts\ImagePrompt;
use Tests\TestCase;
class ImageGenerationTest extends TestCase
{
public function test_generates_landscape_product_image(): void
{
Image::fake();
Image::of('A sunset over the ocean')->landscape()->generate();
Image::assertGenerated(function (ImagePrompt $prompt) {
return $prompt->contains('sunset') && $prompt->isLandscape();
});
}
}Why It Matters
- Fast: No image/audio generation — tests complete in milliseconds
- Deterministic: Controlled fake responses every run
- Assertable: Verify prompts, aspect ratios, voices, and diarization settings
- Prevent leaks:
preventStray*()methods catch accidental real API calls
Reference: Laravel AI SDK — Testing
---
title: Fake AI Embeddings, Reranking, Files, and Vector Stores impact: HIGH impactDescription: Test AI data operations without real API calls tags: testing, ai, embeddings, reranking, files, vector-stores, fake, assert, laravel-ai-sdk ---
Fake AI Embeddings, Reranking, Files, and Vector Stores
Impact: HIGH (Test AI data operations without real API calls)
Use Embeddings::fake(), Reranking::fake(), Files::fake(), and Stores::fake() to test data operations without provider connections.
Bad Example — Pest
<?php
use Illuminate\Support\Str;
test('stores document with embedding', function () {
// Real API call to generate embedding — slow, costs money
$embedding = Str::of($content)->toEmbeddings();
Document::create(['content' => $content, 'embedding' => $embedding]);
});Good Example — Pest
<?php
use Laravel\Ai\Embeddings;
use Laravel\Ai\Files;
use Laravel\Ai\Files\Document;
use Laravel\Ai\Reranking;
use Laravel\Ai\Stores;
use Laravel\Ai\Contracts\Files\StorableFile;
use Laravel\Ai\Prompts\EmbeddingsPrompt;
use Laravel\Ai\Prompts\RerankingPrompt;
test('generates document embeddings', function () {
Embeddings::fake();
Embeddings::for(['Laravel is great.'])->generate();
Embeddings::assertGenerated(function (EmbeddingsPrompt $prompt) {
return $prompt->contains('Laravel');
});
});
test('reranks search results', function () {
Reranking::fake();
Reranking::of(['Doc A', 'Doc B'])->rerank('query');
Reranking::assertReranked(function (RerankingPrompt $prompt) {
return $prompt->contains('query');
});
});
test('stores document with provider', function () {
Files::fake();
Document::fromString('Hello, Laravel!', mimeType: 'text/plain')
->as('hello.txt')
->put();
Files::assertStored(fn (StorableFile $file) =>
(string) $file === 'Hello, Laravel!'
);
});
test('creates vector store and adds files', function () {
Stores::fake(); // Also fakes file operations
$store = Stores::create('Knowledge Base');
Stores::assertCreated('Knowledge Base');
$store->add(Document::fromString('Content', 'text/plain')->as('doc.txt'));
$store->assertAdded(fn (StorableFile $file) => $file->name() === 'doc.txt');
});
test('no unexpected data operations', function () {
Embeddings::fake()->preventStrayEmbeddings();
Files::fake();
Stores::fake();
});Good Example — PHPUnit
<?php
namespace Tests\Feature;
use Laravel\Ai\Embeddings;
use Laravel\Ai\Prompts\EmbeddingsPrompt;
use Tests\TestCase;
class EmbeddingTest extends TestCase
{
public function test_generates_document_embeddings(): void
{
Embeddings::fake();
Embeddings::for(['Laravel is great.'])->generate();
Embeddings::assertGenerated(function (EmbeddingsPrompt $prompt) {
return $prompt->contains('Laravel');
});
}
}Why It Matters
- Fast: No embedding API calls or provider connections
- Deterministic:
Embeddings::fake()auto-generates vectors of proper dimensions - Comprehensive:
Stores::fake()also fakes file operations automatically - Assertable: Verify what was generated, stored, added, and removed
Reference: Laravel AI SDK — Testing
---
How to Use This Guide
1. For AI Agents: Reference specific rules by category and rule name when generating or reviewing test code 2. For Developers: Use as a comprehensive reference for Laravel 13 testing best practices 3. For Code Review: Check test implementations against these patterns 4. For CI/CD: Ensure all AI SDK features are properly faked in test suites
{
"version": "1.1.0",
"organization": "Laravel Community",
"date": "March 2026",
"laravelVersion": "13.x",
"phpVersion": "8.3+",
"pestVersion": "4.x",
"phpunitVersion": "12.x",
"rulesTotal": 24,
"abstract": "Comprehensive Laravel 13 testing guide for AI agents and LLMs, supporting both Pest PHP 4 and PHPUnit 12. Contains 21 rules across 6 categories covering HTTP feature tests, model factories, database assertions, facade faking, authentication testing, and test organisation patterns. Includes framework detection logic: checks composer.json and tests/Pest.php to determine the framework, asks the user if unclear. Framework-specific rules (describe/it, datasets, hooks) show both Pest and PHPUnit syntax side by side.",
"references": [
"https://laravel.com/docs/13.x/testing",
"https://laravel.com/docs/13.x/http-tests",
"https://laravel.com/docs/13.x/database-testing",
"https://laravel.com/docs/13.x/eloquent-factories",
"https://laravel.com/docs/13.x/mocking",
"https://laravel.com/docs/13.x/notifications",
"https://laravel.com/docs/13.x/queues",
"https://laravel.com/docs/13.x/mail",
"https://laravel.com/docs/13.x/console-tests",
"https://pestphp.com/docs/writing-tests",
"https://pestphp.com/docs/datasets",
"https://docs.phpunit.de/en/12.0/writing-tests-for-phpunit.html"
],
"categories": [
{
"name": "HTTP & Feature Tests",
"prefix": "http",
"impact": "CRITICAL",
"description": "Feature test structure, HTTP assertions, and response validation"
},
{
"name": "Model Factories",
"prefix": "factory",
"impact": "CRITICAL",
"description": "Creating test data with factories, states, sequences, and relationships"
},
{
"name": "Database Assertions",
"prefix": "db",
"impact": "HIGH",
"description": "Asserting database state after operations"
},
{
"name": "Faking Services",
"prefix": "fake",
"impact": "HIGH",
"description": "Faking Mail, Queue, Notification, and Event facades in tests"
},
{
"name": "Authentication Testing",
"prefix": "auth",
"impact": "HIGH",
"description": "Testing authenticated routes with actingAs and Sanctum"
},
{
"name": "Test Organisation Patterns",
"prefix": "pest",
"impact": "MEDIUM",
"description": "Pest-specific patterns: describe/it, datasets, and lifecycle hooks"
}
],
"keyFeatures": [
"Test declaration in both Pest (test/it/describe) and PHPUnit (test methods/classes) syntax",
"RefreshDatabase and DatabaseTransactions trait guidance",
"Model factory states, sequences, and relationship helpers",
"Full facade faking: Mail, Queue, Notification, Event, Storage",
"actingAs() and Sanctum::actingAs() for authentication",
"assertJson with fluent AssertableJson closures",
"Parameterized testing with Pest datasets",
"Modern PHP 8.3 syntax in all examples",
"AI SDK faking: Agent::fake(), Image::fake(), Audio::fake(), Embeddings::fake()"
]
}
Laravel 13 Testing — Pest PHP 4 & PHPUnit 12
Comprehensive testing guide for Laravel 13 applications. Supports both Pest PHP 4 and PHPUnit 12. 24 rules across 6 categories.
Framework Detection
Before applying rules, the skill detects which framework is in use: 1. Checks composer.json for pestphp/pest (Pest) or phpunit/phpunit alone (PHPUnit) 2. Checks if tests/Pest.php exists → Pest 3. If unclear → asks the user to choose Pest or PHPUnit
Version: 1.1.0
Overview
This skill provides guidance for:
- HTTP feature tests and response assertions
- Model factories, states, and relationships
- Database assertions after operations
- Faking Mail, Queue, Notification, and Event facades
- Testing authenticated routes with actingAs and Sanctum
- Pest PHP patterns: describe/it, datasets, hooks
Categories
1. HTTP & Feature Tests (Critical)
Structure feature tests with Arrange/Act/Assert. Use HTTP assertion methods to validate responses.
2. Model Factories (Critical)
Create test data with factories. Use states for scenarios, sequences for varied records, relationship helpers for related data.
3. Database Assertions (High)
Assert database state with assertDatabaseHas, assertDatabaseMissing, and assertSoftDeleted.
4. Faking Services (High)
Use Mail::fake(), Queue::fake(), Notification::fake(), Event::fake(), and Storage::fake() to prevent real side effects and assert behavior.
5. Authentication Testing (High)
Use actingAs() for session-based tests and Sanctum::actingAs() for API token tests.
6. Test Organisation Patterns (Medium)
Pest: describe/it blocks, datasets, beforeEach/afterEach hooks. PHPUnit: test class organisation, #[DataProvider], setUp/tearDown.
Rules
| Rule | Category | Impact |
|---|---|---|
http-test-structure | HTTP & Feature Tests | CRITICAL |
http-assert-response | HTTP & Feature Tests | CRITICAL |
http-assert-json-fluent | HTTP & Feature Tests | HIGH |
http-refresh-database | HTTP & Feature Tests | HIGH |
factory-define | Model Factories | CRITICAL |
factory-states | Model Factories | HIGH |
factory-sequences | Model Factories | HIGH |
factory-relationships | Model Factories | HIGH |
db-assert-has | Database Assertions | HIGH |
db-assert-missing | Database Assertions | HIGH |
db-assert-soft-deletes | Database Assertions | MEDIUM |
fake-mail | Faking Services | HIGH |
fake-queue | Faking Services | HIGH |
fake-notification | Faking Services | HIGH |
fake-event | Faking Services | HIGH |
fake-storage | Faking Services | HIGH |
fake-ai-agent | Faking Services | HIGH |
fake-ai-media | Faking Services | HIGH |
fake-ai-data | Faking Services | HIGH |
auth-acting-as | Authentication Testing | HIGH |
auth-sanctum | Authentication Testing | HIGH |
pest-describe-it | Test Organisation Patterns | MEDIUM |
pest-datasets | Test Organisation Patterns | MEDIUM |
pest-hooks | Test Organisation Patterns | MEDIUM |
Authentication Testing with actingAs()
Impact: HIGH (Enables correct testing of authenticated and role-restricted routes)
Use actingAs($user) to authenticate a user for the duration of a test. This sets session state correctly so middleware, policies, and gates evaluate as expected. Always test both authenticated and unauthenticated paths for protected routes.
Bad Example
<?php
// Manually sets session — bypasses auth middleware, incorrect for feature tests
test('user can access dashboard', function () {
$user = User::factory()->create();
session(['user_id' => $user->id]); // bypasses Laravel auth system
$this->get('/dashboard')->assertOk();
});
// Forgets to test the unauthenticated case
test('posts can be created', function () {
$this->postJson('/api/posts', ['title' => 'Hello'])
->assertStatus(201); // passes only if route has no auth middleware
});Good Example
<?php
use App\Models\User;
use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// Authenticated request
test('authenticated user can access dashboard', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get('/dashboard')
->assertOk();
});
// Always test the unauthenticated path
test('guest is redirected from dashboard', function () {
$this->get('/dashboard')
->assertRedirect(route('login'));
});
// Test with specific guard (e.g., admin guard)
test('admin can access admin panel', function () {
$admin = User::factory()->admin()->create();
$this->actingAs($admin, 'web')
->get('/admin')
->assertOk();
});
// Test policy — owner can edit, others cannot
test('user can edit their own post', function () {
$user = User::factory()->create();
$post = Post::factory()->for($user)->create();
$this->actingAs($user)
->patchJson("/api/posts/{$post->id}", ['title' => 'Updated'])
->assertOk();
});
test('user cannot edit another users post', function () {
$owner = User::factory()->create();
$other = User::factory()->create();
$post = Post::factory()->for($owner)->create();
$this->actingAs($other)
->patchJson("/api/posts/{$post->id}", ['title' => 'Hijacked'])
->assertForbidden();
});
// Combine actingAs with withSession for additional state
test('authenticated user with session flash can see message', function () {
$user = User::factory()->create();
$this->actingAs($user)
->withSession(['status' => 'verified'])
->get('/dashboard')
->assertOk()
->assertSessionHas('status', 'verified');
});Why It Matters
- Correct auth flow:
actingAsuses real Laravel auth — middleware, gates, and policies evaluate properly - Comprehensive: Test both authenticated and guest paths to catch missing middleware
- Policy testing: The only reliable way to test policies is through full HTTP requests with
actingAs
Reference: Laravel HTTP Tests — Authentication
API Authentication Testing with Sanctum
Impact: HIGH (Enables correct testing of token-authenticated API endpoints)
Use Sanctum::actingAs($user) to authenticate API requests with a token in tests. Pass an array of abilities to test scope-restricted endpoints. Always test both valid and missing token paths for protected API routes.
Bad Example
<?php
// Uses session-based actingAs() for an API route protected by Sanctum
// — Sanctum middleware checks for Bearer token, not session
test('user can get their profile', function () {
$user = User::factory()->create();
$this->actingAs($user) // wrong guard for Sanctum API
->getJson('/api/user')
->assertOk(); // may work locally but fails with stateless middleware
});
// Hard-codes a token string — fragile and not how Sanctum works in tests
test('api request with token', function () {
$user = User::factory()->create();
$token = $user->createToken('test')->plainTextToken;
$this->withHeader('Authorization', "Bearer {$token}")
->getJson('/api/user')
->assertOk();
// Verbose — Sanctum::actingAs() is simpler and correct
});Good Example
<?php
use App\Models\User;
use App\Models\Post;
use Laravel\Sanctum\Sanctum;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// Basic Sanctum authentication
test('authenticated user can retrieve their profile', function () {
$user = User::factory()->create();
Sanctum::actingAs($user);
$this->getJson('/api/user')
->assertOk()
->assertJsonPath('data.email', $user->email);
});
// Test with specific token abilities (scopes)
test('token with read ability can list posts', function () {
$user = User::factory()->create();
Sanctum::actingAs($user, ['posts:read']);
$this->getJson('/api/posts')->assertOk();
});
test('token without write ability cannot create posts', function () {
$user = User::factory()->create();
Sanctum::actingAs($user, ['posts:read']); // no write ability
$this->postJson('/api/posts', ['title' => 'Hello'])
->assertForbidden();
});
// Wildcard abilities — acts as if all abilities are granted
test('admin token with all abilities can delete any post', function () {
$admin = User::factory()->admin()->create();
$post = Post::factory()->create();
Sanctum::actingAs($admin, ['*']);
$this->deleteJson("/api/posts/{$post->id}")
->assertNoContent();
});
// Unauthenticated request returns 401
test('unauthenticated request to api returns 401', function () {
$this->getJson('/api/user')
->assertUnauthorized();
});
// Test ownership restriction on an API resource
test('user cannot update another users post via api', function () {
$owner = User::factory()->create();
$other = User::factory()->create();
$post = Post::factory()->for($owner)->create();
Sanctum::actingAs($other, ['posts:write']);
$this->patchJson("/api/posts/{$post->id}", ['title' => 'Hijacked'])
->assertForbidden();
});Why It Matters
- Correct guard:
Sanctum::actingAs()targets thesanctumguard — no session pollution - Ability testing: Pass specific abilities to test scope restrictions without creating real tokens
- Stateless: Works correctly with
EnsureFrontendRequestsAreStatefulexcluded from API routes
Reference: Laravel Sanctum — Testing
assertDatabaseHas and assertModelExists
Impact: HIGH (Verifies that operations correctly persist data to the database)
Use assertDatabaseHas() to verify that a table row matching given key/value pairs exists after an operation. Use assertModelExists() when you have an Eloquent model instance. Avoid re-fetching the model just to check it exists.
Bad Example
<?php
// Re-fetches from DB and accesses attributes — noisy and fragile
test('post is created', function () {
$user = User::factory()->create();
$this->actingAs($user)->postJson('/api/posts', [
'title' => 'Hello',
'body' => 'World',
]);
$post = Post::first(); // assumes only one post in DB — brittle
expect($post)->not->toBeNull();
expect($post->title)->toBe('Hello');
expect($post->user_id)->toBe($user->id);
});
// Checks only the response, not persistence
test('user is registered', function () {
$this->postJson('/api/register', [
'name' => 'Alice',
'email' => 'alice@example.com',
]);
// Never checks the DB — response could be mocked or cached
});Good Example
<?php
use App\Models\User;
use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// assertDatabaseHas — verify row exists with matching columns
test('creating a post persists it to the database', function () {
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/posts', [
'title' => 'Hello World',
'body' => 'Some content.',
])
->assertStatus(201);
$this->assertDatabaseHas('posts', [
'title' => 'Hello World',
'user_id' => $user->id,
]);
});
// assertModelExists — cleaner when you have the model instance
test('profile is created when user registers', function () {
$this->postJson('/api/register', [
'name' => 'Alice',
'email' => 'alice@example.com',
'password' => 'secret123',
'password_confirmation' => 'secret123',
]);
$user = User::whereEmail('alice@example.com')->firstOrFail();
$this->assertModelExists($user);
$this->assertModelExists($user->profile); // related model also created
});
// Combine response and DB assertion
test('updating a post changes both response and database', function () {
$user = User::factory()->create();
$post = Post::factory()->for($user)->create(['title' => 'Old Title']);
$this->actingAs($user)
->patchJson("/api/posts/{$post->id}", ['title' => 'New Title'])
->assertOk()
->assertJsonPath('data.title', 'New Title');
$this->assertDatabaseHas('posts', [
'id' => $post->id,
'title' => 'New Title',
]);
});Why It Matters
- Persistence verified: HTTP response alone doesn't confirm the DB was written — both must be checked
- Targeted:
assertDatabaseHaschecks only the relevant columns, ignoring others - Clear intent:
assertModelExists($model)reads naturally compared to manualfind()
Reference: Laravel Database Testing — Assertions
assertDatabaseMissing and assertModelMissing
Impact: HIGH (Confirms that delete operations actually remove data from the database)
Use assertDatabaseMissing() to verify that no row matching given constraints exists in a table. Use assertModelMissing() when you have the model instance. Always pair a delete operation with a missing assertion — a 200/204 response alone does not confirm the record was removed.
Bad Example
<?php
// Only checks HTTP status — not whether the DB row was removed
test('post is deleted', function () {
$user = User::factory()->create();
$post = Post::factory()->for($user)->create();
$this->actingAs($user)
->deleteJson("/api/posts/{$post->id}")
->assertNoContent(); // passes even if delete logic has a bug and does nothing
});
// Queries the DB wrong way — assumes first() is the deleted model
test('user is removed', function () {
$user = User::factory()->create();
$this->delete("/admin/users/{$user->id}");
expect(User::first())->toBeNull(); // fails if other users exist in DB
});Good Example
<?php
use App\Models\User;
use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// assertModelMissing — clearest when you have the instance
test('deleting a post removes it from the database', function () {
$user = User::factory()->create();
$post = Post::factory()->for($user)->create();
$this->actingAs($user)
->deleteJson("/api/posts/{$post->id}")
->assertNoContent();
$this->assertModelMissing($post);
});
// assertDatabaseMissing — when checking by attribute value
test('removing a tag detaches it from posts', function () {
$tag = Tag::factory()->create(['name' => 'laravel']);
$post = Post::factory()->create();
$post->tags()->attach($tag);
$this->deleteJson("/api/tags/{$tag->id}")
->assertNoContent();
$this->assertDatabaseMissing('tags', ['name' => 'laravel']);
$this->assertDatabaseMissing('post_tag', ['tag_id' => $tag->id]);
});
// Assert both the model and pivot records are gone
test('deleting user removes their posts', function () {
$user = User::factory()->create();
$posts = Post::factory()->for($user)->count(3)->create();
$this->actingAs(User::factory()->admin()->create())
->deleteJson("/api/admin/users/{$user->id}")
->assertNoContent();
$this->assertModelMissing($user);
foreach ($posts as $post) {
$this->assertModelMissing($post);
}
});Why It Matters
- Behaviour verified: A
204 No Contentalone says nothing about what changed in the DB - Cascade check:
assertDatabaseMissingverifies pivot/related records are also cleaned up - Explicit intent:
assertModelMissingclearly communicates what the test is verifying
Asserting Soft Deletes
Impact: MEDIUM (Verifies soft-delete behaviour without accidentally treating records as hard-deleted)
Use assertSoftDeleted() to confirm a record has a non-null deleted_at timestamp while still existing in the database. Use assertNotSoftDeleted() to assert a model is active. Use the built-in trashed() factory state to generate pre-soft-deleted records for restore tests.
Bad Example
<?php
// assertModelMissing is wrong for soft deletes — the row still exists
test('post is soft deleted', function () {
$user = User::factory()->create();
$post = Post::factory()->for($user)->create();
$this->actingAs($user)->deleteJson("/api/posts/{$post->id}");
$this->assertModelMissing($post); // WRONG: the row is still there with deleted_at set
});
// Manually checking deleted_at is verbose and bypasses built-in helpers
test('post has deleted_at', function () {
$post = Post::factory()->create();
$post->delete();
$fresh = Post::withTrashed()->find($post->id);
expect($fresh->deleted_at)->not->toBeNull(); // verbose
});Good Example
<?php
use App\Models\User;
use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// assertSoftDeleted — confirms deleted_at is set, row still exists in DB
test('deleting a post soft-deletes it', function () {
$user = User::factory()->create();
$post = Post::factory()->for($user)->create();
$this->actingAs($user)
->deleteJson("/api/posts/{$post->id}")
->assertNoContent();
$this->assertSoftDeleted($post);
});
// assertNotSoftDeleted — verify active record is not accidentally trashed
test('restoring a post removes deleted_at', function () {
$user = User::factory()->create();
$post = Post::factory()->for($user)->trashed()->create();
$this->actingAs($user)
->postJson("/api/posts/{$post->id}/restore")
->assertOk();
$this->assertNotSoftDeleted($post);
});
// trashed() factory state — create pre-soft-deleted records
test('trashed posts do not appear in feed', function () {
$user = User::factory()->create();
Post::factory()->for($user)->count(2)->create(); // active
Post::factory()->for($user)->count(1)->trashed()->create(); // soft-deleted
$this->actingAs($user)
->getJson('/api/feed')
->assertJsonCount(2, 'data'); // trashed post excluded
});
// assertSoftDeleted with table name and attributes
test('membership is soft deleted on cancellation', function () {
$membership = Membership::factory()->create();
$this->postJson("/api/memberships/{$membership->id}/cancel")
->assertNoContent();
$this->assertSoftDeleted('memberships', ['id' => $membership->id]);
});Why It Matters
- Correctness:
assertModelMissingpasses only if the row is gone — wrong for soft deletes - Built-in:
trashed()factory state is available on all models withSoftDeletesautomatically - Completeness: Tests both the delete (soft) and restore paths
Reference: Laravel Database Testing — assertSoftDeleted
Define Factories with Typed Fake Data
Impact: CRITICAL (Provides clean, realistic test data without manual setup)
Define model factories using Faker to generate realistic typed attributes. Use PHP 8.3 typed properties in the factory class. Every model that appears in tests should have a factory — raw DB::table()->insert() calls are fragile, bypass model events and casts, and duplicate schema knowledge across tests.
Bad Example
<?php
// Raw DB insert — bypasses model events, casts, and observers
test('user profile is created', function () {
DB::table('users')->insert([
'name' => 'Test User',
'email' => 'test@test.com',
'password' => 'password', // plaintext — not hashed
'created_at' => now(),
'updated_at' => now(),
]);
$user = User::first();
expect($user->profile)->not->toBeNull(); // profile observer never fired
});
// Factory without types and with hardcoded values
class UserFactory extends Factory
{
public function definition() // missing return type
{
return [
'name' => 'John', // hardcoded — collisions in tests
'email' => 'john@a.com',// always the same — unique constraint fails
];
}
}Good Example
<?php
namespace Database\Factories;
use App\Enums\UserRole;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => Hash::make('password'), // consistent test password
'remember_token' => Str::random(10),
'role' => UserRole::Member,
];
}
}Using the factory in tests:
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// Create a persisted record
test('user has a profile', function () {
$user = User::factory()->create();
expect($user->profile)->not->toBeNull();
});
// Make without persisting (unit tests, no DB needed)
test('user full name is formatted', function () {
$user = User::factory()->make(['name' => 'Alice Smith']);
expect($user->full_name)->toBe('Alice Smith');
});
// Create multiple records
test('index returns all users', function () {
User::factory()->count(5)->create();
$this->getJson('/api/users')
->assertOk()
->assertJsonCount(5, 'data');
});Why It Matters
- Uniqueness:
fake()->unique()->safeEmail()prevents constraint violations across test runs - Model events fire:
create()goes through Eloquent — observers, casts, and booted traits work - Single source of truth: Factory definition lives once — changes propagate everywhere
- Realistic data: Faker generates varied, realistic values that expose edge cases raw data misses
Factory Relationship Helpers
Impact: HIGH (Reduces setup boilerplate and correctly wires related models in tests)
Use has() for HasMany/MorphMany relationships, for() for BelongsTo, recycle() to reuse existing models instead of creating duplicates, and afterCreating() for post-creation side effects. These helpers wire foreign keys correctly and keep test setup concise.
Bad Example
<?php
// Manual relationship wiring — brittle and verbose
test('author post count', function () {
$user = User::factory()->create();
$post1 = Post::factory()->create(['user_id' => $user->id]); // manual FK
$post2 = Post::factory()->create(['user_id' => $user->id]);
$post3 = Post::factory()->create(['user_id' => $user->id]);
expect($user->posts()->count())->toBe(3);
});
// Creates a new user per post — unnecessary records, higher DB cost
test('posts belong to users', function () {
$posts = Post::factory()->count(5)->create(); // each creates its own user
// all 5 posts have different authors, which may not match the test intent
});Good Example
<?php
use App\Models\User;
use App\Models\Post;
use App\Models\Comment;
use App\Models\Tag;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// for() — BelongsTo parent relationship
test('post belongs to correct author', function () {
$user = User::factory()->create();
$post = Post::factory()->for($user)->create();
expect($post->user_id)->toBe($user->id);
});
// has() — HasMany child relationship
test('author has three posts', function () {
$user = User::factory()
->has(Post::factory()->count(3))
->create();
expect($user->posts()->count())->toBe(3);
});
// has() with named relationship method
test('user has pending orders', function () {
$user = User::factory()
->has(Order::factory()->count(2)->pending(), 'orders')
->create();
$this->actingAs($user)
->getJson('/api/orders')
->assertJsonCount(2, 'data');
});
// recycle() — reuse existing models, avoid duplicates
test('posts by same author share one user record', function () {
$user = User::factory()->create();
$posts = Post::factory()
->count(5)
->recycle($user) // all 5 posts share this single user
->create();
expect(User::count())->toBe(1); // no extra users created
});
// afterCreating() — run side effects after model is persisted
// (defined in the factory class)
class PostFactory extends Factory
{
public function definition(): array
{
return [
'title' => fake()->sentence(),
'body' => fake()->paragraphs(3, true),
'status' => 'draft',
];
}
public function withTags(int $count = 3): static
{
return $this->afterCreating(function (Post $post) use ($count) {
$tags = Tag::factory()->count($count)->create();
$post->tags()->attach($tags);
});
}
}Using afterCreating state in a test:
<?php
test('post tags appear in response', function () {
$post = Post::factory()->withTags(2)->create();
$this->getJson("/api/posts/{$post->id}")
->assertJsonCount(2, 'data.tags');
});Why It Matters
- Correct FK wiring:
for()andhas()use Eloquent relationships — no manualuser_idsetting - No duplicates:
recycle()prevents unnecessary records that slow down tests - Encapsulated setup:
afterCreating()keeps pivot/join setup inside the factory
Reference: Laravel Eloquent Factories — Relationships
Factory Sequences for Varied Records
Impact: HIGH (Creates realistic varied datasets without repetitive factory calls)
Use sequence() to assign alternating or ordered attribute values when creating multiple records. This avoids creating multiple separate factory calls and makes the data pattern explicit in the test.
Bad Example
<?php
// Separate factory calls — verbose, hard to see the pattern
test('feed only shows published posts', function () {
$user = User::factory()->create();
Post::factory()->for($user)->create(['status' => 'published']);
Post::factory()->for($user)->create(['status' => 'draft']);
Post::factory()->for($user)->create(['status' => 'published']);
Post::factory()->for($user)->create(['status' => 'archived']);
$response = $this->actingAs($user)->getJson('/api/feed');
$response->assertJsonCount(2, 'data');
});
// Using count() without variation — all records are identical
test('admin sees all statuses', function () {
Post::factory()->count(5)->create(); // all have same default status
// cannot test filtering without varied data
});Good Example
<?php
use App\Models\User;
use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// Alternate between values using sequence()
test('feed only shows published posts', function () {
$user = User::factory()->create();
Post::factory()
->for($user)
->count(4)
->sequence(
['status' => 'published'],
['status' => 'draft'],
['status' => 'published'],
['status' => 'archived'],
)
->create();
$this->actingAs($user)
->getJson('/api/feed')
->assertJsonCount(2, 'data');
});
// Sequence with index for unique values
test('posts are ordered by creation date', function () {
Post::factory()
->count(3)
->sequence(fn (Sequence $sequence) => [
'title' => "Post {$sequence->index}",
'created_at' => now()->subDays($sequence->index),
])
->create();
$response = $this->getJson('/api/posts?sort=newest');
$response->assertJsonPath('data.0.title', 'Post 0'); // most recent first
});Combining sequence with states:
<?php
use App\Models\Order;
test('order stats show correct counts by status', function () {
Order::factory()
->count(6)
->sequence(
['status' => 'pending'],
['status' => 'processing'],
['status' => 'shipped'],
['status' => 'delivered'],
['status' => 'pending'],
['status' => 'cancelled'],
)
->create();
$this->getJson('/api/admin/order-stats')
->assertOk()
->assertJsonPath('pending', 2)
->assertJsonPath('shipped', 1)
->assertJsonPath('cancelled', 1);
});Why It Matters
- Concise: One factory chain replaces multiple separate
create()calls - Explicit: The data pattern is visible in the test — the reader understands the scenario
- Realistic: Varied statuses, dates, and types create meaningful filtering/sorting tests
Reference: Laravel Eloquent Factories — Sequences
Factory States for Test Scenarios
Impact: HIGH (Eliminates repetitive attribute overrides and makes test scenarios self-documenting)
Define named states on factories for distinct model configurations. States make tests readable by naming the scenario rather than spelling out every attribute override. They also centralise scenario logic so changes propagate automatically.
Bad Example
<?php
// Attribute overrides scattered across every test — not reusable
test('suspended user cannot login', function () {
$user = User::factory()->create([
'status' => 'suspended',
'suspended_at' => now(),
'suspended_by' => 1,
]);
// ...
});
test('suspended user cannot post', function () {
$user = User::factory()->create([
'status' => 'suspended',
'suspended_at' => now(),
'suspended_by' => 1,
]);
// ...
});
// If 'suspended_by' is renamed, every test must be updated manuallyGood Example
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
class UserFactory extends Factory
{
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => bcrypt('password'),
'status' => 'active',
];
}
// State: unverified email
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
// State: suspended account
public function suspended(): static
{
return $this->state(fn (array $attributes) => [
'status' => 'suspended',
'suspended_at' => now(),
]);
}
// State: admin role
public function admin(): static
{
return $this->state(fn (array $attributes) => [
'role' => 'admin',
]);
}
}Using states in tests:
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('suspended user cannot login', function () {
$user = User::factory()->suspended()->create();
$this->post('/login', ['email' => $user->email, 'password' => 'password'])
->assertRedirect('/login')
->assertSessionHasErrors('email');
});
test('unverified user cannot access dashboard', function () {
$user = User::factory()->unverified()->create();
$this->actingAs($user)
->get('/dashboard')
->assertRedirect('/email/verify');
});
// Chain multiple states
test('suspended admin is also locked out', function () {
$user = User::factory()->admin()->suspended()->create();
$this->actingAs($user)
->get('/admin')
->assertForbidden();
});Why It Matters
- DRY: Scenario logic is defined once in the factory — not copy-pasted per test
- Self-documenting:
User::factory()->suspended()->create()reads like plain English - Resilient: Renaming a database column only requires updating the factory state
- Composable: Multiple states can be chained to build complex scenarios
Reference: Laravel Eloquent Factories — Factory States
Fake AI Agent Responses
Impact: HIGH (Test agent interactions without real API calls)
Use Agent::fake() to prevent real AI provider calls in tests. Fake responses can be static strings, arrays, or dynamic closures. Assert that agents were prompted with expected inputs.
Bad Example — Pest
<?php
use App\Ai\Agents\SalesCoach;
test('coach analyzes transcript', function () {
// Real API call — slow, expensive, non-deterministic
$response = SalesCoach::make()->prompt('Analyze this...');
// Different response every run, costs money, requires API key in CI
expect((string) $response)->toContain('feedback');
});Good Example — Pest
<?php
use App\Ai\Agents\SalesCoach;
use Laravel\Ai\Prompts\AgentPrompt;
test('coach analyzes transcript', function () {
SalesCoach::fake(['Great job on the opening.']);
$response = SalesCoach::make()->prompt('Analyze this transcript...');
expect((string) $response)->toBe('Great job on the opening.');
SalesCoach::assertPrompted('Analyze this transcript...');
});
test('coach responds based on prompt content', function () {
SalesCoach::fake(function (AgentPrompt $prompt) {
return 'Response for: ' . $prompt->prompt;
});
$response = SalesCoach::make()->prompt('Q4 sales data');
SalesCoach::assertPrompted(fn (AgentPrompt $prompt) => $prompt->contains('Q4'));
SalesCoach::assertNotPrompted('Missing prompt');
});
test('no unexpected agent calls', function () {
SalesCoach::fake()->preventStrayPrompts();
SalesCoach::make()->prompt('Expected call');
// Any unfaked agent call would throw
});Good Example — PHPUnit
<?php
namespace Tests\Feature;
use App\Ai\Agents\SalesCoach;
use Laravel\Ai\Prompts\AgentPrompt;
use Tests\TestCase;
class SalesCoachTest extends TestCase
{
public function test_coach_analyzes_transcript(): void
{
SalesCoach::fake(['Great job on the opening.']);
$response = SalesCoach::make()->prompt('Analyze this transcript...');
$this->assertEquals('Great job on the opening.', (string) $response);
SalesCoach::assertPrompted('Analyze this transcript...');
}
public function test_no_unexpected_agent_calls(): void
{
SalesCoach::fake()->preventStrayPrompts();
SalesCoach::make()->prompt('Expected call');
}
}Structured output agents auto-generate fake data matching the schema — no manual setup needed.
Why It Matters
- Fast: No API calls — tests run in milliseconds
- Deterministic: Same response every run
- Free: No API costs in CI/CD pipelines
- Safe:
preventStrayPrompts()catches accidental real calls
Reference: Laravel AI SDK — Testing Agents
Fake AI Embeddings, Reranking, Files, and Vector Stores
Impact: HIGH (Test AI data operations without real API calls)
Use Embeddings::fake(), Reranking::fake(), Files::fake(), and Stores::fake() to test data operations without provider connections.
Bad Example — Pest
<?php
use Illuminate\Support\Str;
test('stores document with embedding', function () {
// Real API call to generate embedding — slow, costs money
$embedding = Str::of($content)->toEmbeddings();
Document::create(['content' => $content, 'embedding' => $embedding]);
});Good Example — Pest
<?php
use Laravel\Ai\Embeddings;
use Laravel\Ai\Files;
use Laravel\Ai\Files\Document;
use Laravel\Ai\Reranking;
use Laravel\Ai\Stores;
use Laravel\Ai\Contracts\Files\StorableFile;
use Laravel\Ai\Prompts\EmbeddingsPrompt;
use Laravel\Ai\Prompts\RerankingPrompt;
test('generates document embeddings', function () {
Embeddings::fake();
Embeddings::for(['Laravel is great.'])->generate();
Embeddings::assertGenerated(function (EmbeddingsPrompt $prompt) {
return $prompt->contains('Laravel');
});
});
test('reranks search results', function () {
Reranking::fake();
Reranking::of(['Doc A', 'Doc B'])->rerank('query');
Reranking::assertReranked(function (RerankingPrompt $prompt) {
return $prompt->contains('query');
});
});
test('stores document with provider', function () {
Files::fake();
Document::fromString('Hello, Laravel!', mimeType: 'text/plain')
->as('hello.txt')
->put();
Files::assertStored(fn (StorableFile $file) =>
(string) $file === 'Hello, Laravel!'
);
});
test('creates vector store and adds files', function () {
Stores::fake(); // Also fakes file operations
$store = Stores::create('Knowledge Base');
Stores::assertCreated('Knowledge Base');
$store->add(Document::fromString('Content', 'text/plain')->as('doc.txt'));
$store->assertAdded(fn (StorableFile $file) => $file->name() === 'doc.txt');
});
test('no unexpected data operations', function () {
Embeddings::fake()->preventStrayEmbeddings();
Files::fake();
Stores::fake();
});Good Example — PHPUnit
<?php
namespace Tests\Feature;
use Laravel\Ai\Embeddings;
use Laravel\Ai\Prompts\EmbeddingsPrompt;
use Tests\TestCase;
class EmbeddingTest extends TestCase
{
public function test_generates_document_embeddings(): void
{
Embeddings::fake();
Embeddings::for(['Laravel is great.'])->generate();
Embeddings::assertGenerated(function (EmbeddingsPrompt $prompt) {
return $prompt->contains('Laravel');
});
}
}Why It Matters
- Fast: No embedding API calls or provider connections
- Deterministic:
Embeddings::fake()auto-generates vectors of proper dimensions - Comprehensive:
Stores::fake()also fakes file operations automatically - Assertable: Verify what was generated, stored, added, and removed
Reference: Laravel AI SDK — Testing
Fake AI Image, Audio, and Transcription
Impact: HIGH (Test media generation without real API calls)
Use Image::fake(), Audio::fake(), and Transcription::fake() to prevent real generation calls. Assert prompts, aspect ratios, voices, and diarization.
Bad Example — Pest
<?php
use Laravel\Ai\Image;
test('generates product image', function () {
// Real API call — slow, expensive, different image every run
$image = Image::of('Product photo')->landscape()->generate();
expect((string) $image)->not->toBeEmpty();
});Good Example — Pest
<?php
use Laravel\Ai\Audio;
use Laravel\Ai\Image;
use Laravel\Ai\Transcription;
use Laravel\Ai\Prompts\AudioPrompt;
use Laravel\Ai\Prompts\ImagePrompt;
use Laravel\Ai\Prompts\TranscriptionPrompt;
test('generates landscape product image', function () {
Image::fake();
Image::of('A sunset over the ocean')->landscape()->generate();
Image::assertGenerated(function (ImagePrompt $prompt) {
return $prompt->contains('sunset') && $prompt->isLandscape();
});
});
test('generates welcome audio', function () {
Audio::fake();
Audio::of('Welcome to our app.')->female()->generate();
Audio::assertGenerated(function (AudioPrompt $prompt) {
return $prompt->contains('Welcome') && $prompt->isFemale();
});
});
test('transcribes uploaded audio with diarization', function () {
Transcription::fake(['Meeting transcript text here.']);
$transcript = Transcription::fromStorage('meeting.mp3')
->diarize()
->generate();
expect((string) $transcript)->toBe('Meeting transcript text here.');
Transcription::assertGenerated(function (TranscriptionPrompt $prompt) {
return $prompt->isDiarized();
});
});
test('no unexpected media generation', function () {
Image::fake()->preventStrayImages();
Audio::fake()->preventStrayAudio();
Transcription::fake()->preventStrayTranscriptions();
});Good Example — PHPUnit
<?php
namespace Tests\Feature;
use Laravel\Ai\Image;
use Laravel\Ai\Prompts\ImagePrompt;
use Tests\TestCase;
class ImageGenerationTest extends TestCase
{
public function test_generates_landscape_product_image(): void
{
Image::fake();
Image::of('A sunset over the ocean')->landscape()->generate();
Image::assertGenerated(function (ImagePrompt $prompt) {
return $prompt->contains('sunset') && $prompt->isLandscape();
});
}
}Why It Matters
- Fast: No image/audio generation — tests complete in milliseconds
- Deterministic: Controlled fake responses every run
- Assertable: Verify prompts, aspect ratios, voices, and diarization settings
- Prevent leaks:
preventStray*()methods catch accidental real API calls
Reference: Laravel AI SDK — Testing
Faking Events with Event::fake()
Impact: HIGH (Isolates event dispatch from listener side effects during testing)
Call Event::fake() to prevent listeners from executing while still recording which events were dispatched. This isolates the unit under test from listener side effects. Assert that the correct events are dispatched with the right payloads.
Bad Example
<?php
// No Event::fake() — all listeners run, causing chain reactions in tests
test('publishing a post fires event', function () {
$post = Post::factory()->create();
$post->publish();
// PostPublished event fired → listeners run → emails sent, jobs pushed, cache cleared
// Test is slow and fragile due to cascading side effects
});
// Checking listener outcome instead of event dispatch
test('post is indexed after publish', function () {
$post = Post::factory()->create();
$post->publish();
// Asserting on search index is testing the listener, not the publisher
expect(SearchIndex::has($post->id))->toBeTrue();
});Good Example
<?php
use App\Events\OrderShipped;
use App\Events\OrderFailedToShip;
use App\Events\PostPublished;
use App\Models\User;
use App\Models\Post;
use App\Models\Order;
use Illuminate\Support\Facades\Event;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// Assert event was dispatched
test('shipping an order dispatches OrderShipped event', function () {
Event::fake();
$order = Order::factory()->create();
$order->ship();
Event::assertDispatched(OrderShipped::class);
});
// Assert dispatched once
test('order shipped event fires exactly once', function () {
Event::fake();
$order = Order::factory()->create();
$order->ship();
Event::assertDispatchedOnce(OrderShipped::class);
});
// Assert dispatched with closure — inspect event payload
test('order shipped event contains the correct order', function () {
Event::fake();
$order = Order::factory()->create(['number' => 'ORD-123']);
$order->ship();
Event::assertDispatched(
OrderShipped::class,
fn (OrderShipped $event) => $event->order->number === 'ORD-123'
);
});
// Assert failure event is dispatched on error
test('failed shipment dispatches failure event', function () {
Event::fake();
$order = Order::factory()->outOfStock()->create();
$order->ship();
Event::assertDispatched(OrderFailedToShip::class);
Event::assertNotDispatched(OrderShipped::class);
});
// Assert nothing dispatched for read-only operations
test('viewing a post does not dispatch any events', function () {
Event::fake();
$post = Post::factory()->create();
$this->getJson("/api/posts/{$post->id}")->assertOk();
Event::assertNothingDispatched();
});
// Fake only specific events — let others fire normally
test('only PostPublished is faked', function () {
Event::fake([PostPublished::class]);
$post = Post::factory()->create();
$post->publish();
Event::assertDispatched(PostPublished::class);
// Other events (e.g., ModelSaved) still fire normally
});Key Assertions
| Method | Description |
|---|---|
Event::assertDispatched(Event::class) | Event was dispatched at least once |
Event::assertDispatched(Event::class, $closure) | Dispatched and closure returns true |
Event::assertDispatchedOnce(Event::class) | Dispatched exactly once |
Event::assertNotDispatched(Event::class) | Event was not dispatched |
Event::assertNothingDispatched() | No events dispatched |
Why It Matters
- Isolation: Listeners don't run — tests stay fast and side-effect free
- Focused: Tests only verify that the correct event was dispatched, not what listeners do
- Precise: Closure assertions confirm event payload without coupling to listener logic
Reference: Laravel Events — Testing
Faking Mail with Mail::fake()
Impact: HIGH (Prevents real emails from being sent in tests and enables assertion on mail behaviour)
Call Mail::fake() before any code that triggers mail. This swaps the mailer with a fake that records calls instead of sending. Always assert both that the correct mailable was sent and that nothing unexpected was sent.
Bad Example
<?php
// Does not fake — sends real emails during tests
test('order confirmation email is sent', function () {
$order = Order::factory()->create();
$this->postJson('/api/orders', ['product_id' => 1])
->assertStatus(201);
// No Mail assertion — unknown if email was sent at all
});
// Checks response but ignores side effects
test('user registration triggers welcome email', function () {
$this->postJson('/api/register', [
'name' => 'Alice',
'email' => 'alice@example.com',
])->assertStatus(201);
// No Mail::fake() — may fire real SMTP in CI
});Good Example
<?php
use App\Mail\OrderShipped;
use App\Mail\WelcomeEmail;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// Assert a specific mailable was sent
test('order confirmation is emailed to buyer', function () {
Mail::fake();
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/orders', ['product_id' => 1])
->assertStatus(201);
Mail::assertSent(OrderShipped::class);
});
// Assert sent to specific recipient
test('welcome email is sent to new user email address', function () {
Mail::fake();
$this->postJson('/api/register', [
'name' => 'Alice',
'email' => 'alice@example.com',
'password' => 'secret123',
'password_confirmation' => 'secret123',
])->assertStatus(201);
Mail::assertSent(WelcomeEmail::class, 'alice@example.com');
});
// Assert nothing was sent for non-triggering actions
test('updating profile does not send an email', function () {
Mail::fake();
$user = User::factory()->create();
$this->actingAs($user)
->patchJson('/api/profile', ['name' => 'Bob'])
->assertOk();
Mail::assertNothingSent();
});
// Assert sent with closure — inspect mailable attributes
test('order email contains the correct order number', function () {
Mail::fake();
$user = User::factory()->create();
$order = Order::factory()->for($user)->create(['number' => 'ORD-999']);
$order->ship();
Mail::assertSent(OrderShipped::class, fn (OrderShipped $mail) =>
$mail->order->number === 'ORD-999' &&
$mail->hasTo($user->email)
);
});
// Assert sent count
test('bulk action sends one email per user', function () {
Mail::fake();
User::factory()->count(3)->create();
$this->postJson('/api/admin/send-newsletter')->assertOk();
Mail::assertSentTimes(NewsletterMail::class, 3);
});assertSent vs assertQueued
If the mailable is dispatched via a queued job (Mail::to()->queue()), use assertQueued — assertSent will not see it:
// Mailable sent immediately (Mail::to()->send())
Mail::assertSent(OrderShipped::class);
// Mailable queued for background delivery (Mail::to()->queue())
Mail::assertQueued(OrderShipped::class);
// Not sure? Assert at least one of them
Mail::assertSent(OrderShipped::class);
// or
Mail::assertQueued(OrderShipped::class);Key Assertions
| Method | Description |
|---|---|
Mail::assertSent(Mailable::class) | Mailable was sent immediately |
Mail::assertQueued(Mailable::class) | Mailable was queued for delivery |
Mail::assertSent(Mailable::class, $email) | Sent to specific address |
Mail::assertSent(Mailable::class, $closure) | Sent and closure returns true |
Mail::assertNotSent(Mailable::class) | Mailable was not sent |
Mail::assertNotQueued(Mailable::class) | Mailable was not queued |
Mail::assertNothingSent() | No mailables sent or queued |
Mail::assertSentTimes(Mailable::class, $n) | Sent exactly N times |
Mail::assertSentCount($n) | Total mails sent equals N |
Why It Matters
- No side effects: Real emails in CI clutter inboxes and fail in sandboxed environments
- Behaviour verified: Tests confirm mail is triggered at the right time, to the right address
- Fast: No SMTP round-trip — tests run at full speed
Reference: Laravel Mocking — Mail Fake
Faking Notifications with Notification::fake()
Impact: HIGH (Prevents real notifications from being sent and enables assertion on notification behaviour)
Call Notification::fake() before any code that triggers notifications. This stops real SMS, email, Slack, and database notifications from being sent. Assert that the right notification was sent to the right user with the correct data.
Bad Example
<?php
// Does not fake — sends real push/SMS/email notifications in tests
test('order shipped notification is sent', function () {
$user = User::factory()->create();
$order = Order::factory()->for($user)->create();
$order->ship();
// No Notification assertion — unknown if notification fired
});
// Only checks HTTP status, ignores notification side effect
test('resetting password sends a notification', function () {
$user = User::factory()->create();
$this->postJson('/api/forgot-password', ['email' => $user->email])
->assertOk();
// Real notification may have been sent or not
});Good Example
<?php
use App\Notifications\OrderShipped;
use App\Notifications\PasswordResetLink;
use App\Notifications\WeeklyDigest;
use App\Models\User;
use App\Models\Order;
use Illuminate\Support\Facades\Notification;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// Assert notification sent to a specific user
test('user is notified when their order ships', function () {
Notification::fake();
$user = User::factory()->create();
$order = Order::factory()->for($user)->create();
$order->ship();
Notification::assertSentTo($user, OrderShipped::class);
});
// Assert sent with closure — inspect notification data
test('order shipped notification contains order number', function () {
Notification::fake();
$user = User::factory()->create();
$order = Order::factory()->for($user)->create(['number' => 'ORD-001']);
$order->ship();
Notification::assertSentTo(
$user,
OrderShipped::class,
fn (OrderShipped $notification) =>
$notification->order->number === 'ORD-001'
);
});
// Assert nothing was sent for non-triggering operations
test('viewing an order does not send a notification', function () {
Notification::fake();
$user = User::factory()->create();
$order = Order::factory()->for($user)->create();
$this->actingAs($user)
->getJson("/api/orders/{$order->id}")
->assertOk();
Notification::assertNothingSent();
});
// Assert notification was not sent to a user
test('other users are not notified of someone elses order', function () {
Notification::fake();
$owner = User::factory()->create();
$other = User::factory()->create();
$order = Order::factory()->for($owner)->create();
$order->ship();
Notification::assertSentTo($owner, OrderShipped::class);
Notification::assertNotSentTo($other, OrderShipped::class);
});
// Assert sent count
test('weekly digest is sent to all subscribers', function () {
Notification::fake();
User::factory()->count(5)->create(['subscribed' => true]);
User::factory()->count(2)->create(['subscribed' => false]);
$this->artisan('notifications:weekly-digest')->assertSuccessful();
Notification::assertSentTimes(WeeklyDigest::class, 5);
});Key Assertions
| Method | Description |
|---|---|
Notification::assertSentTo($user, Notification::class) | Sent to specific notifiable |
Notification::assertSentTo($user, Notification::class, $closure) | Sent and closure returns true |
Notification::assertNotSentTo($user, Notification::class) | Not sent to notifiable |
Notification::assertNothingSent() | No notifications sent |
Notification::assertSentTimes(Notification::class, $n) | Sent exactly N times |
Notification::assertCount($n) | Total notifications sent equals N |
Why It Matters
- No real delivery: Prevents SMS charges, cluttered inboxes, and Slack noise during test runs
- Targeting verified: Confirms the right user receives the notification, not just that one was sent
- Payload checked: Closure assertions verify the notification carries the correct data
Reference: Laravel Notifications — Testing
Faking Queues with Queue::fake()
Impact: HIGH (Prevents real job execution in tests and enables assertion on dispatch behaviour)
Call Queue::fake() before code that dispatches jobs. The fake records dispatches without executing them, making tests fast and deterministic. Assert that jobs were dispatched with the right data, to the right queue, the right number of times.
Bad Example
<?php
// No Queue::fake() — job runs synchronously and creates real side effects
test('placing order dispatches shipment job', function () {
$order = Order::factory()->create();
$this->postJson('/api/orders', ['product_id' => 1])
->assertStatus(201);
// ShipOrder job runs immediately — unpredictable in tests
});
// Checks only HTTP, not job dispatch
test('bulk import queues a job', function () {
$this->postJson('/api/imports', ['file' => 'data.csv'])
->assertAccepted();
// No assertion that a job was pushed
});Good Example
<?php
use App\Jobs\ShipOrder;
use App\Jobs\SendInvoice;
use App\Jobs\ProcessImport;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// Assert job was pushed
test('placing an order dispatches the ship order job', function () {
Queue::fake();
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/orders', ['product_id' => 1])
->assertStatus(201);
Queue::assertPushed(ShipOrder::class);
});
// Assert pushed to specific queue
test('invoice job is dispatched to billing queue', function () {
Queue::fake();
$order = Order::factory()->create();
$order->generateInvoice();
Queue::assertPushedOn('billing', SendInvoice::class);
});
// Assert nothing was pushed for safe operations
test('viewing an order does not dispatch any jobs', function () {
Queue::fake();
$order = Order::factory()->create();
$this->getJson("/api/orders/{$order->id}")
->assertOk();
Queue::assertNothingPushed();
});
// Assert pushed with closure — inspect job properties
test('ship order job contains correct order id', function () {
Queue::fake();
$order = Order::factory()->create();
$order->ship();
Queue::assertPushed(ShipOrder::class, fn (ShipOrder $job) =>
$job->order->id === $order->id
);
});
// Assert pushed count
test('importing 5 rows dispatches 5 jobs', function () {
Queue::fake();
$this->postJson('/api/imports', ['rows' => array_fill(0, 5, ['name' => 'Item'])])
->assertAccepted();
Queue::assertPushedTimes(ProcessImport::class, 5);
});
// Fake only specific jobs — let others execute normally
test('notification job is faked but audit job runs', function () {
Queue::fake([SendInvoice::class]);
$order = Order::factory()->create();
$order->complete();
Queue::assertPushed(SendInvoice::class); // faked
// AuditLog job ran normally
});Key Assertions
| Method | Description |
|---|---|
Queue::assertPushed(Job::class) | Job was dispatched |
Queue::assertPushed(Job::class, $closure) | Dispatched and closure returns true |
Queue::assertPushedOn($queue, Job::class) | Job pushed to specific queue |
Queue::assertPushedTimes(Job::class, $n) | Dispatched exactly N times |
Queue::assertNotPushed(Job::class) | Job was not dispatched |
Queue::assertNothingPushed() | No jobs pushed at all |
Queue::assertCount($n) | Total jobs pushed equals N |
Why It Matters
- No side effects: Jobs don't execute — no emails, no DB writes, no external API calls
- Fast: No queue worker needed — tests run synchronously at full speed
- Dispatch verified: Confirms the controller/service dispatches jobs at the right time
Reference: Laravel Mocking — Queue Fake
Faking Storage with Storage::fake()
Impact: HIGH (Prevents real file writes during tests and enables assertion on file upload behaviour)
Call Storage::fake('disk') before any code that reads or writes files. This swaps the real filesystem with an in-memory fake. Use UploadedFile::fake() to generate fake files for upload tests. Assert presence or absence of files with Storage::disk()->assertExists().
Bad Example
<?php
// Writes real files to disk — pollutes storage, fails in CI environments
test('user can upload avatar', function () {
$user = User::factory()->create();
$file = new \Illuminate\Http\UploadedFile(
storage_path('test-image.jpg'), // relies on a file existing locally
'avatar.jpg',
'image/jpeg',
null,
true
);
$this->actingAs($user)
->postJson('/api/avatar', ['file' => $file])
->assertOk();
// Real file written to storage/app/public — not cleaned up after test
});Good Example
<?php
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// Basic upload test
test('user can upload an avatar', function () {
Storage::fake('avatars');
$user = User::factory()->create();
$file = UploadedFile::fake()->image('avatar.jpg', 200, 200);
$this->actingAs($user)
->postJson('/api/avatar', ['avatar' => $file])
->assertOk();
Storage::disk('avatars')->assertExists("users/{$user->id}/avatar.jpg");
});
// Assert file is not present when upload is rejected
test('oversized image is rejected and not stored', function () {
Storage::fake('avatars');
$user = User::factory()->create();
$file = UploadedFile::fake()->image('big.jpg')->size(5001); // 5001 KB > limit
$this->actingAs($user)
->postJson('/api/avatar', ['avatar' => $file])
->assertUnprocessable()
->assertJsonValidationErrors(['avatar']);
Storage::disk('avatars')->assertMissing("users/{$user->id}/big.jpg");
});
// Assert file is removed on delete
test('deleting avatar removes file from storage', function () {
Storage::fake('avatars');
$user = User::factory()->create([
'avatar_path' => "users/{$user->id}/avatar.jpg",
]);
Storage::disk('avatars')->put("users/{$user->id}/avatar.jpg", 'fake-content');
$this->actingAs($user)
->deleteJson('/api/avatar')
->assertNoContent();
Storage::disk('avatars')->assertMissing("users/{$user->id}/avatar.jpg");
});
// Fake multiple disks
test('document is moved from temp to permanent disk', function () {
Storage::fake('temp');
Storage::fake('documents');
$user = User::factory()->create();
$file = UploadedFile::fake()->create('report.pdf', 512, 'application/pdf');
$this->actingAs($user)
->postJson('/api/documents', ['file' => $file])
->assertStatus(201);
Storage::disk('documents')->assertExists("reports/{$user->id}/report.pdf");
Storage::disk('temp')->assertMissing("reports/{$user->id}/report.pdf");
});UploadedFile::fake() Helpers
UploadedFile::fake()->image('photo.jpg') // JPEG image
UploadedFile::fake()->image('photo.png', 400, 300) // image with dimensions
UploadedFile::fake()->create('doc.pdf', 1024) // file with size in KB
UploadedFile::fake()->create('doc.pdf', 1024, 'application/pdf') // with MIMEKey Assertions
| Method | Description |
|---|---|
Storage::disk('s3')->assertExists($path) | File exists at path |
Storage::disk('s3')->assertMissing($path) | File does not exist at path |
Storage::disk('s3')->assertDirectoryEmpty($dir) | Directory has no files |
Why It Matters
- No real I/O: No files written to disk or S3 — tests are fast, clean, and portable
- CI-safe: No dependency on local filesystem paths or cloud credentials
- Assertions: Explicitly verify that files are created at the expected paths
Reference: Laravel Mocking — Storage Fake
Fluent JSON Assertions with AssertableJson
Impact: HIGH (Enables precise, readable assertions on complex JSON structures)
Pass a closure to assertJson() to receive an AssertableJson instance. This fluent API allows you to assert specific values, count nested arrays, check for missing keys, and drill into nested structures — all without array comparison fragility.
Bad Example
<?php
// Brittle array comparison — breaks if any extra key is added to response
test('post index returns posts', function () {
$posts = Post::factory()->count(3)->create();
$this->getJson('/api/posts')
->assertJson($posts->toArray()); // fails if response wraps in 'data' key
});
// Too vague — only checks top-level key exists
test('post show', function () {
$post = Post::factory()->create(['title' => 'Hello']);
$this->getJson("/api/posts/{$post->id}")
->assertJson(['title' => 'Hello']); // passes even if nested under 'data'
});Good Example
<?php
use App\Models\User;
use App\Models\Post;
use Illuminate\Testing\Fluent\AssertableJson;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// Assert exact path values and structure
test('post show returns resource shape', function () {
$user = User::factory()->create(['name' => 'Alice']);
$post = Post::factory()->for($user)->create(['title' => 'Hello World']);
$this->getJson("/api/posts/{$post->id}")
->assertOk()
->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.title', 'Hello World')
->where('data.author.name', 'Alice')
->has('data.id')
->missing('data.password') // sensitive field must not leak
);
});
// Assert paginated list count and first item shape
test('post index returns paginated posts', function () {
Post::factory()->count(5)->create();
$this->getJson('/api/posts')
->assertOk()
->assertJson(fn (AssertableJson $json) => $json
->has('data', 5) // exactly 5 items in data array
->has('data.0', fn ($item) => // inspect first item shape
$item->hasAll(['id', 'title', 'created_at'])
->missing('body') // body not included in list view
)
->has('meta')
->where('meta.total', 5)
);
});
// Assert conditional value with closure
test('post marked as featured has featured flag', function () {
$post = Post::factory()->create(['is_featured' => true]);
$this->getJson("/api/posts/{$post->id}")
->assertJson(fn (AssertableJson $json) => $json
->where('data.is_featured', true)
->etc() // ignore remaining keys
);
});AssertableJson Key Methods
| Method | Description |
|---|---|
has(key) | Key exists |
has(key, count) | Array key has exact count |
has(key, fn) | Key exists and passes inner callback |
where(key, value) | Key equals value |
whereNot(key, value) | Key does not equal value |
missing(key) | Key is absent |
missingAll([keys]) | All listed keys are absent |
hasAll([keys]) | All listed keys exist |
etc() | Ignore any additional keys |
Why It Matters
- Precision: Test exact field values and nested structure without brittle full-array comparisons
- Security:
missing('password')explicitly asserts sensitive fields are never leaked - Readability: Fluent chain reads like English — clear intent, no noise
- Flexibility:
etc()prevents over-constraining when only a subset matters
Reference: Laravel HTTP Tests — Fluent JSON Testing
HTTP Response Assertions
Impact: CRITICAL (Catches regressions in status codes, redirects, and response shape)
Use the full range of HTTP assertion methods on TestResponse to verify status codes, JSON content, redirects, headers, and validation errors. Always assert the specific status code — never rely on assertOk() alone when a 201 or 422 is expected.
Bad Example
<?php
// Only checks 200 — misses 201, 422, 403 distinctions
test('store post', function () {
$response = $this->post('/posts', ['title' => 'x']);
$response->assertOk(); // passes even if 200 instead of 201
});
// Checks too broadly — assertJson only checks subset, not structure
test('post list', function () {
$response = $this->getJson('/api/posts');
$response->assertJson([]); // always passes — empty array is a subset of anything
});Good Example
<?php
use App\Models\User;
use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// Assert exact status and JSON keys
test('creating a post returns 201 with resource data', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/posts', [
'title' => 'Hello',
'body' => 'World',
]);
$response->assertStatus(201)
->assertJsonStructure([
'data' => ['id', 'title', 'body', 'created_at'],
]);
});
// Assert validation errors with assertJsonValidationErrors
test('creating a post without title returns 422', function () {
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/posts', ['body' => 'No title'])
->assertStatus(422)
->assertJsonValidationErrors(['title']);
});
// Assert redirect after web form submit
test('web form redirects after store', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/posts', ['title' => 'Hello', 'body' => 'World'])
->assertRedirect(route('posts.index'))
->assertSessionHas('success');
});
// Assert forbidden for unauthorized access
test('guest cannot create a post', function () {
$this->postJson('/api/posts', ['title' => 'Hi'])
->assertUnauthorized(); // 401
});
// Assert specific JSON path value
test('post response contains correct author', function () {
$user = User::factory()->create(['name' => 'Alice']);
$post = Post::factory()->for($user)->create();
$this->getJson("/api/posts/{$post->id}")
->assertOk()
->assertJsonPath('data.author.name', 'Alice');
});Key Assertion Methods
| Method | Use Case |
|---|---|
assertStatus(int) | Exact HTTP status code |
assertOk() | 200 |
assertCreated() | 201 |
assertNoContent() | 204 |
assertNotFound() | 404 |
assertForbidden() | 403 |
assertUnauthorized() | 401 |
assertUnprocessable() | 422 |
assertJson(array) | JSON contains subset |
assertJsonPath(key, value) | Specific JSON path value |
assertJsonStructure(array) | JSON key structure exists |
assertJsonMissing(array) | Keys/values absent from JSON |
assertJsonValidationErrors(array) | Validation error fields |
assertRedirect(url) | Redirect location |
assertSessionHas(key) | Session value exists |
Why It Matters
- Precision: Exact status codes distinguish
200 OKfrom201 Createdfrom422 Unprocessable - Regression safety: Shape assertions catch API contract breakages before production
- Validation coverage:
assertJsonValidationErrorsensures form rules are applied
Reference: Laravel HTTP Tests — Available Assertions
RefreshDatabase vs DatabaseTransactions
Impact: HIGH (Prevents test pollution and ensures a clean database state between tests)
Use RefreshDatabase for most feature tests — it migrates a fresh schema and wraps each test in a transaction that rolls back on completion. Use DatabaseTransactions when you need the schema already migrated (e.g., in CI with a pre-built test database). Never share database state across tests.
Bad Example
<?php
// No database reset — tests can pollute each other
test('user count is 1 after registration', function () {
$this->post('/register', ['name' => 'Alice', 'email' => 'a@a.com', 'password' => 'secret']);
expect(User::count())->toBe(1); // fails if previous test left users in DB
});
// Manual teardown is fragile and forgettable
test('post is deleted', function () {
$post = Post::factory()->create();
$this->delete("/posts/{$post->id}");
$post->delete(); // attempting manual cleanup — unreliable
});Good Example
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
// Apply per-file via uses() — recommended approach in Pest
uses(RefreshDatabase::class);
test('user count is 1 after registration', function () {
$this->post('/register', [
'name' => 'Alice',
'email' => 'alice@example.com',
'password' => 'secret123',
'password_confirmation' => 'secret123',
]);
expect(User::count())->toBe(1);
});
test('deleting a post removes it from database', function () {
$user = User::factory()->create();
$post = Post::factory()->for($user)->create();
$this->actingAs($user)->delete("/posts/{$post->id}");
$this->assertModelMissing($post);
});Apply to entire test directory via `Pest.php`:
<?php
// tests/Pest.php — apply RefreshDatabase to all Feature tests globally
uses(
Tests\TestCase::class,
Illuminate\Foundation\Testing\RefreshDatabase::class,
)->in('Feature');When to use `DatabaseTransactions` instead:
<?php
use Illuminate\Foundation\Testing\DatabaseTransactions;
// Use when: schema is pre-migrated in CI and you want faster tests
// (skips migration step, just wraps in transaction)
uses(DatabaseTransactions::class);
test('order total is calculated correctly', function () {
$order = Order::factory()->hasItems(3)->create();
expect($order->total)->toBeGreaterThan(0);
});Comparison
| Trait | Runs Migrations | Speed | Use When |
|---|---|---|---|
RefreshDatabase | Yes (first run) | Moderate | Default for most tests |
DatabaseTransactions | No | Faster | Pre-migrated CI database |
| Neither | No | Fast | No DB interaction in test |
Critical: DatabaseTransactions and Nested Transactions
Do not use `DatabaseTransactions` when your code under test calls `DB::transaction()`.
When the code opens its own transaction, the outer test transaction is implicitly committed — the rollback at test teardown has no effect, leaving dirty data in the database.
<?php
// Code under test:
class OrderService
{
public function place(array $data): Order
{
return DB::transaction(function () use ($data) { // inner transaction
$order = Order::create($data);
$order->items()->create([...]);
return $order;
});
}
}
// BAD — DatabaseTransactions will not clean up after a nested transaction
uses(DatabaseTransactions::class);
test('order is placed', function () {
(new OrderService())->place([...]);
expect(Order::count())->toBe(1); // passes but data is NOT rolled back after test
});
// GOOD — RefreshDatabase handles nested transactions correctly
uses(RefreshDatabase::class);
test('order is placed', function () {
(new OrderService())->place([...]);
expect(Order::count())->toBe(1); // correctly isolated
});Why It Matters
- Isolation: Each test starts with a clean state — no order-dependency between tests
- Reliability: Eliminates "passes locally, fails in CI" issues caused by leftover data
- Simplicity: No manual teardown needed — the framework handles cleanup automatically
Reference: Laravel Database Testing — Resetting the Database
Feature Test Structure with Arrange/Act/Assert
Impact: CRITICAL (Produces readable, maintainable, and deterministic tests)
Structure every feature test with three clear phases: Arrange (set up state with factories), Act (make the HTTP request), Assert (verify the response and side effects). Test behavior and outcomes — not implementation details.
Bad Example
<?php
// No factory — raw DB insert is fragile and bypasses model logic
test('create post', function () {
DB::table('users')->insert(['name' => 'Alice', 'email' => 'a@a.com', 'password' => 'pass']);
DB::table('posts')->insert(['title' => 'Test', 'user_id' => 1]);
// Asserts internal implementation detail (method was called)
$mock = Mockery::mock(PostRepository::class);
$mock->shouldReceive('save')->once();
app()->instance(PostRepository::class, $mock);
$this->post('/posts', ['title' => 'Test']);
});Good Example — Pest
<?php
use App\Models\User;
use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('authenticated user can create a post', function () {
// Arrange — factories handle all setup
$user = User::factory()->create();
// Act — one HTTP call
$response = $this->actingAs($user)
->postJson('/api/posts', [
'title' => 'Hello World',
'body' => 'Some content.',
]);
// Assert — behavior and outcomes, not internals
$response->assertStatus(201)
->assertJsonPath('data.title', 'Hello World');
$this->assertDatabaseHas('posts', [
'title' => 'Hello World',
'user_id' => $user->id,
]);
});Good Example — PHPUnit
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostControllerTest extends TestCase
{
use RefreshDatabase;
public function test_authenticated_user_can_create_a_post(): void
{
// Arrange
$user = User::factory()->create();
// Act
$response = $this->actingAs($user)
->postJson('/api/posts', [
'title' => 'Hello World',
'body' => 'Some content.',
]);
// Assert
$response->assertStatus(201)
->assertJsonPath('data.title', 'Hello World');
$this->assertDatabaseHas('posts', [
'title' => 'Hello World',
'user_id' => $user->id,
]);
}
}Testing a policy-based action (Pest):
<?php
uses(RefreshDatabase::class);
test('user cannot delete another users post', function () {
$owner = User::factory()->create();
$other = User::factory()->create();
$post = Post::factory()->for($owner)->create();
$this->actingAs($other)
->deleteJson("/api/posts/{$post->id}")
->assertForbidden();
$this->assertModelExists($post);
});Testing a policy-based action (PHPUnit):
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostPolicyTest extends TestCase
{
use RefreshDatabase;
public function test_user_cannot_delete_another_users_post(): void
{
$owner = User::factory()->create();
$other = User::factory()->create();
$post = Post::factory()->for($owner)->create();
$this->actingAs($other)
->deleteJson("/api/posts/{$post->id}")
->assertForbidden();
$this->assertModelExists($post);
}
}Why It Matters
- Readability: Clear phases make intent obvious to every team member
- Deterministic: Factories + RefreshDatabase eliminate test pollution
- Behavioral focus: Testing outcomes rather than internals means refactoring is safe
- Maintainability: Changing implementation never breaks tests that only verify behavior
Reference: Laravel HTTP Tests