Clean, human-readable tests for your Appdaemon automations.
- Totally transparent, No code modification is needed.
- Mock the state of your home:
given_that.state_of('sensor.temperature').is_set_to('24.9')
- Seamless assertions:
assert_that('light.bathroom').was.turned_on()
- Simulate time:
time_travel.fast_forward(2).minutes()
def test_during_night_light_turn_on(given_that, living_room, assert_that):
given_that.state_of('sensor.living_room_illumination').is_set_to(200) # 200lm == night
living_room._new_motion(None, None, None)
assert_that('light.living_room').was.turned_on()
def test_click_light_turn_on_for_5_minutes(given_that, living_room, assert_that):
living_room._new_button_click(None, None, None)
assert_that('light.bathroom').was.turned_on()
# At T=4min light should not yet have been turned off
time_travel.fast_forward(4).minutes()
assert_that('light.bathroom').was_not.turned_off()
# At T=5min light should have been turned off
time_travel.fast_forward(1).minutes()
time_travel.assert_current_time(5).minutes()
assert_that('light.bathroom').was.turned_off()
- 5-Minutes Quick Start Guide
- General Test Flow and Available helpers
- Examples
- Under The Hood
- Advanced Usage
- Author Information
- Install pytest:
pip install pytest
- Install the framework:
pip install appdaemontestframework
- Copy
conftest.py
at the root of your project
Let's test an Appdaemon automation we created, which, say, handles automatic lighting in the Living Room: class LivingRoom
- Initialize the Automation Under Test in a pytest fixture:
@pytest.fixture def living_room(given_that): living_room = LivingRoom(None, None, None, None, None, None, None, None) living_room.initialize() given_that.mock_functions_are_cleared() return living_room
- Create the instance
living_room = LivingRoom((None, None, None, None, None, None, None, None)
- Don't worry about all these
None
dependencies, they're mocked by the framework
- Replicate Appdaemon lifecycle by calling
living_room.initialize()
- Reset mock functions that might have been called during the previous step:
given_that.mock_functions_are_cleared()
- Create the instance
- Write your first test:
def test_during_night_light_turn_on(given_that, living_room, assert_that): given_that.state_of('sensor.living_room_illumination').is_set_to(200) # 200lm == night living_room._new_motion(None, None, None) assert_that('light.living_room').was.turned_on()
The following fixtures are injected by pytest using the
conftest.py
file and the initialisation fixture created at Step 1:living_room
given_that
assert_that
time_travel
(Optionally)
# Important:
# For this example to work, do not forget to copy the `conftest.py` file.
@pytest.fixture
def living_room(given_that):
living_room = LivingRoom(None, None, None, None, None, None, None, None)
living_room.initialize()
given_that.mock_functions_are_cleared()
return living_room
def test_during_night_light_turn_on(given_that, living_room, assert_that):
given_that.state_of('sensor.living_room_illumination').is_set_to(200) # 200lm == night
living_room._new_motion(None, None, None)
assert_that('light.living_room').was.turned_on()
def test_during_day_light_DOES_NOT_turn_on(given_that, living_room, assert_that):
given_that.state_of('sensor.living_room_illumination').is_set_to(1000) # 1000lm == sunlight
living_room._new_motion(None, None, None)
assert_that('light.living_room').was_not.turned_on()
-
See: Appdaemon - Passing arguments to Apps
# Command given_that.passed_arg(ARG_KEY).is_set_to(ARG_VAL) # Example given_that.passed_arg('color').is_set_to('blue')
-
# Command given_that.state_of(ENTITY_ID).is_set_to(STATE_TO_SET) # Example given_that.state_of(media_player.speaker).is_set_to('playing')
-
# Command given_that.time_is(TIME_AS_DATETIME) # Example given_that.time_is(time(hour=20))
-
# Clear all calls recorded on the mocks given_that.mock_functions_are_cleared() # To also clear all mocked state, use the option: 'clear_mock_states' given_that.mock_functions_are_cleared(clear_mock_states=True) # To also clear all mocked passed args, use the option: 'clear_mock_passed_args' given_that.mock_functions_are_cleared(clear_mock_passed_args=True)
The way Automations work in Appdaemon is:
- First you register callback methods during the
initialize()
phase - At some point Appdaemon will trigger these callbacks
- Your Automation reacts to the call on the callback
To trigger actions on your automation, simply call one of the registered callbacks.
def test_during_night_light_turn_on(given_that, living_room, assert_that):
...
living_room._new_motion(None, None, None)
...
class LivingRoom(hass.Hass):
def initialize(self):
...
self.listen_event(
self._new_motion,
'motion',
entity_id='binary_sensor.bathroom_motion')
...
def _new_motion(self, event_name, data, kwargs):
< Handle motion here >
It is best-practice to have an initial test that will test the callbacks are actually registered as expected during the
initialize()
phase.For now you need to use direct call to the mocked
hass_functions
See: Full example — Kitchen & Direct call tohass_functions
-
# Available commmands assert_that(ENTITY_ID).was.turned_on(OPTIONAL_KWARGS) assert_that(ENTITY_ID).was.turned_off() assert_that(ENTITY_ID).was_not.turned_on(OPTIONAL_KWARGS) assert_that(ENTITY_ID).was_not.turned_off() # Examples assert_that('light.living_room').was.turned_on() assert_that('light.living_room').was.turned_on(color_name=SHOWER_COLOR) assert_that('light.living_room').was_not.turned_off()
-
# Available commmands assert_that(SERVICE).was.called_with(OPTIONAL_KWARGS) assert_that(SERVICE).was_not.called_with(OPTIONAL_KWARGS) # Examples assert_that('notify/pushbullet').was.called_with( message='Hello :)', target='My Phone') assert_that('media_player/volume_set').was.called_with( entity_id='media_player.bathroom_speaker', volume_level=0.6)
This helper simulate going forward in time.
It will run the callbacks registered with the run_in
function of Appdaemon:
- Order is kept
- Callback is run only if due at current simulated time
- Multiples calls can be made in the same test
- Automatically resets between each test (with default config)
# Available commmands
## Simulate time
time_travel.fast_forward(MINUTES).minutes()
time_travel.fast_forward(SECONDS).seconds()
## Assert time in test — Only useful for sanity check
time_travel.assert_current_time(MINUTES).minutes()
time_travel.assert_current_time(SECONDS).seconds()
# Example
# 2 services:
# * 'first/service': Should be called at T=3min
# * 'second/service': Should be called at T=5min
time_travel.assert_current_time(0).minutes()
time_travel.fast_forward(3).minutes()
assert_that('some/service').was.called()
assert_that('some_other/service').was_not.called()
time_travel.fast_forward(2).minutes()
assert_that('some_other/service').was.called()
- Kitchen
- Bathroom
This section is entirely optional
For a guide on how to use the framework, see the above sections!
Why a test framework dedicated for Appdaemon?
The way Appdaemon allow the user to implement automations is based on inheritance.
This makes testing not so trivial.
This test framework abstracts away all that complexity, allowing for a smooth TDD experience.
Couldn't we just use the MVP pattern with clear interfaces at the boundaries?
Yes we could... but would we?
Let's be pragmatic, with that kind of project we're developing for our home, and we're a team of one.
While being a huge proponent for clean architecture, I believe using such a complex architecture for such a simple project would only result in bringing more complexity than necessary.
Every Automation in Appdaemon follows the same model:
- Inherit from
hass.Hass
- Call Appdaemon API through
self
AppdaemonTestFramework captures all calls to the API and helpers make use of the information to implement common functionality needed in our tests.
Methods from the hass.Hass
class are patched globally, and injected in the helper classes.
This is done with the patch_hass()
wich returns a tuple containing:
hass_functions
: dictionary with all patched functionsunpatch_callback
: callback to un-patch all patched functions
hass_functions
are injected in the helpers when creating their instances.
After all tests, unpatch_callback
is used to un-patch all patched functions.
Setup and teardown are handled in the conftest.py
file.
- Patch
hass.Hass
functions - Inject
hass_functions
in helpers:given_that
,assert_that
,time_travel
- Run the test suite
- Un-patch
hass.Hass
functions
If you do no wish to use pytest
, first maybe reconsider, pytest
is awesome :)
If you're really set on using something else, worry not it's pretty straighforward too ;)
What the conftest.py
file is doing is simply handling the setup & teardown, as well as providing the helpers as injectable fixtures.
It is pretty easy to replicate the same behavior with your test framework of choice. For instance, with unittest
a base TestCase
can replace pytest conftest.py
. See the Unittest Example
/!\ WARNING — EXPERIMENTAL /!\
Want a functionality not implemented by the helpers?
You can inject hass_functions
directly in your tests, patched functions are MagicMocks
.
The list of patched functions can be found in the init_framework
module.
Follow me on Twitter: @ThisIsFlorianK
Find out more about my work: Florian Kempenich — Personal Website