Package appdaemon_testing

appdaemon-testing

Ergonomic and pythonic unit testing for AppDaemon apps. Utilities to allow you to test your AppDaemon home automation apps using all the pythonic testing patterns you are already familiar with.

Install

pip install appdaemon-testing

Full Documentation

An enhanced, source-linked version of the documentation below as well as complete API documentation is available here

Writing your first test

This demo assumes you will use pytest as your test runner. Install the appdaemon-testing and pytest packages:

pip install appdaemon-testing pytest

In your appdaemon configuration directory, introduce a new tests directory. This is where we are going to write the tests for your apps.

Additionally we also need to introduce an __init__.py file to tests and apps directories to make them an importable package. You should have a tree that looks something like this:

├── appdaemon.yaml
├── apps
│   ├── __init__.py
│   ├── apps.yaml
│   └── living_room_motion.py
├── dashboards
├── namespaces
└── tests
    ├── __init__.py
    └── test_living_room_motion.py

We have an automation, apps/living_room_motion.py that we wish to test. It looks like this:

import appdaemon.plugins.hass.hassapi as hass


class LivingRoomMotion(hass.Hass):
    def initialize(self):
        self.listen_state(self.on_motion_detected, self.args["motion_entity"])

    def on_motion_detected(self, entity, attribute, old, new, kwargs):
        if old == "off" and new == "on":
            for light in self.args["light_entities"]:
                self.turn_on(light)

Create a new file, tests/test_living_room_motion.py. This is where we will write the tests for our automation.

First we will declare an automation_fixture():

@automation_fixture(
    LivingRoomMotion,
    args={
        "motion_entity": "binary_sensor.motion_detected",
        "light_entities": ["light.1", "light.2", "light.3"],
    },
)
def living_room_motion() -> LivingRoomMotion:
    pass

With this fixture, it's now possible to write some tests. We will first write a test to check the state listener callbacks are registered:

def test_callbacks_are_registered(hass_driver, living_room_motion: LivingRoomMotion):
    listen_state = hass_driver.get_mock("listen_state")
    listen_state.assert_called_once_with(
        living_room_motion.on_motion_detected, "binary_sensor.motion_detected")

We use the hass_driver() fixture to obtain mock implementations of methods that exist on the AppDaemon Hass API. We can query these mocks and make assertions on their values. In this test we make an assertion that listen_state is called once with the specified parameters.

We will next write a test to make an assertion that the lights are turned on when motion is detected:

def test_lights_are_turned_on_when_motion_detected(
    hass_driver, living_room_motion: LivingRoomMotion
):
    with hass_driver.setup():
        hass_driver.set_state("binary_sensor.motion_detected", "off")

    hass_driver.set_state("binary_sensor.motion_detected", "on")

    turn_on = hass_driver.get_mock("turn_on")
    assert turn_on.call_count == 3
    turn_on.assert_has_calls(
        [mock.call("light.1"), mock.call("light.2"), mock.call("light.3")]
    )

This test uses the HassDriver.setup() context manager to set the initial state for testing. When execution is within HassDriver.setup() all state updates will not be triggered.

With the initial state configured, we can now proceed to triggering the state change (HassDriver.set_state()). After the state change has occured, we can then begin to make assertions about calls made to the underlying API. In this test we wish to make assertions that turn_on is called. We obtain the turn_on mock implementation and make assertions about its calls and call count.

You can see this full example and example directory structure within the example directory in this repo.

pytest plugin

The appdaemon_testing.pytest package provides a handy hass_driver() fixture to allow you easy access to the global HassDriver instance. This fixture takes care of ensuring AppDaemon base class methods are patched.

Additionally, it provides a decorator, automation_fixture() which can be used to declare automation fixtures. It can be used like so:

from appdaemon_testing.pytest import automation_fixture
from apps.living_room_motion import LivingRoomMotion


@automation_fixture(
    LivingRoomMotion,
    args={
        "motion_entity": "binary_sensor.motion_detected",
        "light_entities": ["light.1", "light.2", "light.3"],
    },
)
def living_room_motion() -> LivingRoomMotion:
    pass
Expand source code
"""
.. include:: ../README.md
"""
from .hass_driver import HassDriver

__all__ = ["HassDriver"]
__version__ = "0.0.0-dev"

Sub-modules

appdaemon_testing.pytest

Classes

