Coder Social home page Coder Social logo

bachya / appdaemon-test-framework Goto Github PK

View Code? Open in Web Editor NEW

This project forked from hellothisisflo/appdaemon-test-framework

0.0 2.0 0.0 156 KB

Clean, human-readable tests for Appdaemon

Home Page: https://floriankempenich.github.io/Appdaemon-Test-Framework/

License: MIT License

Python 100.00%

appdaemon-test-framework's Introduction

Appdaemon Test Framework

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()
How does it look?
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()

Table of Contents


5-Minutes Quick Start Guide

Initial Setup

  1. Install pytest: pip install pytest
  2. Install the framework: pip install appdaemontestframework
  3. Copy conftest.py at the root of your project

Write you first unit test

Let's test an Appdaemon automation we created, which, say, handles automatic lighting in the Living Room: class LivingRoom

  1. Initialize the Automation Under Test in a pytest fixture:
    Complete initialization 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
    Steps breakdown
    1. 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
    2. Replicate Appdaemon lifecycle by calling living_room.initialize()
    3. Reset mock functions that might have been called during the previous step:
      given_that.mock_functions_are_cleared()
  2. Write your first test:
    Our first unit 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()
    Note

    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)

Result

# 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()

General Test Flow and Available helpers

1. Set the stage to prepare for the test: given_that

  • Simulate args passed via apps.yaml config

    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')
  • State

    # 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')
  • Time

    # Command
    given_that.time_is(TIME_AS_DATETIME)
    
    # Example
    given_that.time_is(time(hour=20))
  • Extra

    # 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)

2. Trigger action on your automation

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.

Example

LivingRoomTest.py
def test_during_night_light_turn_on(given_that, living_room, assert_that):
   ...
   living_room._new_motion(None, None, None)
   ...
With LivingRoom.py
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 >

Note

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 to hass_functions

3. Assert on your way out: assert_that

  • Entities

    # 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()
  • Services

    # 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)

Bonus — Travel in Time: time_travel

This helper simulate going forward in time.

It will run the callbacks registered with the run_infunction 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()

Examples

Simple


Under The Hood

This section is entirely optional
For a guide on how to use the framework, see the above sections!

Understand the motivation

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.

Enjoy the simplicity

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:

  1. hass_functions: dictionary with all patched functions
  2. unpatch_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.

Appdaemon Test Framework flow

1. Setup
  • Patch hass.Hass functions
  • Inject hass_functions in helpers: given_that, assert_that, time_travel
2. Test run
  • Run the test suite
3. Teardown
  • Un-patch hass.Hass functions

Advanced Usage

Without pytest

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

Direct call to mocked functions

/!\ 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.


Author Information

Follow me on Twitter: @ThisIsFlorianK
Find out more about my work: Florian Kempenich — Personal Website

appdaemon-test-framework's People

Contributors

hellothisisflo avatar

Watchers

 avatar  avatar

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.