Lập trình · 22/09/2026

Laravel 13 Course – Lesson 31: Testing Laravel Databases, Authentication, Policies and Queues

Lesson 31 — Database, authentication, policy and queue tests: HTTP 200 does not prove correct persistence; HTTP 403 does not prove that a rejected request caused no side effects. Each TaskFlow test should identify the boundary under test and the state that must remain true afterward.

Học Laravel 13 – Bài 31: Kiểm thử database, auth, policy và queue trong Laravel

Lesson 31 — Database, authentication, policy and queue tests: HTTP 200 does not prove correct persistence; HTTP 403 does not prove that a rejected request caused no side effects. Each TaskFlow test should identify the boundary under test and the state that must remain true afterward.

1. Start with a behavior matrix

ScenarioHTTP resultState assertion
Guest creates an API task401No insertion
Owner submits an empty title422No insertion
Unrelated user completes task403Status unchanged
Owner uses wrong parent project404Status unchanged
Complete an already done task409No second state transition
Task transaction rolls backNot applicableNo new task or job

Responses depend on routes and request formats. An HTML dashboard guest redirects to login, while getJson receives 401. Name tests for observable behavior rather than controller internals.

2. Isolate the database before migration

Feature tests explicitly select in-memory SQLite after parent::setUp. Clearing the connection URL prevents an inherited DB_URL from overriding the database name; purging discards an already resolved connection. Import the DB facade and execute this inside the test, never in a production request.

config([
    'database.default' => 'sqlite',
    'database.connections.sqlite.database' => ':memory:',
    'database.connections.sqlite.url' => null,
]);
DB::purge('sqlite');
$this->artisan('migrate', ['--force' => true])->assertSuccessful();

Create small fixtures with factories rather than relying on demo seeds or existing developer accounts. Reload models with fresh before asserting persisted state. Check counts, statuses and paths on rejection paths too. migrate --force does not protect an incorrectly selected database.

RefreshDatabase is useful for CRUD tests but may wrap execution in a transaction. TaskFlow's after-commit tests intentionally omit that trait to observe actual commits. SQLite provides quick feedback, not evidence of MySQL/PostgreSQL locking, collation, JSON/index behavior or deadlocks. Engine-specific guarantees require a separate suite against the target engine.

3. Authentication is not authorization

SessionAuthenticationTest submits credentials to /login and verifies identity, session rotation and logout invalidation. It also checks password failures, absence of flashed passwords and the IP rate limit. ProjectAuthorizationTest instead uses actingAs to isolate resource authorization without exercising login. Neither replaces the other.

SanctumTokenTest persists test tokens to exercise read/write abilities, ownership, expiry and revocation without printing plaintext tokens. tasks:write does not grant access to another owner's project. CSRF is separate: ordinary feature tests bypass it, while SessionCsrfTest explicitly enforces the middleware. Laravel 13 can use Fetch Metadata for same-origin verification, so not every tokenless POST necessarily returns 419.

4. Test nested binding even for an owner

This excerpt from ProjectAuthorizationTest creates two projects with the same owner. A task belonging to the second project is still invalid under the first project's URL. The test isolates incorrect resource combinations rather than just missing ownership.

$first = Project::factory()->create();
$second = Project::factory()->for($first->owner, 'owner')->create();
$task = Task::factory()->for($second)->create();

$this->actingAs($first->owner)
    ->patchJson(route('project-tasks.complete', [$first, $task]))
    ->assertNotFound();
$this->assertSame('todo', $task->fresh()->status);

Import Project and Task from App\Models. Other tests forge owner_id, assignee_id and status during creation to confirm that client fields outside the whitelist are ignored. A passing policy does not replace scoped binding, and validation does not replace authorization. Avoid globally disabling the middleware you intend to test.

5. Faked dispatch and actual worker execution differ

