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.
assertSilencesPruned(?int $days = null)assertNoSilencesPruned()silences()->prune() was called — with that many days, when given.
assertReconfigured(string $checkOrKey, ?Model $owner = null)assertNothingReconfigured()reconfigure() was called for a schedule of the check (class name or key), for the owner when given. A refused call is not recorded.
$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.
  • A run of a check goes through the owner’s oldest monitor of it, as the real run does, else an on-demand monitor with an inline check’s define() options; a run of a row uses that row. A row that belongs to another owner is still refused with InvalidHealthCheck.
  • report() and status() are built from the fake’s in-memory monitors and their open alerts, as the real report is — never from the database. since: and scheduledOnly: apply (each in-memory result is stamped with now(), so travel() works), a monitor that never ran reads ok with uptime null, muted comes from the fake silences by the real rule, meta is the open fake alert’s stored meta, and p95LatencyMs stays null — the fake records no durations.
  • Silences live in memory, one per key and scope as mute() upserts: a matching fake silence (key, tag or *) suppresses the recorded alert; isMuted(), active(), snapshot() and covers() read them back, and silences()->prune() drops the ended ones and is recorded.
  • schedule(), monitor()->save() and mute() return unsaved models; an invalid cron, escalation policy or uniqueBy() identity is refused, as in the real action. For a uniqueBy() check schedule() upserts in memory with the real diff and counter-reset rule, and each later schedule() of the same identity returns a fresh copy of the row reporting wasRecentlyCreated and wasChanged(). for($owner)->monitors() lists what was scheduled, oldest first.
  • reconfigure() merges in memory with the real rules: another owner’s row is refused and another schedule’s identity throws ScheduleConflict — neither is recorded.
  • unmonitor() removes the check’s monitors for the owner — and its on-demand monitor — from later runs and the report, and returns how many it removed; a database row the fake did not schedule is left alone (0).
  • runDue() and prune() return 0; prune() and silences()->prune() below 1 day throw InvalidRetention, like the real ones.
  • 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. Point the health check’s notifiable columns at a real owner. Alert::factory() opens an alert the way the pipeline does — on its health check’s owner, holding the check’s open slot until recovered() — so the report sees it, a failing run follows it, and a second open alert for the same check is refused by the unique index. Alert also has warning(), failed() and skipped() states:

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]);

// Opens on the row's owner and holds its open slot, as the pipeline does
Alert::factory()->warning()->create([          // also failed(), skipped(), recovered()
    '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.