Skip to content

Commit 537ff4e

Browse files
committed
Merge branch 'testing-docs'
1 parent e7ae491 commit 537ff4e

16 files changed

+336
-141
lines changed

docs/INTERNALS.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ to run in the correct order, which is the reverse order that the contexts were e
5656
Contexts
5757
~~~~~~~~
5858

59-
The various context managers that get entered as AppDaemon is started include the logic for these steps. Some of these
59+
The various context managers that get entered as AppDaemon is started include the logic for following steps. Some of these
6060
are entered as part of the ``ADMain`` context, and some are entered in the :py:class:`~appdaemon.__main__.ADMain.run`
6161
method. All of them are exited in reverse order as the :py:class:`~contextlib.ExitStack` is closed, which happens when
6262
``ADMain`` context is exited.

docs/TESTING.rst

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
Testing AppDaemon
2+
=================
3+
4+
AppDaemon uses `pytest <https://docs.pytest.org/en/stable/>`_ and `pytest-asyncio <https://pytest-asyncio.readthedocs.io/en/stable/>`_.
5+
6+
Background
7+
----------
8+
9+
- Pytest is configured in the this section in the `pyproject.toml <https://docs.pytest.org/en/stable/reference/customize.html#pyproject-toml>`_ file.
10+
11+
.. literalinclude:: ../pyproject.toml
12+
:language: toml
13+
:lines: 93-102
14+
:caption: Pytest configuration options
15+
16+
- Pytest-asyncio manages creating the event loop, which is normally handled by :py:class:`~appdaemon.__main__.ADMain`.
17+
The event loop persists throughout the test session and is reused many times by different instantiations of the
18+
top-level :py:class:`~appdaemon.appdaemon.AppDaemon` class.
19+
- Instantiating the :py:class:`~appdaemon.appdaemon.AppDaemon` class can now be done without any side effects. This will
20+
also instantiate the necessary subsystem classes, but nothing will really start happening until the :py:meth:`~appdaemon.appdaemon.AppDaemon.start` method is called.
21+
- The :py:class:`~appdaemon.appdaemon.AppDaemon` object is provided as a `pytest fixture <https://docs.pytest.org/en/stable/how-to/fixtures.html#>`_ (``ad``)
22+
with the ``function`` scope, which means it will be recreated fresh for each test function. The fixture handles starting
23+
and stopping the :py:class:`~appdaemon.appdaemon.AppDaemon` instance before/after each test. It also disables all apps,
24+
so they can be selectively run by the tests.
25+
- Apps can be modified before they're run by the :py:class:`~appdaemon.app_management.AppManagement` object.
26+
- The ``run_app_for_time`` fixture combines the functionality of the ``ad`` fixture with the
27+
:py:meth:`~appdaemon.app_management.AppManagement.app_run_context` so that apps can be temporarily modified and run
28+
for short periods.
29+
30+
31+
Running Tests
32+
-------------
33+
34+
Use the `uv run <https://docs.astral.sh/uv/reference/cli/#uv-run>`_ command to ensure uv handles the environment. It will make sure the dependencies are all satisfied.
35+
36+
.. code-block:: bash
37+
:caption: Run all tests
38+
39+
uv run pytest
40+
41+
42+
Unit Tests
43+
~~~~~~~~~~
44+
45+
Unit tests don't require AppDaemon to be running and should be run frequently during development. Currently the only unit tests are ones that cover datetime and timedelta parsing.
46+
47+
.. code-block:: bash
48+
:caption: Run unit tests
49+
50+
uv run pytest -m unit
51+
52+
53+
Functional
54+
~~~~~~~~~~
55+
56+
Functional tests cover various end-to-end interactions between components, so they require AppDaemon to be running. An example would be starting an app, having it register a callback for an event, firing that event, and checking that the callback was called. These should cover as many corner cases as possible
57+
58+
.. code-block:: bash
59+
:caption: Run functional tests
60+
61+
uv run pytest -m functional
62+
63+
Startup Tests
64+
^^^^^^^^^^^^^
65+
66+
.. autofunction:: tests.functional.test_startup.test_hello_world
67+
68+
Event Tests
69+
^^^^^^^^^^^
70+
71+
.. autoclass:: tests.functional.test_event.TestEventCallback
72+
:members:
73+
74+
State Tests
75+
^^^^^^^^^^^
76+
77+
.. autoclass:: tests.functional.test_state.TestStateCallback
78+
:members:
79+
80+
CI Tests
81+
~~~~~~~~
82+
83+
The CI tests get run as part of the GitHub action on PRs to the ``dev`` branch. They're intended to each run more or less instantly and collectively only take a few seconds.
84+
85+
.. code-block:: bash
86+
:caption: Run CI tests
87+
88+
uv run pytest -m ci
89+
90+
Plugin Tests
91+
~~~~~~~~~~~~
92+
93+
Testing plugins involves either connecting to or mocking external systems, so aren't yet covered.

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ Contents:
7474
WIDGETDEV
7575
DEV
7676
INTERNALS
77+
TESTING
7778
REST_STREAM_API
7879
UPGRADE_FROM_3.x
7980
UPGRADE_FROM_2.x

