Browse documentationOpen

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

AssertionCovers
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');
PropertyMeaning
typeAn InteractionType
targetThe request, record, or list of requests it acted on
actorWho performed it
workflowThe workflow argument, for submission and resubmission
verdictThe Verdict, for decisions
commentThe comment, trimmed
metadataOperation-specific input
recipientThe 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