Queue::fake answers whether the expected job was requested. It does not execute handle, test backend serialization or prove that a worker consumes the correct queue. TaskQueueTest uses an actual database queue and queue:work --once in the test process. The excerpt needs CreateTask, CreateTaskData, Project, Cache and DB imports, the earlier isolated setup, and BROADCAST_CONNECTION=null from phpunit.xml.

// TaskQueueTest: database queue on the same isolated SQLite connection.
config(['queue.default' => 'database',
    'queue.connections.database.connection' => 'sqlite', 'cache.default' => 'array']);
$project = Project::factory()->create();
$key = 'taskflow:project:'.$project->id.':task-count:v1';
Cache::put($key, 0, 30);
DB::transaction(function () use ($project) {
    app(CreateTask::class)->handle($project, new CreateTaskData('Queued task'));
    $this->assertDatabaseCount('jobs', 0);
});
$this->assertDatabaseCount('jobs', 1);
$this->assertSame(0, Cache::get($key));
$this->artisan('queue:work', ['connection' => 'database', '--queue' => 'maintenance',
    '--once' => true, '--tries' => 3, '--timeout' => 15])->assertSuccessful();
$this->assertDatabaseCount('jobs', 0);
$this->assertNull(Cache::get($key));
$this->assertDatabaseCount('failed_jobs', 0);

No job exists before commit; one maintenance job exists afterward; processing removes the job and cache key without failed jobs. A separate rollback test checks that neither task nor job remains. Repeated handle execution proves scoped idempotent cache deletion, not exactly-once delivery, enforced Windows timeouts, multi-process workers or Redis operation.

Array cache is visible here because the worker executes in the same process. Production web and worker processes need a shared cache for cross-process invalidation. Do not replace the driver with sync and claim database-queue verification. Avoid faking all events when testing the actual TaskCreated listener's dispatch.

6. Attachment regression cases

This lesson adds three TaskAttachmentTest cases: reject a 2,049 KB PDF, reject an attachment under the wrong task even for its owner, and return 404 for missing storage without deleting metadata. Oversized input must leave no database record, file or notification:

// TaskAttachmentTest setup already uses isolated DB, Storage::fake('local')
// and Notification::fake(). Imports: Task model, UploadedFile and Storage facade.
$task = Task::factory()->create();
$this->actingAs($task->project->owner)
    ->postJson(route('attachments.store', [$task->project, $task]), [
        'attachment' => UploadedFile::fake()->create('large.pdf', 2049, 'application/pdf'),
    ])->assertUnprocessable()->assertJsonValidationErrors('attachment');
$this->assertDatabaseCount('task_attachments', 0);
$this->assertSame([], Storage::disk('local')->allFiles());
Notification::assertNothingSent();

A fake file simulates size and MIME, not valid or malware-free PDF content. Fake storage does not test filesystem permissions or S3. Fake notifications assert intent, not inbox delivery. These boundaries tell you which integration tests remain necessary.

7. Run targeted checks and the complete suite

php artisan test --filter=SessionAuthenticationTest
php artisan test --filter=ProjectAuthorizationTest
php artisan test --filter=SanctumTokenTest
php artisan test --filter=TaskQueueTest
php artisan test --filter=TaskAttachmentTest
php artisan test

The checkpoint passes 97 tests with 435 assertions on PHP 8.4.25 and Pest 4.7.8. The added tests require no production-code changes or development-database migration. Counts are not coverage or security certification. When a test fails, inspect its first assertion, fixture and configuration before increasing timeouts or adding sleeps.

Exercises: test a valid title with a nonexistent project, a read-only token attempting a write and a job whose dependency fails. Record which collaborators are real or fake, which side effects must be absent and which guarantees remain untested. The next lesson adds CI configuration; local success alone is not proof that a hosted pipeline ran.

References: Laravel Database Testing, Laravel Mocking. Navigation: Lesson 30: Pest · Roadmap.

Laravel 13 course navigation

Previous lesson (30) · Next lesson (32) · All 37 lessons

Discussion

Comments 0

Sign in to comment

You need an account to join the discussion and reply to other readers.

Sign inRegister

No comments yet. Be the first to share your thoughts.