Testing apps
A test runs your app against fake versions of Home Assistant, the clock and everything else Stewart passes in. Nothing connects to Home Assistant, and time moves only when the test moves it, so a test of “after five minutes” takes milliseconds. This test checks the door alarm:
<?php
declare(strict_types=1);
namespace App\Tests;
use App\DoorAlarm;use PHPUnit\Framework\TestCase;use Stewart\Contracts\Time\Duration;use Stewart\Testing\App\AppHarness;
final class DoorAlarmTest extends TestCase{ public function testOpenDoorIsReportedAfterFiveMinutes(): void { $harness = new AppHarness(); $harness->ha->seedState('binary_sensor.front_door', 'off'); $harness->startApp(DoorAlarm::class, ['door' => 'binary_sensor.front_door']);
$harness->ha->pushState('binary_sensor.front_door', 'on'); $harness->advanceTime(Duration::minutes(5));
self::assertSame('notify.notify', $harness->ha->getLastCall()->getServiceName()); }}Put tests in tests/ and run them:
make testThe harness comes from stewart-php/testing, which every project from the skeleton has.
Starting the app
Section titled “Starting the app”startApp($class, $options) builds the app the way the worker does and runs its initialize(). The app’s ID comes
from its #[Automation] attribute. Each constructor parameter is filled from:
- an option with the parameter’s exact name; the array stands in for
options:instewart.yaml, with PHP values rather than strings; #[GlobalStore]and#[PeerStore('…')], from the harness’s stores;- a service passed to
provideService(Type::class, $object)before the app starts, for whatservices.phpwould give it; - a fake for each type Stewart injects (see below);
- a class that takes only a
HaContext, such as the generatedEntitiesandServices, built over$harness->ha; - the parameter’s default, or null when it is nullable.
An option that matches no parameter, a required option that is missing, and a parameter nothing can fill all fail
with HarnessException, naming the parameter. One harness runs one app; stopApp() cancels its subscriptions and
schedules, then calls dispose().
Seed what the app reads at start (states, registry entries, store values, restored exposures) before startApp().
An entity’s first state is not a change, so seed it before pushing the change you test:
whenChangedTo('on') ignores a state pushed for an entity it has never seen.
The fakes
Section titled “The fakes”Each fake is a public property of the harness:
| Property | Injected as | What a test does with it |
|---|---|---|
ha |
HaContext |
seedState(), pushState(), pushEvent(), pushTrigger(); read calls, getLastCall(), firedEvents |
entities |
EntityExposure |
seedSnapshot(); requireSwitch('key')->pushCommand(...), requireSensor('key')->getValue() |
scheduler |
Scheduler |
runs as advanceTime() passes each occurrence |
stores |
Store, ReadableStore |
openAppStore(new AppId('…')) and openGlobalStore() to seed and read |
mqtt |
Mqtt |
pushMessage(); read published |
logger |
LoggerInterface |
listMessagesAt('info') |
registryEditor |
RegistryEditor |
read updates; edits land in $harness->ha->registry |
clock, timers |
Clock, Timers |
the virtual clock |
sunCalendar |
SunCalendar |
sunrise 06:00, solar noon 12:00, sunset 18:00; stubEventTime() changes one |
The fakes are usable on their own as well. The per-feature Testing sections show RecordingHaContext,
RecordingEntityExposure and the others built by hand.
The clock starts at 2026-01-01 00:00 UTC, or at the moment passed to new AppHarness($start), and stands still.
advanceTime($duration) moves it. On the way, every timer, schedule, debounce and wait that falls due runs in order,
at its own time. A handler that a timer resumes runs before the next timer is due, so a timer it starts fires in the
same advance when it falls inside it.
$harness->startApp(HallMotionLight::class, ['offAfterMinutes' => 2]);$harness->ha->pushState('binary_sensor.hall_motion', 'on');$harness->ha->pushState('binary_sensor.hall_motion', 'off');
$harness->advanceTime(Duration::seconds(119)); // the light is still on$harness->advanceTime(Duration::seconds(1)); // the 2 minutes are over: light.turn_offSchedules follow the Scheduler rules with two differences: there is no pause, and an occurrence is never late or skipped, since virtual time waits for the handler.
Waits and handlers
Section titled “Waits and handlers”Every handler runs in its own fiber. One that calls waitForState() or waitForNext() suspends without holding up
the test: the push returns, and the handler continues when a later push or advanceTime() satisfies or times out
the wait. A handler that throws before it suspends throws out of the push; one that throws after a wait throws out
of the next advanceTime().
Like the worker, the harness delivers nothing to the app until initialize() returns. States pushed meanwhile are
held and arrive afterwards, so a wait inside initialize() ends only by the shortcut for a state the entity is
already in, or by its timeout, as it would in production.
Not modelled
Section titled “Not modelled”- Pausing an app.
- Queue limits: no event is dropped, however many a test pushes.
- Home Assistant itself: a service call is recorded, and no state changes unless the test pushes it.
The app under test
Section titled “The app under test”<?php
declare(strict_types=1);
namespace App;
use Stewart\Contracts\App;use Stewart\Contracts\Automation;use Stewart\Contracts\HaContext;use Stewart\Contracts\Time\Duration;
#[Automation(id: 'door-alarm')]final readonly class DoorAlarm implements App{ public function __construct( private HaContext $ha, private string $door, ) {}
public function initialize(): void { $this->ha->watchStateChanges($this->door) ->whenChangedTo('on', for: Duration::minutes(5)) ->subscribe(function (): void { $this->ha->callService('notify', 'notify', ['message' => 'The door has been open for 5 minutes.']); }); }
public function dispose(): void {}}Common problems
Section titled “Common problems”whenChangedTo() does not fire in a test. Seed the entity’s state before startApp(). A first state is not a
change.
HarnessException naming a parameter. The harness cannot fill it: pass it as an option, or give it to
provideService() before startApp().
A wait in the app always ends at its timeout. Nothing pushed the state the app waits for. Push it after the app starts waiting.