class HassDriver
Expand source code
class HassDriver:
    def __init__(self):
        self._mocks = dict(
            log=mock.Mock(),
            error=mock.Mock(),
            call_service=mock.Mock(),
            cancel_timer=mock.Mock(),
            get_state=mock.Mock(side_effect=self._se_get_state),
            # TODO(NW): Implement side-effect for listen_event
            listen_event=mock.Mock(),
            fire_event=mock.Mock(),
            listen_state=mock.Mock(side_effect=self._se_listen_state),
            notify=mock.Mock(),
            run_at=mock.Mock(),
            run_at_sunrise=mock.Mock(),
            run_at_sunset=mock.Mock(),
            run_daily=mock.Mock(),
            run_every=mock.Mock(),
            run_hourly=mock.Mock(),
            run_in=mock.Mock(),
            run_minutely=mock.Mock(),
            set_state=mock.Mock(),
            time=mock.Mock(),
            turn_off=mock.Mock(),
            turn_on=mock.Mock(),
        )

        self._setup_active = False
        self._states: Dict[str, Dict[str, Any]] = defaultdict(lambda: {"state": None})
        self._state_spys: Dict[Union[str, None], List[StateSpy]] = defaultdict(
            lambda: []
        )

    def get_mock(self, meth: str) -> mock.Mock:
        """
        Returns the mock associated with the provided AppDaemon method

        Parameters:
            meth: The method to retreive the mock implementation for
        """
        return self._mocks[meth]

    def inject_mocks(self) -> None:
        """
        Monkey-patch the AppDaemon hassapi.Hass base-class methods with mock
        implementations.
        """
        for meth_name, impl in self._mocks.items():
            if getattr(hass.Hass, meth_name) is None:
                raise AssertionError("Attempt to mock non existing method: ", meth_name)
            _LOGGER.debug("Patching hass.Hass.%s", meth_name)
            setattr(hass.Hass, meth_name, impl)

    @contextlib.contextmanager
    def setup(self):
        """
        A context manager to indicate that execution is taking place during a
        "setup" phase.

        This context manager can be used to configure/set up any existing states
        that might be required to run the test. State changes during execution within
        this context manager will cause `listen_state` handlers to not be called.

        Example:

        ```py
        def test_my_app(hass_driver, my_app: MyApp):
            with hass_driver.setup():
                # Any registered listen_state handlers will not be called
                hass_driver.set_state("binary_sensor.motion_detected", "off")

            # Respective listen_state handlers will be called
            hass_driver.set_state("binary_sensor.motion_detected", "on")
            ...
        ```
        """
        self._setup_active = True
        yield None
        self._setup_active = False

    def set_state(
        self, entity, state, *, attribute_name="state", previous=None, trigger=None
    ) -> None:
        """
        Update/set state of an entity.

        State changes will cause listeners (via listen_state) to be called on
        their respective state changes.

        Parameters:
            entity: The entity to update
            state: The state value to set
            attribute_name: The attribute to set
            previous: Forced previous value
            trigger: Whether this change should trigger registered listeners
                     (via listen_state)
        """
        if trigger is None:
            # Avoid triggering state changes during state setup phase
            trigger = not self._setup_active

        domain, _ = entity.split(".")
        state_entry = self._states[entity]
        prev_state = copy(state_entry)
        old_value = previous or prev_state.get(attribute_name)
        new_value = state

        if old_value == new_value:
            return

        # Update the state entry
        state_entry[attribute_name] = new_value

        if not trigger:
            return

        # Notify subscribers of the change
        for spy in self._state_spys[domain] + self._state_spys[entity]:
            sat_attr = spy.attribute == attribute_name or spy.attribute == "all"
            sat_new = spy.new is None or spy.new == new_value
            sat_old = spy.old is None or spy.old == old_value

            param_old = prev_state if spy.attribute == "all" else old_value
            param_new = copy(state_entry) if spy.attribute == "all" else new_value
            param_attribute = None if spy.attribute == "all" else attribute_name

            if all([sat_old, sat_new, sat_attr]):
                spy.callback(entity, param_attribute, param_old, param_new, spy.kwargs)

    def _se_get_state(self, entity_id=None, attribute="state", default=None, **kwargs):
        _LOGGER.debug("Getting state for entity: %s", entity_id)

        fully_qualified = "." in entity_id
        matched_states = {}
        if fully_qualified:
            matched_states[entity_id] = self._states[entity_id]
        else:
            for s_eid, state in self._states.items():
                domain, entity = s_eid.split(".")
                if domain == entity_id:
                    matched_states[s_eid] = state

        # With matched states, map the provided attribute (if applicable)
        if attribute != "all":
            matched_states = {
                eid: state.get(attribute) for eid, state in matched_states.items()
            }

        if default is not None:
            matched_states = {
                eid: state or default for eid, state in matched_states.items()
            }

        if fully_qualified:
            return matched_states[entity_id]
        else:
            return matched_states

    def _se_listen_state(
        self, callback, entity=None, attribute=None, new=None, old=None, **kwargs
    ):
        spy = StateSpy(
            callback=callback,
            attribute=attribute or "state",
            new=new,
            old=old,
            kwargs=kwargs,
        )
        self._state_spys[entity].append(spy)

