Testing helpers for your own app live in RoundlyConsulting\Approvals\Testing. They pull in no runtime dependency — Pest is only touched when you call the registrar.
Approvals::fake()
Approvals::fake() swaps in ApprovalsFake, a subtype of ApprovalsManager, for the facade and for injected managers, and returns it. It is a recording fake that still performs: operations run against your database as usual, and the fake records each one — whether it came through the facade, an injected manager, a model trait such as $user->approve($post) or the test helpers below:
use RoundlyConsulting\Approvals\Facades\Approvals;
it('approves the invoice', function (): void {
$fake = Approvals::fake();
$this->actingAs($cfo)->post("/invoices/{$invoice->id}/approve");
$fake->assertApproved($invoice, by: $cfo); // or Approvals::assertApproved(...)
$fake->assertNothingRejected();
});Every assert is also callable statically on the facade — Approvals::assertApproved($invoice, $cfo). An operation that throws is not recorded.
Every assertion
| Assert | Passes when | Negative |
|---|---|---|
assertApproved($approvable, ?$by) | an approval was recorded (by $by) | assertNothingApproved() |
assertRejected($approvable, ?$by) | a rejection was recorded | assertNothingRejected() |
assertAsked($approvable, ?$actor) | a pending decision was asked for with ask() | assertNothingAsked() |
assertCancelled($approvable, ?$by) | a cancel ran | assertNothingCancelled() |
assertToggled($approvable, ?$by) | a toggle ran | assertNothingToggled() |
assertOpened($subject, ?$workflow) | a request was opened for the subject (from the preset) | assertNothingOpened() |
assertDelegated($delegator, ?$to) | a delegation was granted | assertNothingDelegated() |
assertRevoked($delegator, ?$delegate) | a revoke ran — a revoke-all records no delegate | assertNothingRevoked() |
assertExpired(?$count, ?$subjectType) | an expiry sweep ran (and lapsed $count decisions and requests in total); with $subjectType only sweeps scoped to that type count | assertNothingExpired() |
$fake = Approvals::fake();
// Decisions — the actor argument is optional
$fake->assertApproved($invoice, by: $cfo);
$fake->assertRejected($invoice, by: $auditor);
$fake->assertAsked($contract, $legal);
$fake->assertCancelled($contract, by: $legal);
$fake->assertToggled($post, by: $user);
// Requests — optionally from a named preset
$fake->assertOpened($release);
$fake->assertOpened($budget, 'payout');
// Delegation
$fake->assertDelegated($manager, to: $assistant);
$fake->assertRevoked($manager, $assistant);
// Expiry sweeps — a sweep ran, and lapsed 3 decisions and requests in total
$fake->assertExpired(3);
$fake->assertExpired(subjectType: Invoice::class); // only sweeps scoped to invoices count
// Every assert has a negative twin
$fake->assertNothingApproved();
$fake->assertNothingRejected();
$fake->assertNothingAsked();
$fake->assertNothingCancelled();
$fake->assertNothingToggled();
$fake->assertNothingOpened();
$fake->assertNothingDelegated();
$fake->assertNothingRevoked();
$fake->assertNothingExpired(); // nothing lapsedThe raw log
recorded(?ApprovalOperation) returns the RecordedApprovalOperation list in call order — optionally one kind of operation: Approve, Reject, Ask, Cancel, Toggle, Open, Delegate, Revoke or Expire. Each entry carries the operation, its context models and the result, for custom assertions:
use RoundlyConsulting\Approvals\Enums\ApprovalOperation;
$opened = $fake->recorded(ApprovalOperation::Open); // list<RecordedApprovalOperation>
$opened[0]->operation; // ApprovalOperation::Open
$opened[0]->context['workflow']; // 'payout' or null
$opened[0]->model('subject'); // ?Model
$opened[0]->involves('subject', $budget); // bool
$opened[0]->result; // the ApprovalRequest the call returnedPest expectations
Register them once in tests/Pest.php:
// tests/Pest.php
use RoundlyConsulting\Approvals\Testing\ApprovalExpectations;
ApprovalExpectations::register();- toBeApproved() — the model’s status is Approved.
- toBePendingApproval() — the status is Pending.
- toBeRejected() — the status is Rejected.
The status comes from currentApprovalStatus() when the model uses RequiresApproval; otherwise it is derived from the decisions still in force against the model — any rejection wins, then any approval, else pending.
Acting as an approver
use App\Models\Release;
use App\Models\User;
use RoundlyConsulting\Approvals\Testing\InteractsWithApprovals;
uses(InteractsWithApprovals::class);
it('approves a release once both reviewers sign off', function (): void {
$release = Release::factory()->create();
$lead = User::factory()->create();
$qa = User::factory()->create();
$release->requestApproval([$lead, $qa]);
$this->actingAsApprover($lead)->approveAs($release);
expect($release)->toBePendingApproval();
$this->approveAs($release, 'Tests pass', $qa);
expect($release)->toBeApproved();
});
it('rejects with a reason', function (): void {
$release = Release::factory()->create();
$reviewer = User::factory()->create();
$release->requestApproval([$reviewer]);
$this->rejectAs($release, 'needs work', $reviewer);
expect($release)->toBeRejected();
});approveAs() and rejectAs() take the approvable, an optional reason and an optional actor; with no remembered or passed actor they throw a RuntimeException. They go through ApprovalsManager, so the gate, named approvers, delegation and request resolution all apply — and Approvals::fake() records them.
Factories
use RoundlyConsulting\Approvals\Models\Approval;
use RoundlyConsulting\Approvals\Models\ApprovalDelegation;
use RoundlyConsulting\Approvals\Models\ApprovalRequest;
Approval::factory()->pending()->forActor($user)->forApprovable($post)
->create(['expires_at' => now()->subHour()]);
Approval::factory()->approved()->weight(3)->delegated($assistant)->create();
ApprovalRequest::factory()->forSubject($release)->weighted(3)->create();
ApprovalDelegation::factory()->forDelegator($manager)->toDelegate($assistant)->create();| Factory | States |
|---|---|
ApprovalFactory | pending(), approved(), rejected(), cancelled(), expired(), weight($n), delegated($decidedBy), forStage($stage), forActor($actor), forApprovable($approvable) |
ApprovalRequestFactory | unanimous(), quorum($n), any(), weighted($threshold), staged(), forSubject($subject) |
ApprovalRequestStageFactory | forRequest($request), atPosition($n), quorum($n), cleared(), pending() |
ApprovalDelegationFactory | forDelegator($delegator), toDelegate($delegate), expired(), revoked() |
The package’s own suite
To run the package’s test suite from a checkout:
composer testShow your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.