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() |
assertClosed($subject, ?$outcome) | a close of the subject’s round was requested with close() (as $outcome); a close that found no open round still counts | assertNothingClosed() |
assertApproversReplaced($subject, ?$stage) | the pending approvers of one of the subject’s stages were replaced with replaceApprovers() — of the stage named $stage when given; a flat round’s replaceFlatApprovers() counts when $stage is null | assertNothingReplaced() |
assertVoided($subject) | a voidIfChanged() call on the subject voided at least one round | assertNothingVoided() |
assertRechecked($subject, ?$closedAs) | a recheckThreshold() of the subject’s round ran — closing it as $closedAs when given | assertNothingRechecked() |
$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
// Rounds your app closed — optionally with the outcome
$fake->assertClosed($purchase);
$fake->assertClosed($purchase, ApprovalStatus::Cancelled);
// Stages handed to other approvers — optionally the stage by name
$fake->assertApproversReplaced($release);
$fake->assertApproversReplaced($release, 'engineering');
$fake->assertApproversReplaced($invoice); // a flat round's replacement counts without a stage
// Decision guards — a void that voided a round, a threshold re-check (closing it as given)
$fake->assertVoided($invoice);
$fake->assertRechecked($invoice);
$fake->assertRechecked($invoice, ApprovalStatus::Cancelled);
// 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 lapsed
$fake->assertNothingClosed();
$fake->assertNothingReplaced();
$fake->assertNothingVoided(); // no void voided anything
$fake->assertNothingRechecked();The raw log
recorded(?ApprovalOperation) returns the RecordedApprovalOperation list in call order — optionally one kind of operation: Approve, Reject, Ask, Cancel, Toggle, Open, Delegate, Revoke, Expire, Close, Replace, Void or Recheck. 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 returnedA decision’s context holds the builder’s because(), weight() and expiresIn() / expiringAt() as given — reason, weight and expires_at, null when not set — not what the action resolved from them (the actor’s own weight, the default lifetime); the approval actually written is in result. A close records subject, request and outcome, and its result is the number of rounds it closed; a replacement records subject, request, stage (null for a flat round) and approvers, and its result is the round; a void records subject and request, and its result is the number of rounds voided; a re-check records subject, request and closeAs, and its result is the ThresholdVerdict or null. There is no assert for these values — read them from recorded():
use RoundlyConsulting\Approvals\Enums\ApprovalOperation;
use RoundlyConsulting\Approvals\Enums\ApprovalStatus;
use RoundlyConsulting\Approvals\Facades\Approvals;
it('closes the approval round when the purchase is cancelled', function (): void {
$fake = Approvals::fake();
$purchase->cancel(); // your code calls Approvals::for($purchase)->close()
$fake->assertClosed($purchase, ApprovalStatus::Cancelled);
});
it('asks the director with a deadline', function (): void {
$fake = Approvals::fake();
$budget->escalate(); // your code calls ->because('Over limit')->expiresIn(86400)->ask()
$ask = $fake->recorded(ApprovalOperation::Ask)[0]->context;
expect($ask['reason'])->toBe('Over limit')
->and($ask['expires_at'])->not->toBeNull()
->and($ask['weight'])->toBeNull();
});A void that the void mode of fingerprint.on_change runs inside a refused decision goes straight to the action, so the fake records no Void operation for it — nor the refused decision.
ApprovalOperation gained the Close case in 1.1.0, the Replace case in 2.1.0 and the Void and Recheck cases in 2.2.0 — a match over it without a default arm needs the new cases.
Pest 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.