Methods

def get_mock(self, meth: str) ‑> unittest.mock.Mock

Returns the mock associated with the provided AppDaemon method

Parameters

meth: The method to retreive the mock implementation for

Expand source code
def get_mock(self, meth: str) -> mock.Mock:
    """
    Returns the mock associated with the provided AppDaemon method

    Parameters:
        meth: The method to retreive the mock implementation for
    """
    return self._mocks[meth]
def inject_mocks(self) ‑> None

Monkey-patch the AppDaemon hassapi.Hass base-class methods with mock implementations.

Expand source code
def inject_mocks(self) -> None:
    """
    Monkey-patch the AppDaemon hassapi.Hass base-class methods with mock
    implementations.
    """
    for meth_name, impl in self._mocks.items():
        if getattr(hass.Hass, meth_name) is None:
            raise AssertionError("Attempt to mock non existing method: ", meth_name)
        _LOGGER.debug("Patching hass.Hass.%s", meth_name)
        setattr(hass.Hass, meth_name, impl)
def set_state(self, entity, state, *, attribute_name='state', previous=None, trigger=None) ‑> None

Update/set state of an entity.

State changes will cause listeners (via listen_state) to be called on their respective state changes.

Parameters

entity: The entity to update state: The state value to set attribute_name: The attribute to set previous: Forced previous value trigger: Whether this change should trigger registered listeners (via listen_state)

Expand source code
def set_state(
    self, entity, state, *, attribute_name="state", previous=None, trigger=None
) -> None:
    """
    Update/set state of an entity.

    State changes will cause listeners (via listen_state) to be called on
    their respective state changes.

    Parameters:
        entity: The entity to update
        state: The state value to set
        attribute_name: The attribute to set
        previous: Forced previous value
        trigger: Whether this change should trigger registered listeners
                 (via listen_state)
    """
    if trigger is None:
        # Avoid triggering state changes during state setup phase
        trigger = not self._setup_active

    domain, _ = entity.split(".")
    state_entry = self._states[entity]
    prev_state = copy(state_entry)
    old_value = previous or prev_state.get(attribute_name)
    new_value = state

    if old_value == new_value:
        return

    # Update the state entry
    state_entry[attribute_name] = new_value

    if not trigger:
        return

    # Notify subscribers of the change
    for spy in self._state_spys[domain] + self._state_spys[entity]:
        sat_attr = spy.attribute == attribute_name or spy.attribute == "all"
        sat_new = spy.new is None or spy.new == new_value
        sat_old = spy.old is None or spy.old == old_value

        param_old = prev_state if spy.attribute == "all" else old_value
        param_new = copy(state_entry) if spy.attribute == "all" else new_value
        param_attribute = None if spy.attribute == "all" else attribute_name

        if all([sat_old, sat_new, sat_attr]):
            spy.callback(entity, param_attribute, param_old, param_new, spy.kwargs)
def setup(self)

A context manager to indicate that execution is taking place during a "setup" phase.

This context manager can be used to configure/set up any existing states that might be required to run the test. State changes during execution within this context manager will cause listen_state handlers to not be called.

Example:

def test_my_app(hass_driver, my_app: MyApp):
    with hass_driver.setup():
        # Any registered listen_state handlers will not be called
        hass_driver.set_state("binary_sensor.motion_detected", "off")

    # Respective listen_state handlers will be called
    hass_driver.set_state("binary_sensor.motion_detected", "on")
    ...
Expand source code
@contextlib.contextmanager
def setup(self):
    """
    A context manager to indicate that execution is taking place during a
    "setup" phase.

    This context manager can be used to configure/set up any existing states
    that might be required to run the test. State changes during execution within
    this context manager will cause `listen_state` handlers to not be called.

    Example:

    ```py
    def test_my_app(hass_driver, my_app: MyApp):
        with hass_driver.setup():
            # Any registered listen_state handlers will not be called
            hass_driver.set_state("binary_sensor.motion_detected", "off")

        # Respective listen_state handlers will be called
        hass_driver.set_state("binary_sensor.motion_detected", "on")
        ...
    ```
    """
    self._setup_active = True
    yield None
    self._setup_active = False