pyproject.toml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,15 +89,14 @@ exclude = ["contrib", "docs", "docs.*", "tests*"]
8989
[tool.setuptools.dynamic]
9090
version = {attr = "appdaemon.version.__version__"}
9191

92-
# Taken from: https://docs.pytest.org/en/7.1.x/explanation/goodpractices.html
92+
# https://docs.pytest.org/en/stable/explanation/goodpractices.html
9393
[tool.pytest.ini_options]
9494
asyncio_mode = "strict"
9595
asyncio_default_test_loop_scope = "session"
9696
asyncio_default_fixture_loop_scope = "session"
97-
addopts = [
98-
"--import-mode=importlib",
99-
]
97+
addopts = ["--import-mode=importlib"]
10098
markers = [
99+
"unit: mark test as unit test",
101100
"ci: mark test to run in CI environment",
102101
"functional: mark test as functional test",
103102
]
@@ -148,7 +147,8 @@ lint.select = ["E", "F"]
148147
lint.ignore = []
149148

150149
# Allow autofix for all enabled rules (when `--fix`) is provided.
151-
lint.fixable = ["E", "F", "UP"]
150+
# lint.fixable = ["E", "F", "UP"]
151+
lint.fixable = ["ALL"]
152152
lint.unfixable = []
153153

154154
# Allow unused variables when underscore-prefixed.

