---
title: "Testing — Reports for Laravel | Roundly"
description: "Reports::fake() records every mutating call instead of running it, with an assertion per verb — plus a factory state for every lifecycle stage."
url: https://roundly-consulting.com/open-source/docs/reports-for-laravel/testing
language: en
---

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

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

# Testing

Reports::fake() swaps a recording fake in behind the facade and the container, so the facade, an injected ReportsManager, the builders, reports:prune, $report->changeStatusTo() and the GivesReports trait all land on it. Mutating calls are recorded and nothing is written: create() returns an unsaved Pending report, moderate()->open() an unsaved ApprovalRequest and prune() returns 0. The reason reads still answer for real:

```php
use RoundlyConsulting\Reports\Facades\Reports;

$fake = Reports::fake();

$user->report($post)->for('spam')->create();
Reports::resolve($report, by: $admin, note: 'Removed.');

$fake->assertReported($post, $user, 'spam');
$fake->assertResolved($report, $admin, 'Removed.');
$fake->assertNothingPruned();
```

## Every assertion

| Assertion | Negative | Passes when |
| --- | --- | --- |
| `assertReported($subject, ?$by, Reason\|string\|null $reason)` | `assertNothingReported()` | A report was filed about the subject (by $by, for $reason, when given). |
| `assertModerated($report)` | `assertNothingModerated()` | moderate($report)->…->open() ran. |
| `assertResolved($report, ?$by, ?$note)` | `assertNothingResolved()` | The report was resolved (by, with note, when given). |
| `assertRejected($report, ?$by, ?$note)` | `assertNothingRejected()` | The same, for rejections. |
| `assertStatusChanged($report, ?Status $to)` | `assertNothingStatusChanged()` | changeStatus(), review(), close() or changeStatusTo() moved it (to $to). |
| `assertPruned(?int $days, ?bool $force)` | `assertNothingPruned()` | prune() ran (with that window and flag, when given). |

The assertions match a report or model by identity: two saved models match when they are the same row, and an unsaved one — like every report the fake files — only matches itself.

```php
use RoundlyConsulting\Reports\Enums\Status;
use RoundlyConsulting\Reports\Facades\Reports;

it('routes a report through moderation', function () {
    $fake = Reports::fake();

    $this->actingAs($admin)->post(route('admin.reports.escalate', $report))->assertOk();

    $fake->assertModerated($report);                       // moderate($report)->…->open() ran
    $fake->assertStatusChanged($report, Status::InReview); // review() / changeStatus() / changeStatusTo()
    $fake->assertNothingResolved();
    $fake->assertNothingRejected();
    $fake->assertNothingReported();
});

it('rejects and prunes from the console', function () {
    $fake = Reports::fake();

    Reports::reject($report, by: $admin, note: 'Not a violation.');
    $this->artisan('reports:prune', ['--days' => 30])->assertSuccessful();

    $fake->assertRejected($report, $admin, 'Not a violation.');
    $fake->assertPruned(30, force: false);
    $fake->assertNothingModerated();
    $fake->assertNothingStatusChanged();
});
```

## What the fake refuses

Before recording, the fake refuses what the real manager refuses, with the same exception, and records nothing — so a test can’t pass over a call that fails in production:

- create() — an unsaved subject or reporter (LogicException), an unknown reason (UnknownReportReasonException), an invalid threshold (InvalidConfigurationException), and a duplicate (DuplicateReportException) of a stored report or of one the fake filed, under the same prevent\_duplicates and duplicate\_scope rules.
- moderate()->open() — no moderators (MissingModeratorsException), or a settled report or one already under moderation, stored or opened through the fake (ModerationNotAllowedException).
- prune() — no $days and no prune\_after\_days (MissingPruneWindowException), so reports:prune fails under the fake too.
- resolve(), reject(), changeStatus(), review() and close() — a move strict\_transitions forbids (InvalidStatusTransitionException), checked against where the fake last moved the report, else its own status. The fake tracks its moves without writing, so review → resolve → close works and resolve → reject throws, as for real.

Moderation itself isn’t simulated: approvals’ request checks (an unsaved moderator, an unreachable quorum) don’t run, and under moderation resolve(), reject() and close() are recorded like direct calls rather than limited to the named moderators.

## Factory states

Report ships a factory with a state for every stage of the lifecycle. It builds the model configured in reports.model, so a subclass’s casts and events run for seeded reports:

```php
use RoundlyConsulting\Reports\Enums\Reason;
use RoundlyConsulting\Reports\Models\Report;

Report::factory()->create();                                     // Pending, reason 'spam'
Report::factory()->forReporter($user)->against($post)->create();
Report::factory()->reason(Reason::Harassment)->create();         // Reason case or slug
Report::factory()->inReview()->create();
Report::factory()->resolved($moderator)->create();               // sets resolver + resolved_at
Report::factory()->rejected()->create();
Report::factory()->closed()->create();
Report::factory()->guest('guest-hash')->create();                // null reporter, guest identifier
```

Factory reports are written directly, so reason validation, duplicate checks and the threshold don’t run — ReportCreated still fires. Without the fake, file through the facade when you want the real flow:

```php
use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Reports\Enums\Reason;
use RoundlyConsulting\Reports\Enums\Status;
use RoundlyConsulting\Reports\Events\ReportResolved;
use RoundlyConsulting\Reports\Events\ReportThresholdReached;
use RoundlyConsulting\Reports\Exceptions\DuplicateReportException;
use RoundlyConsulting\Reports\Facades\Reports;

it('files, deduplicates and resolves a report', function () {
    Event::fake([ReportResolved::class]);

    $user = User::factory()->create();
    $moderator = User::factory()->create();
    $post = Post::factory()->create();

    $report = Reports::report($post)->by($user)->for(Reason::Spam)->create();

    expect($report->status)->toBe(Status::Pending)
        ->and($post->isReportedBy($user))->toBeTrue();

    expect(fn () => Reports::report($post)->by($user)->for(Reason::Spam)->create())
        ->toThrow(DuplicateReportException::class);

    Reports::resolve($report, by: $moderator, note: 'Removed.');

    expect($report->fresh()->status)->toBe(Status::Resolved);
    Event::assertDispatched(ReportResolved::class);
});

it('escalates once the threshold is reached', function () {
    config()->set('reports.threshold', 3);
    Event::fake([ReportThresholdReached::class]);

    $post = Post::factory()->create();

    foreach (User::factory()->count(3)->create() as $user) {
        Reports::report($post)->by($user)->for(Reason::Spam)->create();
    }

    Event::assertDispatchedTimes(ReportThresholdReached::class, 1);
});
```

Run your suite with the published reports and approvals migrations (RefreshDatabase) — resolving or rejecting a report reads approval\_requests even when you don’t test moderation.

## 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/reports-for-laravel.md)
