Testing
The widget is a Livewire component, so your application tests it the way they test any
other Livewire component — Livewire::test(), the Laravel fakes, no browser required.
Two things are worth knowing before the first test, and both of them are the reason an otherwise correct test fails: the abuse floor runs on every submit, and it is tuned for real people rather than for a suite.
Make the floor permissive in the test environment
The floor is a honeypot, a server-anchored time trap and per-hour rate limits, and it cannot be switched off — see Abuse protection. Nothing about that changes in a test, which is the point: it means your suite exercises the same path production does.
What it costs is that a test submits instantly, and the time trap exists precisely to reject that. Relax the two settings once, in the test environment, and tighten them again in the tests that are actually about abuse:
// tests/TestCase.php, or a Pest beforeEach
config()->set('visual-feedback.abuse.min_fill_seconds', 0);
config()->set('visual-feedback.abuse.rate_limit', 1000);
config()->set('visual-feedback.abuse.guest_rate_limit', 1000);
Without the first line every submit is rejected as too fast, and the rejection is silent by design — the component reports success and stores nothing, so the test fails on a missing mail rather than on anything that names the cause.
The rate limits matter as soon as a file has more than a handful of submits: the shipped defaults are 30 per authenticated user per hour and 5 per guest IP per hour, and the counter survives from one test to the next unless the cache is reset between them.
The honeypot needs nothing: its field starts empty and only a test that fills it in trips it.
Submit a report
use Illuminate\Support\Facades\Mail;
use Livewire\Livewire;
use Pushery\VisualFeedback\Channels\Mail\ReportMail;
use Pushery\VisualFeedback\Livewire\ReportWidget;
it('mails a report the reporter filed', function (): void {
Mail::fake();
Livewire::test(ReportWidget::class)
->set('category', 'bug')
->set('message', 'The invoice total is wrong.')
->call('submit')
->assertSet('submitted', true);
Mail::assertSent(ReportMail::class);
});
ReportWidget::class and the registered name visual-feedback.report-widget are
interchangeable here; the class is the one your editor can resolve.
Mount props go in the second argument, exactly as they do in Blade:
The per-instance props are listed under
Placing the trigger. All of them are
#[Locked], so ->set('recipient', …) throws rather than assigning — that is the guarantee
the lock exists for, and it means a test can only reach them through the mount.
Assert on the events instead of the channels
Every submit path ends in an event, and asserting on those is both cheaper and more precise than asserting on mail, because a rejection has an event of its own:
use Illuminate\Support\Facades\Event;
use Pushery\VisualFeedback\Events\ReportRejected;
use Pushery\VisualFeedback\Events\ReportSubmitted;
Event::fake([ReportSubmitted::class, ReportRejected::class]);
// … submit …
Event::assertDispatched(ReportSubmitted::class);
Event::assertNotDispatched(ReportRejected::class);
ReportSubmitted carries the whole Report, so a single assertion can check the category,
the metadata and the reporter that were actually recorded. ReportRejected carries a
RejectionReason, which is the only way to tell a honeypot hit from a rate limit from a
validation failure — from the outside all three look like a success screen.
Faking the events does not stop delivery, and it is worth being exact about that, because
the assumption is easy to make and it costs a real mail. The channels are dispatched before
ReportSubmitted is, and they enqueue jobs rather than fire events — so an Event::fake()
leaves the whole delivery path running underneath it.
What suppresses delivery is Queue::fake(), which is what the package's own suite uses:
use Illuminate\Support\Facades\Queue;
use Pushery\VisualFeedback\Jobs\SendReportMail;
Queue::fake();
// … submit …
Queue::assertPushed(SendReportMail::class);
That also names the other half of the first example on this page. Mail::assertSent() finds
the mail because a test environment runs the queue synchronously — Laravel's own
phpunit.xml sets QUEUE_CONNECTION=sync — and the job therefore runs inline. Point a test
at a real queue connection and the same assertion fails on a report that is perfectly fine:
the job was pushed, not run. Assert on the job there, or run the queue.
Attachments and screenshots
Both ride Livewire's upload path, so Storage::fake() on the disk the package is configured
to use plus UploadedFile::fake() is the whole setup:
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
Storage::fake('local');
Livewire::test(ReportWidget::class)
->set('attachments', [UploadedFile::fake()->image('screen.png')])
->set('category', 'bug')
->set('message', 'Attached.')
->call('submit')
->assertSet('submitted', true);
Uploads are validated the moment they land, not only at submit, so a file that is too large
or of a rejected type surfaces its error on the set() and never reaches submit(). The
caps come from attachments.max_files, attachments.max_file_size and
attachments.mimes — see Configuration.
A screenshot goes on screenshot rather than attachments, and screenshotStage records
which capture stage produced it. Neither is required for a report to be valid.
What the bundled suites already prove
The package ships no tests to the published tree — they are development files, and a public checkout has nothing to run. What they cover is still worth knowing, because it decides which tests are worth writing in your application and which would be duplicates:
- Every shipped line and every shipped type, at 100%, with a mutation run on top so a test that cannot fail does not count as coverage.
- The submit pipeline end to end — validation, the abuse floor, metadata sanitization, attachment and screenshot perimeters, the events, and each delivery channel in isolation.
- Both view trees in a real browser: an axe sweep over every widget state, a keyboard-only run through the whole flow, contrast measured on the values the browser rendered, and the capture running against real Chromium.
- Real database engines, not SQLite alone: the same suites re-run against PostgreSQL and MySQL 8.4, so the optional reports table is proven on the engines it will actually meet.
What they cannot cover is everything that belongs to your page, and those are the tests worth having:
- that the widget is on the layout you think it is on, in the environments you think it is enabled in;
- that
attachments.diskpoints at a private disk in every environment — a misconfiguration here is a public URL to somebody's screen, and it looks identical to a correct setup from inside the package; - that the reports actually arrive where you sent them: a queue that is not running, a mail transport that is not configured, or a webhook endpoint that rejects the signature all produce a perfectly successful submit;
- that a redacted region on your own pages is still redacted after a redesign. The Integration contract explains why a CSS effect is not a redaction; a test that asserts the attribute is present on the element that holds the sensitive data is what keeps it from being removed by accident.
The package's own suites are in the repository if you want to see how a case is set up before writing your own.