byrcsc/laravel-approval · 1.x
Testing.
Use Approval::fake() to assert approval behavior without writing approval state, and build fixtures with the shipped factories.
Approval::fake() records every public mutation instead of performing it, and
provides an assertion for each:
$fake = Approval::fake();
$order->submitForApproval('purchase-order', ['amount' => 10_000]);
$fake->assertSubmitted(
$order,
fn ($recorded) => $recorded->metadata['new_attributes'] === ['amount' => 10_000],
);
$fake->restore();Nothing is written to the approval tables while the fake is active. Each faked command returns an unsaved model, or an empty result, of the shape the real operation returns — so code under test that reads the returned request keeps working.
What is faked, and what is not
Mutations are faked. Submission, decisions, bulk decisions, withdrawal, cancellation, delegation, reassignment, resynchronization, conflict resolution, resubmission, attachment, guard bypass, definition sync, and overdue processing.
Queries are not. awaiting(), pendingFor(), requestsFor(),
overdue(), workflow(), and verifyTrail() run against the database as
usual, so a test can seed real requests and still fake the decisions taken on
them.
Resolution is not faked either. A faked submit() with no requester resolves
one the way the real manager does — through approvalRequester() and then the
authenticated user — so a test that forgets to authenticate fails the same way
production would.
Assertions
| Assertion | Covers |
|---|---|
assertSubmitted($approvable, $callback?) | Submission |
assertNotSubmitted($approvable) | — |
assertSubmittedTimes($count, $approvable?) | — |
assertApproved($request, $callback?) | An approval decision |
assertRejected($request, $callback?) | A rejection |
assertReturnedForRevision($request, $callback?) | A return for revision |
assertNotApproved($request) | — |
assertApprovedTimes($count, $request?) | — |
assertRejectedTimes($count, $request?) | — |
assertApprovedMany($callback?) | A bulk approval |
assertRejectedMany($callback?) | A bulk rejection |
assertWithdrawn($request, $callback?) | Withdrawal |
assertCancelled($request, $callback?) | Administrative cancellation |
assertDelegated($request, $callback?) | Delegation |
assertReassigned($request, $callback?) | Reassignment |
assertResynced($request, $callback?) | Resynchronization |
assertConflictResolved($request, $callback?) | Conflict resolution |
assertResubmitted($request, $callback?) | Revise and resubmit |
assertAttached($request, $callback?) | Attachment |
assertGuardBypassed($approvable, $callback?) | A guard-mode bypass |
assertOverdueProcessed($callback?) | The overdue sweep |
assertDefinitionsSynced($callback?) | Definition sync |
assertNothingRecorded() | All of them |
assertNothingRecorded() names the interaction types that were recorded when
it fails, which is usually enough to see what happened without a debugger.
Inspecting an interaction
The optional callback receives an ApprovalInteraction and returns a boolean:
$fake->assertDelegated($request, fn ($recorded) =>
$recorded->recipient->is($deputy)
&& $recorded->comment === 'On leave this week');| Property | Meaning |
|---|---|
type | An InteractionType |
target | The request, record, or list of requests it acted on |
actor | Who performed it |
workflow | The workflow argument, for submission and resubmission |
verdict | The Verdict, for decisions |
comment | The comment, trimmed |
metadata | Operation-specific input |
recipient | The approver an assignment moved to |
target is null for the operations that act on no single subject — overdue
processing and definition sync.
Read them all with $fake->interactions() when an assertion helper does not
fit.
Restoring
$fake->restore();Puts the real manager back in the container and on the facade. Calling
fake() again on an active fake clears its recorded interactions instead of
nesting.
Testing without the fake
Integration tests that want the real engine need only a database. Everything the package does is local — no HTTP, no queue, no external service:
it('applies the draft after the final approval', function () {
$order = PurchaseOrder::factory()->create(['amount' => 4_000]);
$request = $order->submitForApproval('purchase-order', ['amount' => 10_000]);
Approval::approve($request, $financeUser);
expect($order->fresh()->amount)->toBe(10_000)
->and($request->fresh()->state)->toBe(RequestState::Approved);
});Factories
Every model ships a factory under
ByRcsc\LaravelApproval\Database\Factories:
use ByRcsc\LaravelApproval\Models\ApprovalWorkflow;
$workflow = ApprovalWorkflow::factory()
->has(ApprovalWorkflowStage::factory()->count(2), 'stages')
->create(['slug' => 'purchase-order']);Useful for building an odd request state directly — a conflicted request, a stranded stage — that would take several steps to reach through the engine.
Asserting events
Approval events are ordinary Laravel events:
Event::fake([ApprovalCompleted::class]);
Approval::approve($request, $financeUser);
Event::assertDispatched(ApprovalCompleted::class);Note that faking events stops the notification listener too, since it is registered against those events.
Asserting notifications
config(['approval.notifications.enabled' => true]);
Notification::fake();
$order->submitForApproval('purchase-order');
Notification::assertSentTo($financeUser, ApprovalRequestedNotification::class);Delivery is deferred with DB::afterCommit(). Outside a transaction the
callback runs immediately, so a test that does not wrap the call in one sees
the notification without extra work.
Contributing to the package
The package's own suite needs no external database, .env, or service:
composer install
composer test
composer analyse
vendor/bin/pint --test