---
title: "Testing — Approvals for Laravel | Roundly"
description: "Approvals::fake() records every operation for assertions, plus Pest expectations, an approver test trait and factories for every model."
url: https://roundly-consulting.com/open-source/docs/approvals-for-laravel/testing
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Approvals for Laravel](https://roundly-consulting.com/open-source/docs/approvals-for-laravel.md)

# Testing

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:

```php
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()` |

```php
$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);

// 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();
```

## 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 or Close. Each entry carries the operation, its context models and the result, for custom assertions:

```php
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 returned
```

A 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. There is no assert for these values — read them from recorded():

```php
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();
});
```

ApprovalOperation gained the Close case in 1.1.0 — a match over it without a default arm needs the new case.

## Pest expectations

Register them once in tests/Pest.php:

```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

```php
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

```php
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:

```bash
composer test
```

## Show 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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/approvals-for-laravel.md)
