Testing Your Apps
Hassette ships with hassette.test_utils — a set of utilities for testing your automations in isolation, without a running Home Assistant instance. You can simulate state changes, inspect API calls your app makes, and control time for scheduler tests.
The core idea: AppTestHarness runs your app against a test-grade Hassette environment with a RecordingApi in place of a live HA connection. When your app calls self.api.turn_on(), self.api.call_service(), or any other API method, RecordingApi records those calls instead of contacting Home Assistant — you then assert on the recorder via harness.api_recorder.
Installation
hassette.test_utils is part of the main hassette package — no extra install required. You only need to add your test runner:
pip install pytest pytest-asyncio
Or with uv:
uv add --dev pytest pytest-asyncio
Add this to your pyproject.toml to configure pytest-asyncio:
[tool.pytest.ini_options]
asyncio_mode = "auto"
With asyncio_mode = "auto", any async def test_* function is automatically treated as an async test — no @pytest.mark.asyncio decorator required. If you skip this config, your async tests will silently succeed without actually running — a silent false-green failure mode. The examples on this page assume asyncio_mode = "auto" is set.
Quick Start
Here's a complete test for an app that turns on a light when motion is detected:
Replace the placeholders with your own app
Replace MotionLights with your app class and motion_lights with your module path. The config keys (motion_entity, light_entity) should match the fields on your app's AppConfig subclass.
from hassette.test_utils import AppTestHarness
from my_apps.motion_lights import MotionLights
async def test_light_turns_on_when_motion_detected():
async with AppTestHarness(
MotionLights,
config={"motion_entity": "binary_sensor.hallway", "light_entity": "light.hallway"},
) as harness:
await harness.simulate_state_change("binary_sensor.hallway", old_value="off", new_value="on")
harness.api_recorder.assert_called(
"turn_on",
entity_id="light.hallway",
)
After async with, the app is fully initialized and ready to receive events. The harness tears everything down cleanly when the async with block exits.
The Test Harness
AppTestHarness wires your app class into a test-grade Hassette environment with a RecordingApi instead of a live HA connection.
Constructor
from hassette import App
from hassette.test_utils import AppTestHarness
AppTestHarness(
app_cls=App, # Replace with your App subclass
config={}, # Dict matching your app's AppConfig fields
tmp_path=None, # Optional: Path | None
)
| Parameter | Description |
|---|---|
app_cls |
Your App subclass to test. |
config |
Dict of config values. Keys must match the fields defined on your app's AppConfig subclass (see App Configuration). Invalid or missing fields raise AppConfigurationError during harness setup. |
tmp_path |
Optional directory for Hassette data files. Created and cleaned up automatically if omitted. In pytest, pass the built-in tmp_path fixture to share a directory across multiple harnesses in one test. |
Properties
Once inside the async with block, the harness exposes:
| Property | Type | Description |
|---|---|---|
harness.app |
App |
The fully initialized app instance. |
harness.bus |
Bus |
The test bus your app registered handlers on. |
harness.scheduler |
Scheduler |
The test scheduler your app registered jobs on. |
harness.api_recorder |
RecordingApi |
Records every API call your app makes. Use this for assertions. |
harness.states |
StateManager |
The state manager your app reads from. |
State Seeding
Before simulating events, seed the state of any entities your app reads. Use set_state() for a single entity or set_states() for multiple at once.
from hassette.test_utils import AppTestHarness
from my_apps.thermostat import ThermostatApp
async def test_thermostat_state_seeding():
async with AppTestHarness(ThermostatApp, config={}) as harness:
# Seed a single entity
await harness.set_state("sensor.temperature", "20.5", unit_of_measurement="°C")
# Seed multiple entities at once
await harness.set_states(
{
"sensor.temperature": ("20.5", {"unit_of_measurement": "°C"}),
"sensor.humidity": "55",
"climate.living_room": "heat",
}
)
set_states() accepts either a plain state string or a (state, attributes) tuple.
set_state() does not fire bus events
set_state() is for pre-test setup only. It writes directly to the state proxy without publishing a state_changed event, so no handlers will fire. Do not use set_state() mid-test to simulate a state transition — use simulate_state_change() instead.
A second hazard: calling set_state() after a simulate_state_change() for the same entity will silently overwrite the simulated state with the seeded value, which can make subsequent reads return wrong values. Seed first, simulate second.
Simulating Events
If your handler reads entity state during handling (e.g., self.states.light.get("light.kitchen")), seed it first with harness.set_state(). Simulating an event does not update the state proxy automatically unless your handler writes back via the API.
State changes
simulate_state_change() publishes a state_changed event through the bus and waits for all triggered handlers to finish before returning.
from hassette.test_utils import AppTestHarness
from my_apps.motion_lights import MotionLights
async def test_simulate_state_changes():
async with AppTestHarness(MotionLights, config={}) as harness:
# Basic state change
await harness.simulate_state_change(
"binary_sensor.motion",
old_value="off",
new_value="on",
)
# With attributes
await harness.simulate_state_change(
"sensor.temperature",
old_value="20.0",
new_value="21.5",
old_attrs={"unit_of_measurement": "°C"},
new_attrs={"unit_of_measurement": "°C"},
)
Attribute changes
simulate_attribute_change() simulates a change to a single attribute while keeping the state value the same.
from hassette.test_utils import AppTestHarness
from my_apps.light_app import LightApp
async def test_simulate_attribute_change():
async with AppTestHarness(LightApp, config={}) as harness:
await harness.simulate_attribute_change(
"light.kitchen",
"brightness",
old_value=128,
new_value=255,
)
# Seed state first to avoid the "unknown" fallback in predicates
await harness.set_state("light.kitchen", "on", brightness=128)
# ...or pass state= explicitly for a one-off:
await harness.simulate_attribute_change(
"light.kitchen",
"brightness",
old_value=128,
new_value=255,
state="on", # avoids the "unknown" fallback
)
The generated event carries the entity's current cached state for the state field. If you haven't seeded the entity with set_state() first, that field defaults to "unknown" — which silently breaks any state-conditional predicates on the same entity. You can pass an explicit state= to avoid this, as shown above.
simulate_attribute_change can also fire state-change handlers
This method delegates to simulate_state_change under the hood. With the default changed=True, state-change handlers do not fire (the state value is unchanged). But if your app registers on_state_change with changed=False, that handler will fire — matching HA's real behavior where state_changed events fire even when only attributes change:
from hassette import App, AppConfig
from hassette.test_utils import AppTestHarness
class SensorApp(App[AppConfig]):
async def on_initialize(self):
# on_state_change with changed=False fires even when only attributes change
await self.bus.on_state_change("sensor.temp", changed=False, handler=self.on_temp_state, name="temp_state")
await self.bus.on_attribute_change("sensor.temp", "temperature", handler=self.on_temp_attr, name="temp_attr")
async def on_temp_state(self):
await self.api.turn_on("light.indicator")
async def on_temp_attr(self):
await self.api.turn_on("light.indicator")
async def test_both_handlers_fire():
async with AppTestHarness(SensorApp, config={}) as harness:
# simulate_attribute_change fires BOTH handlers because on_state_change
# was registered with changed=False (fires for any state_changed event).
# With the default changed=True, only the attribute handler would fire.
await harness.simulate_attribute_change("sensor.temp", "temperature", old_value=20, new_value=21)
# Both handlers ran
harness.api_recorder.assert_call_count("turn_on", 2)
Use harness.api_recorder.reset() between simulate calls, or get_calls() for targeted inspection, to isolate which handler made which API call.
Service call events
simulate_call_service() publishes a call_service event, useful for apps that listen for HA service calls.
from hassette.test_utils import AppTestHarness
from my_apps.light_app import LightApp
async def test_simulate_call_service():
async with AppTestHarness(LightApp, config={}) as harness:
await harness.simulate_call_service(
"light",
"turn_on",
entity_id="light.kitchen",
brightness=200,
)
Timeouts and slow handlers
All three simulate methods wait for dispatched handlers to finish before returning. The default timeout is 2 seconds. Override it with the timeout= parameter:
from hassette.test_utils import AppTestHarness
from my_apps.slow_app import SlowApp
async def test_with_custom_timeout():
async with AppTestHarness(SlowApp, config={}) as harness:
await harness.simulate_state_change(
"sensor.slow_device",
old_value="off",
new_value="on",
timeout=5.0,
)
Task chains drain to completion
The drain is iterative: after the bus dispatch queue clears, any tasks spawned by self.task_bucket.spawn(...) inside a handler are awaited in turn, to arbitrary depth. simulate_* does not return until the full chain is settled. If a task raises or the drain times out, a DrainFailure subclass is raised — see DrainFailure Exception Hierarchy for the full exception hierarchy and catch patterns.
Typed dependency injection in handlers
Hassette handlers support typed dependency injection via D.* annotations. These work seamlessly with simulate_* — the harness dispatches the same event objects that production code receives, so DI resolution runs identically.
State change with D.StateNew — extract a typed state model from the event:
from hassette import D, states
from hassette.app import App, AppConfig
from hassette.test_utils import AppTestHarness
class SecurityConfig(AppConfig):
door_entity: str
class SecurityApp(App[SecurityConfig]):
async def on_initialize(self):
await self.bus.on_state_change(
self.app_config.door_entity,
changed_to="on",
handler=self.on_door_opened,
name="door_opened",
)
async def on_door_opened(self, new_state: D.StateNew[states.BinarySensorState]):
device_class = new_state.attributes.device_class
if device_class == "door":
await self.api.call_service("notify", "send_message", message="Door opened")
async def test_typed_state_change_handler():
async with AppTestHarness(SecurityApp, config={"door_entity": "binary_sensor.front_door"}) as harness:
await harness.set_state("binary_sensor.front_door", "off", device_class="door")
await harness.simulate_state_change("binary_sensor.front_door", old_value="off", new_value="on")
harness.api_recorder.assert_called("call_service", domain="notify", service="send_message", message="Door opened")
Service call with D.Domain — extract the service domain from the event:
from hassette import D
from hassette.app import App, AppConfig
from hassette.test_utils import AppTestHarness
class AuditConfig(AppConfig):
pass
class AuditApp(App[AuditConfig]):
async def on_initialize(self):
await self.bus.on_call_service(domain="light", handler=self.on_light_service, name="light_service")
async def on_light_service(self, domain: D.Domain):
await self.api.call_service("notify", "log", message=f"Service called on {domain}")
async def test_typed_call_service_handler():
async with AppTestHarness(AuditApp, config={}) as harness:
await harness.simulate_call_service("light", "turn_on", entity_id="light.kitchen")
harness.api_recorder.assert_called("call_service", domain="notify", service="log", message="Service called on light")
Hassette service events
simulate_hassette_service_status() and its convenience wrappers (simulate_hassette_service_failed, simulate_hassette_service_crashed, simulate_hassette_service_started) let you test how your app responds to internal service lifecycle changes.
from hassette.app import App, AppConfig
from hassette.test_utils import AppTestHarness
from hassette.types.enums import ResourceStatus
class WatchdogConfig(AppConfig):
pass
class WatchdogApp(App[WatchdogConfig]):
async def on_initialize(self):
await self.bus.on_hassette_service_failed(handler=self.on_service_failed, name="service_watchdog")
async def on_service_failed(self) -> None:
await self.api.call_service("notify", "send_message", message="Service failed")
async def test_service_failure_triggers_notification():
async with AppTestHarness(WatchdogApp, config={}) as harness:
await harness.simulate_hassette_service_failed("WebSocketService")
harness.api_recorder.assert_called(
"call_service",
domain="notify",
service="send_message",
message="Service failed",
)
async def test_granular_service_status():
"""You can also simulate specific status transitions."""
async with AppTestHarness(WatchdogApp, config={}) as harness:
await harness.simulate_hassette_service_status(
"SchedulerService",
ResourceStatus.FAILED,
previous_status=ResourceStatus.RUNNING,
exception=ConnectionError("connection lost"),
)
harness.api_recorder.assert_called(
"call_service",
domain="notify",
service="send_message",
message="Service failed",
)
Asserting API Calls
harness.api_recorder records every call your app makes to self.api. Use it to assert that your app called the right services.
assert_called
Passes if the method was called at least once with kwargs that match all specified values (partial matching — additional kwargs in the recorded call are allowed).
from hassette.test_utils import AppTestHarness
from my_apps.motion_lights import MotionLights
async def test_assert_called_examples():
async with AppTestHarness(MotionLights, config={}) as harness:
await harness.simulate_state_change("binary_sensor.motion", old_value="off", new_value="on")
# Assert turn_on was called for a specific entity
harness.api_recorder.assert_called(
"turn_on",
entity_id="light.kitchen",
domain="light",
)
# Assert fire_event was called with a specific event type
harness.api_recorder.assert_called("fire_event", event_type="my_custom_event")
# Assert call_service was called directly (for services without a named wrapper)
harness.api_recorder.assert_called(
"call_service",
domain="light",
service="set_color_temp",
target={"entity_id": "light.kitchen"},
)
turn_on, turn_off, and toggle_service record under their own names
These convenience methods record calls using their own method name — not call_service. Assert them directly:
from hassette.test_utils import AppTestHarness
from my_apps.motion_lights import MotionLights
async def test_turn_on_off_recording():
async with AppTestHarness(MotionLights, config={}) as harness:
await harness.simulate_state_change("binary_sensor.motion", old_value="off", new_value="on")
# Your app calls: await self.api.turn_on("light.kitchen", domain="light")
harness.api_recorder.assert_called("turn_on", entity_id="light.kitchen", domain="light")
# Your app calls: await self.api.turn_off("light.kitchen", domain="light")
harness.api_recorder.assert_called("turn_off", entity_id="light.kitchen", domain="light")
Use assert_called("call_service", ...) only for direct self.api.call_service(...) calls.
assert_not_called
from hassette.test_utils import AppTestHarness
from my_apps.motion_lights import MotionLights
async def test_assert_not_called():
async with AppTestHarness(MotionLights, config={}) as harness:
harness.api_recorder.assert_not_called("call_service")
assert_call_count
from hassette.test_utils import AppTestHarness
from my_apps.motion_lights import MotionLights
async def test_assert_call_count():
async with AppTestHarness(MotionLights, config={}) as harness:
await harness.simulate_state_change("binary_sensor.motion", old_value="off", new_value="on")
harness.api_recorder.assert_call_count("call_service", 2)
get_calls
Returns a list of ApiCall records, optionally filtered by method name. Each ApiCall has method, args, and kwargs attributes.
from hassette.test_utils import AppTestHarness
from my_apps.motion_lights import MotionLights
async def test_get_calls():
async with AppTestHarness(MotionLights, config={}) as harness:
await harness.simulate_state_change("binary_sensor.motion", old_value="off", new_value="on")
calls = harness.api_recorder.get_calls("call_service")
for call in calls:
print(call.kwargs) # e.g. {"domain": "light", "service": "turn_on", ...}
reset
Clears all recorded calls. Useful when you want to assert on calls made after a specific point in your test.
from hassette.test_utils import AppTestHarness
from my_apps.motion_lights import MotionLights
async def test_recorder_reset():
async with AppTestHarness(MotionLights, config={}) as harness:
await harness.simulate_state_change("binary_sensor.motion", old_value="off", new_value="on")
harness.api_recorder.reset() # ignore calls from the above simulate
await harness.simulate_state_change("binary_sensor.motion", old_value="on", new_value="off")
harness.api_recorder.assert_called("turn_off", entity_id="light.hallway", domain="light")
Configuration Errors
If the config dict you pass to AppTestHarness fails validation against your app's AppConfig class, the harness raises AppConfigurationError during setup — the async with body is never entered, so your test code inside the block does not run.
import pytest
from hassette.test_utils import AppConfigurationError, AppTestHarness
from my_apps.motion_lights import MotionLights
async def test_missing_config_raises():
with pytest.raises(AppConfigurationError) as exc_info:
async with AppTestHarness(MotionLights, config={}) as harness:
pass
# The error message includes the field name and validation failure reason
print(exc_info.value)
# AppConfigurationError for MotionLights: 1 validation error — field 'motion_entity': Field required
AppConfigurationError has two attributes:
- app_cls — the App class whose config failed.
- original_error — the underlying pydantic.ValidationError with full field-level detail.
Read the error message to find which field is missing or invalid, then fix the config dict in your test.
Harness Startup Failures
If the harness raises TimeoutError: Timed out waiting for <YourApp> RUNNING, the app's on_initialize() either raised an exception or took longer than 5 seconds to complete.
This is a bare TimeoutError, not DrainTimeout
Harness startup timeouts are distinct from drain timeouts. The startup wait still raises a plain TimeoutError — catch TimeoutError here, not DrainTimeout or DrainFailure. Drain-related failures only happen once the harness is running and you call simulate_*.
Check test output for log lines near the TimeoutError — exceptions raised during on_initialize() are caught and logged at WARNING level during harness cleanup, so the TimeoutError is the symptom, not the root cause.
Common triggers:
- A required config field is present but its value causes a runtime error during initialization (distinct from a missing-field AppConfigurationError which fires before entry).
- on_initialize() awaits something that never resolves, such as an external call that isn't mocked.
- An await inside on_initialize() raises an exception that propagates out.
Next Steps
- Time Control: Freeze and advance time to test scheduler-driven behavior
- Concurrency & pytest-xdist: Understand the same-class lock and parallel test runners
- Factories & Internals: Event factories, state factories,
make_test_config, andRecordingApicoverage boundary - Bus: Event subscriptions and handler registration
- Scheduler: Timed and recurring job registration
- API: The
self.apimethods your app calls