Skip to main content

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();
config()->set('visual-feedback.mail.to', '[email protected]');

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:

Livewire::test(ReportWidget::class, ['mode' => 'inline', 'recipient' => '[email protected]']);

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.disk points 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.