tests/conf/apps/event_test_app.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ class TestEventCallback(ADAPI):
1818
fire_kwargs: The keyword arguments to pass to the fire_event method
1919
"""
2020
def initialize(self):
21-
self.log(f"{self.__class__.__name__} initialized")
21+
self.execute_event = asyncio.Event()
2222
self.listen_event(self.event_callback, self.event, **self.listen_kwargs)
2323
self.run_in(self.test_fire, delay=self.args.get("delay", 0.1), **self.fire_kwargs)
24-
self.execute_event = asyncio.Event()
24+
self.log(f"{self.__class__.__name__} initialized")
2525

2626
@property
2727
def event(self) -> str:

tests/conf/apps/state_test_app.py

Lines changed: 21 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import asyncio
12
from enum import Enum, auto
2-
from functools import partial
3+
from typing import Any
34

45
from appdaemon.adapi import ADAPI
56

@@ -27,56 +28,33 @@ class StateTestApp(ADAPI):
2728
"""A simple AppDaemon app to test state management."""
2829

2930
def initialize(self):
31+
self.set_log_level("DEBUG")
3032
self.log("Hello from AppDaemon")
31-
self.add_namespace("test", persist=False)
33+
self.execute_event = asyncio.Event()
3234
self.set_namespace("test")
33-
3435
self.add_entity(TEST_ENTITY, state="initialized")
35-
36-
listen = partial(self.listen_state, self.state_callback, TEST_ENTITY)
37-
run_soon = partial(self.run_in, self.change_state, delay=self.delay)
38-
39-
self.log(f"Running in {self.mode} mode")
40-
match self.mode:
41-
case StateTestAppMode.ATTRIBUTES:
42-
run_soon = partial(run_soon, test_kwarg=self.args["test_kwarg"])
43-
case StateTestAppMode.LISTEN_KWARGS:
44-
listen = partial(listen, listen_kwarg=self.args["test_kwarg"])
45-
case StateTestAppMode.NEW_STATE_FILTER_POSITIVE | StateTestAppMode.NEW_STATE_FILTER_NEGATIVE:
46-
listen = partial(listen, new=self.args["new"])
47-
case StateTestAppMode.NEW_ATTRIBUTE_FILTER_POSITIVE | StateTestAppMode.NEW_ATTRIBUTE_FILTER_NEGATIVE:
48-
listen = partial(listen, attribute=self.args["attribute"], new="changed")
49-
attrs = {self.args["attribute"]: self.args["value"]}
50-
run_soon = partial(run_soon, **attrs)
51-
52-
self.log(f"Calling listen_state with kwargs: {listen.keywords}")
53-
listen()
54-
55-
change_state_kwargs = run_soon.keywords.copy()
56-
change_state_kwargs.pop("delay", None)
57-
self.log(f"Calling {run_soon.args[0].__name__} with kwargs: {change_state_kwargs}")
58-
run_soon()
36+
self.listen_state(self.state_callback, TEST_ENTITY, **self.listen_kwargs)
37+
self.run_in(self.test_change_state, delay=self.delay, **self.state_kwargs)
5938

6039
@property
6140
def delay(self) -> float:
6241
return self.args.get("delay", 0.1)
6342

6443
@property
65-
def mode(self) -> StateTestAppMode:
66-
return StateTestAppMode(self.args.get("mode", StateTestAppMode.BASIC))
67-
68-
def change_state(self, **kwargs) -> None:
69-
"""Change the state of the test_state entity."""
70-
kwargs.pop("__thread_id", None)
71-
match self.mode:
72-
case StateTestAppMode.BASIC:
73-
self.set_state(TEST_ENTITY, state="changed")
74-
case StateTestAppMode.NEW_ATTRIBUTE_FILTER_POSITIVE | StateTestAppMode.NEW_ATTRIBUTE_FILTER_NEGATIVE:
75-
self.set_state(TEST_ENTITY, attributes=kwargs)
76-
case _:
77-
self.set_state(TEST_ENTITY, state="changed", attributes=kwargs)
78-
79-
def state_callback(self, entity: str, attribute: str, old: str, new: str, **kwargs) -> None:
44+
def listen_kwargs(self) -> dict[str, Any]:
45+
return self.args.get("listen_kwargs", {})
46+
47+
@property
48+
def state_kwargs(self) -> dict[str, Any]:
49+
return self.args.get("state_kwargs", {})
50+
51+
def test_change_state(self, **kwargs):
52+
self.set_state(TEST_ENTITY, **kwargs)
53+
54+
def state_callback(self, entity: str, attribute: str, old: Any, new: Any, **kwargs: Any) -> None:
55+
assert isinstance(entity, str), "Entity should be a string"
56+
assert isinstance(attribute, str), "Attribute should be a string"
57+
8058
self.log(f' {entity}.{attribute} '.center(40, '-'))
8159
self.log(f"{entity}.{attribute} changed from {old} to {new} with kwargs: {kwargs}")
8260

@@ -85,3 +63,4 @@ def state_callback(self, entity: str, attribute: str, old: str, new: str, **kwar
8563

8664
self.log(f"New state for {entity}: {new_state}")
8765
self.log("State callback executed successfully")
66+
self.execute_event.set()

tests/functional/test_event.py

Lines changed: 42 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
11
import asyncio
22
import logging
33
import uuid
4-
from collections.abc import Callable
5-
from contextlib import AbstractAsyncContextManager
64

75
import pytest
86
from appdaemon.app_management import ManagedObject
9-
from appdaemon.appdaemon import AppDaemon
107

11-
logger = logging.getLogger("AppDaemon._test")
12-
13-
AsyncTempTest = Callable[..., AbstractAsyncContextManager[tuple[AppDaemon, pytest.LogCaptureFixture]]]
8+
from .utils import AsyncTempTest
149

10+
logger = logging.getLogger("AppDaemon._test")
1511

1612
@pytest.mark.ci
1713
@pytest.mark.functional
1814
class TestEventCallback:
15+
"""Class to group the various tests for event callbacks."""
1916
app_name: str = "test_event_app"
2017

2118
@pytest.mark.asyncio(loop_scope="session")
@@ -25,17 +22,17 @@ async def test_event_callback(self, run_app_for_time: AsyncTempTest) -> None:
2522
Process:
2623
- Unique values are generated for the event and its kwargs
2724
- The run_app_for_time context manager is used to run the test_event_app temporarily
28-
- Event test app listens for an event and fires the same event shortly after
29-
- Wait for the async execute_event to be set by the event callback in the app
30-
- Clear the event
25+
- Event test app listens for an event and fires the same event shortly after
26+
- Wait for :py:class:`~asyncio.Event` to be set by the callback in the app
27+
- Clear the :py:class:`~asyncio.Event`
3128
- Set the app arg to another event name so it will no longer match the event that was initially listened for
3229
- Fire the new event
33-
- Wait for the async execute_event again, expecting a timeout.
30+
- Wait for the :py:class:`~asyncio.Event` again, expecting a timeout.
3431
3532
Coverage:
36-
- listen_event results in the callback being called for corresponding events
37-
- keyword arguments provided in listen_event are passed to the callback in ``kwargs``
38-
- keyword arguments provided in the fire_event call are passed to the callback in ``data``
33+
- ``listen_event`` results in the callback being called for corresponding events
34+
- keyword arguments provided in ``listen_event`` are passed to the callback in ``kwargs``
35+
- keyword arguments provided in the ``fire_event`` call are passed to the callback in ``data``
3936
"""
4037
listen_id = str(uuid.uuid4())
4138
fire_id = str(uuid.uuid4())
@@ -73,31 +70,32 @@ async def test_event_callback(self, run_app_for_time: AsyncTempTest) -> None:
7370
async def test_event_callback_filtered(self, run_app_for_time: AsyncTempTest, sign: bool) -> None:
7471
"""Test the event callback filtering based on keyword arguments.
7572
76-
If the event data has a key that matches one of the kwargs provided in the listen_event call, then the values
73+
If the event data has a key that matches one of the kwargs provided in the ``listen_event`` call, then the values
7774
for those keys must also match for the callback to be executed.
7875
76+
Process:
77+
- A unique value is generated for firing the event. If the callback is supposed to fire (positive case),
78+
then the same value is used for listening to the event. Otherwise (negative case), a different, unique
79+
value is used to listen for the event, which will prevent the callback from executing.
80+
- The unique fire and listen values are passed to the app as args.
81+
- The ``test_event_app`` app is run until a python :py:class:`~asyncio.Event` is set.
82+
- The :py:class:`~asyncio.Event` is created when the app initializes.
83+
- The app listens for the event and then fires it after a short delay, using the relevant kwargs for each.
84+
- If the callback is executed, :py:class:`~asyncio.Event` is set, and the unique values are printed in the logs.
85+
7986
Coverage:
80-
- Event filtering based on kwargs provided to listen_event
81-
- Positive case: There is a matching key between the listen kwargs and the event data and the values
82-
match
83-
- Negative case: There is a matching key between the listen kwargs and the event data but the values
84-
do not match
87+
- Positive
88+
There is a matching key between the listen kwargs and the event data and the values match, so the callback is executed.
89+
- Negative
90+
There is a matching key between the listen kwargs and the event data but the values do not match, so the callback is not executed.
8591
"""
86-
listen_id = str(uuid.uuid4())
8792
fire_id = str(uuid.uuid4())
93+
listen_id = fire_id if sign else str(uuid.uuid4())
8894
test_kwarg_name = "test_kwarg"
89-
if sign:
90-
# The kwarg name and value must match for the callback to work.
91-
app_args = {
92-
"listen_kwargs": {test_kwarg_name: listen_id},
93-
"fire_kwargs": {test_kwarg_name: listen_id},
94-
}
95-
else:
96-
# The kwarg name must match, but the value must be different for the callback to be filtered.
97-
app_args = {
98-
"listen_kwargs": {test_kwarg_name: listen_id},
99-
"fire_kwargs": {test_kwarg_name: fire_id},
100-
}
95+
app_args = {
96+
"listen_kwargs": {test_kwarg_name: listen_id},
97+
"fire_kwargs": {test_kwarg_name: fire_id},
98+
}
10199

