Skip to content

Testing

AppDaemon has no official test harness. Testing AppDaemon apps means patching the Hass runtime, which is fragile and usually tests the mock rather than your code.

Hassette ships hassette.test_utils with AppTestHarness, a test harness that wires your app into a real Hassette environment. Because Hassette apps are async, tests are async too — test functions are declared async def, and that's the main difference from testing synchronous code. RecordingApi replaces the live Home Assistant connection, recording every API call your app makes so you can assert against it — it's available in tests as harness.api_recorder.

Setup

Install test dependencies:

pip install pytest pytest-asyncio    # or: uv add --dev pytest pytest-asyncio

Add asyncio_mode = "auto" to your pyproject.toml:

[tool.pytest.ini_options]
asyncio_mode = "auto"

Don't skip this

asyncio_mode = "auto" tells pytest to actually run async def test functions. Without it, pytest skips the test body and reports a false pass. This is the most common setup mistake when migrating from AppDaemon.

Seed state before simulating events. set_state() and simulate_state_change() are harness methods — the full example below shows them in context. Call set_state() before simulate_state_change() for the same entity. Calling it afterward overwrites the simulated state with the seeded value, silently corrupting subsequent reads.

from hassette import App, AppConfig
from hassette.test_utils import AppTestHarness


class MyApp(App[AppConfig]):
    async def on_initialize(self) -> None:
        await self.bus.on_state_change("binary_sensor.motion", handler=self.on_motion, name="motion")

    async def on_motion(self) -> None: ...


async def test_correct_order():
    async with AppTestHarness(MyApp, config={}) as harness:
        # Correct: seed first, simulate second
        await harness.set_state("binary_sensor.motion", "off")
        await harness.simulate_state_change("binary_sensor.motion", old_value="off", new_value="on")

        # Wrong: set_state() after simulate_state_change() overwrites the simulated state
        await harness.simulate_state_change("binary_sensor.motion", old_value="off", new_value="on")
        await harness.set_state("binary_sensor.motion", "off")  # clobbers the simulated state

What a Test Looks Like

Open the harness in an async with block, seed your state, fire an event, assert the API call.

from hassette import App, AppConfig, D, states
from hassette.test_utils import AppTestHarness


class MyConfig(AppConfig):
    light_entity: str = "light.kitchen"


class MotionLightApp(App[MyConfig]):
    async def on_initialize(self):
        await self.bus.on_state_change(
            "binary_sensor.motion",
            handler=self.on_motion,
            changed_to="on",
            name="motion_on",
        )

    async def on_motion(self, new_state: D.StateNew[states.BinarySensorState]):
        await self.api.turn_on(self.app_config.light_entity)


async def test_motion_turns_on_light():
    async with AppTestHarness(MotionLightApp, config={"light_entity": "light.kitchen"}) as harness:
        await harness.set_state("binary_sensor.motion", "off")
        await harness.set_state("light.kitchen", "off", brightness=0)

        await harness.simulate_state_change("binary_sensor.motion", old_value="off", new_value="on")

        harness.api_recorder.assert_called("turn_on", entity_id="light.kitchen")

Run it with pytest -v. A passing test prints PASSED; if pytest reports 0 tests or skips the body, check that asyncio_mode = "auto" made it into pyproject.toml.

Full Reference

The Testing Your Apps section covers the complete harness API: state seeding, event simulation, API call assertions, scheduler time control, and concurrency helpers.