NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

Health::fake() swaps the manager — for the facade and for anything that injects HealthManager — with a recording HealthFake, and returns it. The fake keeps the checks you registered, runs checks without dispatching jobs, sending notifications or writing rows, answers report(), status() and silences from memory, and records every call, including those made through the UsesHealthChecks trait and the Artisan commands:

use RoundlyConsulting\Alerts\CheckResult;
use RoundlyConsulting\Alerts\Facades\Health;

$fake = Health::fake();

Health::define('disk-full', fn () => CheckResult::failed('Disk full'));

Health::for($team)->run(DiskUsageCheck::class);    // healthy: checked, nothing to recover
Health::for($team)->run('disk-full');             // failing: alerted
$team->monitorCheck(DiskUsageCheck::class)->hourly()->save();
Health::silences()->mute('db', for: $team);
$this->artisan('alerts:prune-runs', ['--days' => 7]);

$fake->assertChecked('disk_usage_check');
$fake->assertNothingRecovered();                  // a first healthy run recovers nothing
$fake->assertAlerted('disk-full');                // not recorded while a fake silence matches
$fake->assertMonitored(DiskUsageCheck::class, $team);
$fake->assertMuted('db', $team);
$fake->assertPruned(7);
$fake->assertNothingRanDue();

Every assertion

AssertionNegativePasses when
assertChecked(string $key)assertNothingChecked()A check with this key was run for an owner.
assertAlerted(string $key)assertNothingAlerted()An alert fired for the key: failAfter consecutive failures (warning or failed) were reached while no fake silence matched its key, its tags or *.
assertRecovered(string $key)assertNothingRecovered()An open alert of the key closed after recoverAfter consecutive successes, with no fake silence matching. A healthy run with nothing open records nothing.
assertMonitored(string $checkOrKey, ?Model $owner = null)assertNothingMonitored()The check (class name or key) was scheduled — by monitor()->save(), schedule() or the trait — for the owner, when given.
assertUnmonitored(string $checkOrKey, ?Model $owner = null)assertNothingUnmonitored()unmonitor() was called for the check, for the owner when given.
assertMuted(string $key, ?Model $owner = null)assertNothingMuted()silences()->mute() was called for the key, scoped to the owner when given.
assertUnmuted(string $key, ?Model $owner = null)assertNothingUnmuted()silences()->unmute() was called for the key, scoped to the owner when given.
assertRanDue(?int $times = null)assertNothingRanDue()runDue() was called — at least once, or exactly $times times.
assertPruned(?int $days = null)assertNothingPruned()prune() was called — with that many days, when given.
$fake = Health::fake();
$healthy = false;

Health::define('payments-api', function () use (&$healthy): bool {
    return $healthy;
})->failAfter(2)->recoverAfter(2);

Health::for($team)->run('payments-api');   // 1st failure — below failAfter
$fake->assertNothingAlerted();

Health::for($team)->run('payments-api');   // 2nd failure — the alert opens
$fake->assertAlerted('payments-api');

$healthy = true;
Health::for($team)->run('payments-api');   // 1st OK — still open
$fake->assertNothingRecovered();
Health::for($team)->status();              // Status::Failed until recovery is confirmed

Health::for($team)->run('payments-api');   // 2nd OK — the alert closes
$fake->assertRecovered('payments-api');

Health::for($team)->unmonitor(DiskUsageCheck::class);
Health::silences()->unmute('db', for: $team);
Health::runDue();

$fake->assertUnmonitored(DiskUsageCheck::class, $team);
$fake->assertUnmuted('db', $team);
$fake->assertRanDue(1);
$fake->assertNothingMonitored();
$fake->assertNothingMuted();
$fake->assertNothingPruned();

How the fake behaves

  • A run passes the same gates as a real one, kept in memory per owner and check: a thrown exception or an overrun timeout becomes a failed result, an alert is recorded only once failAfter consecutive failures are reached, and a recovery only when an open alert closes after recoverAfter consecutive successes. No rows, runs, events or notifications.
  • The options come from the row you run, a monitor you recorded for that owner, or an inline check’s define(). A row that belongs to another owner is still refused with InvalidHealthCheck.
  • report() and status() are built from the fake’s in-memory monitors — the open alert per owner and check, as the real report is — never from the database.
  • Silences live in memory: a matching fake silence (key, tag or *) suppresses the recorded alert, and isMuted() and active() read them back.
  • schedule(), monitor()->save() and mute() return unsaved models; an invalid cron is refused with InvalidCronExpression, as in the real action. runDue(), prune() and unmonitor() return 0; monitors() returns an empty collection.
  • Skipped results change no counters and are neither alerted nor recovered.

Testing the full pipeline

For end-to-end behaviour, run the real pipeline against your database with Laravel’s Notification and Event fakes:

use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Notification;
use RoundlyConsulting\Alerts\CheckResult;
use RoundlyConsulting\Alerts\Events\HealthCheckFailed;
use RoundlyConsulting\Alerts\Facades\Health;

it('opens an alert and notifies the team when a check fails', function () {
    Notification::fake();
    Event::fake([HealthCheckFailed::class]);

    Health::define('always-down', fn () => CheckResult::failed('Down'));
    $team = Team::factory()->create();

    $result = Health::for($team)->run('always-down');

    expect($result->status->isAlertable())->toBeTrue()
        ->and($team->alerts()->open()->count())->toBe(1);

    Event::assertDispatched(HealthCheckFailed::class);
});

Factories

HealthCheck, Alert, HealthCheckRun and AlertSilence ship with factories. Alert has warning(), failed(), skipped() and recovered() states. Point the notifiable columns at a real owner:

use RoundlyConsulting\Alerts\Alert;
use RoundlyConsulting\Alerts\HealthCheck;
use RoundlyConsulting\Alerts\HealthCheckRun;

$row = HealthCheck::factory()->create([
    'notifiable_type' => $team->getMorphClass(),
    'notifiable_id' => $team->getKey(),
    'health_check' => 'disk_usage_check',
]);

HealthCheckRun::factory()->count(20)->create(['health_check_id' => $row->id]);

Alert::factory()->warning()->create([          // also failed(), skipped(), recovered()
    'notifiable_type' => $team->getMorphClass(),
    'notifiable_id' => $team->getKey(),
    'health_check_id' => $row->id,
]);

The package’s own tests

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

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