102100
async with run_app_for_time(self.app_name, **app_args) as (ad, caplog):
103101
match ad.app_management.objects.get(self.app_name):
@@ -122,10 +120,15 @@ async def test_event_callback_namespace(self, run_app_for_time: AsyncTempTest, s
122120
123121
Event callbacks should only be fired for events in the correct namespace.
124122
123+
Process:
124+
- The event test app is given namespaces to listen and fire the event in.
125+
- The app listens for the event and then fires it after a short delay, using the relevant namespaces for each.
126+
125127
Coverage:
126-
- Event callbacks should only be fired for events in the correct namespace.
127-
- Positive case: The listen and fire namespaces match
128-
- Negative case: The listen and fire namespaces do not match
128+
- Positive
129+
The listen and fire namespaces match, so the callback is executed.
130+
- Negative
131+
The listen and fire namespaces do not match, so the callback is not executed.
129132
"""
130133
namespace = "test"
131134
if sign:
@@ -160,6 +163,10 @@ async def test_event_callback_oneshot(self, run_app_for_time: AsyncTempTest) ->
160163
161164
Event callbacks that are registered with the oneshot flag should only be fired once.
162165
166+
Process:
167+
- Listen for an event with the oneshot flag set.
168+
- Fire the event twice, and ensure that the callback is only fired once.
169+
163170
Coverage:
164171
- Event callbacks that are registered with the oneshot flag should only be fired once.
165172
"""

tests/functional/test_startup.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
@pytest.mark.parametrize("app_name", ["hello_world", "another_app"])
1212
@pytest.mark.asyncio(loop_scope="session")
1313
async def test_hello_world(ad: AppDaemon, caplog: pytest.LogCaptureFixture, app_name: str) -> None:
14+
"""Run one of the hello world apps and ensure that the startup text is in the logs."""
15+
1416
ad.app_dir = ad.config_dir / "apps/hello_world"
1517
assert ad.app_dir.exists(), "App directory does not exist"
1618
logger.info("Test started")

0 commit comments

Comments
 (0)