Improve testing harness to allow tests on real devices (#197)

* test_cli: provide return values to patched objects to avoid warning about non-awaited calls

* test_cli: restore alias after testing

* smartstrip: remove internal update() calls for turn_{on,off}, set_led

* Make sure power is always a float

* Fix discovery tests

* Make tests runnable on real devices

* Add a note about running tests on a real device

* test_strip: run update against the parent device
This commit is contained in:
Teemu R 2021-09-19 23:45:48 +02:00 committed by GitHub
parent b088596205
commit 1803a83ae6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 70 additions and 24 deletions

View File

@ -94,6 +94,15 @@ This will make sure that the checks are passing when you do a commit.
You can also execute the checks by running either `tox -e lint` to only do the linting checks, or `tox` to also execute the tests. You can also execute the checks by running either `tox -e lint` to only do the linting checks, or `tox` to also execute the tests.
### Running tests
You can run tests on the library by executing `pytest` in the source directory.
This will run the tests against contributed example responses, but you can also execute the tests against a real device:
```
pytest --ip <address>
```
Note that this will perform state changes on the device.
### Analyzing network captures ### Analyzing network captures
The simplest way to add support for a new device or to improve existing ones is to capture traffic between the mobile app and the device. The simplest way to add support for a new device or to improve existing ones is to capture traffic between the mobile app and the device.

View File

@ -247,6 +247,7 @@ async def alias(dev, new_alias, index):
if new_alias is not None: if new_alias is not None:
click.echo(f"Setting alias to {new_alias}") click.echo(f"Setting alias to {new_alias}")
click.echo(await dev.set_alias(new_alias)) click.echo(await dev.set_alias(new_alias))
await dev.update()
click.echo(f"Alias: {dev.alias}") click.echo(f"Alias: {dev.alias}")
if dev.is_strip: if dev.is_strip:

View File

@ -603,7 +603,7 @@ class SmartDevice:
raise SmartDeviceException("Device has no emeter") raise SmartDeviceException("Device has no emeter")
response = EmeterStatus(await self.get_emeter_realtime()) response = EmeterStatus(await self.get_emeter_realtime())
return response["power"] return float(response["power"])
async def reboot(self, delay: int = 1) -> None: async def reboot(self, delay: int = 1) -> None:
"""Reboot the device. """Reboot the device.
@ -658,7 +658,8 @@ class SmartDevice:
def device_id(self) -> str: def device_id(self) -> str:
"""Return unique ID for the device. """Return unique ID for the device.
This is the MAC address of the device. If not overridden, this is the MAC address of the device.
Individual sockets on strips will override this.
""" """
return self.mac return self.mac

View File

@ -100,12 +100,10 @@ class SmartStrip(SmartDevice):
async def turn_on(self, **kwargs): async def turn_on(self, **kwargs):
"""Turn the strip on.""" """Turn the strip on."""
await self._query_helper("system", "set_relay_state", {"state": 1}) await self._query_helper("system", "set_relay_state", {"state": 1})
await self.update()
async def turn_off(self, **kwargs): async def turn_off(self, **kwargs):
"""Turn the strip off.""" """Turn the strip off."""
await self._query_helper("system", "set_relay_state", {"state": 0}) await self._query_helper("system", "set_relay_state", {"state": 0})
await self.update()
@property # type: ignore @property # type: ignore
@requires_update @requires_update
@ -126,7 +124,6 @@ class SmartStrip(SmartDevice):
async def set_led(self, state: bool): async def set_led(self, state: bool):
"""Set the state of the led (night mode).""" """Set the state of the led (night mode)."""
await self._query_helper("system", "set_led_off", {"off": int(not state)}) await self._query_helper("system", "set_led_off", {"off": int(not state)})
await self.update()
@property # type: ignore @property # type: ignore
@requires_update @requires_update

View File

@ -147,7 +147,7 @@ def get_device_for_file(file):
with open(p) as f: with open(p) as f:
sysinfo = json.load(f) sysinfo = json.load(f)
model = basename(file) model = basename(file)
p = device_for_file(model)(host="123.123.123.123") p = device_for_file(model)(host="127.0.0.123")
p.protocol = FakeTransportProtocol(sysinfo) p.protocol = FakeTransportProtocol(sysinfo)
asyncio.run(p.update()) asyncio.run(p.update())
return p return p
@ -168,21 +168,29 @@ def dev(request):
asyncio.run(d.update()) asyncio.run(d.update())
if d.model in file: if d.model in file:
return d return d
raise Exception("Unable to find type for %s" % ip) else:
pytest.skip(f"skipping file {file}")
return get_device_for_file(file) return get_device_for_file(file)
def pytest_addoption(parser): def pytest_addoption(parser):
parser.addoption("--ip", action="store", default=None, help="run against device") parser.addoption(
"--ip", action="store", default=None, help="run against device on given ip"
)
def pytest_collection_modifyitems(config, items): def pytest_collection_modifyitems(config, items):
if not config.getoption("--ip"): if not config.getoption("--ip"):
print("Testing against fixtures.") print("Testing against fixtures.")
return
else: else:
print("Running against ip %s" % config.getoption("--ip")) print("Running against ip %s" % config.getoption("--ip"))
requires_dummy = pytest.mark.skip(
reason="test requires to be run against dummy data"
)
for item in items:
if "requires_dummy" in item.keywords:
item.add_marker(requires_dummy)
# allow mocks to be awaited # allow mocks to be awaited

View File

@ -124,7 +124,7 @@ LIGHT_STATE_SCHEMA = Schema(
"dft_on_state": Optional( "dft_on_state": Optional(
{ {
"brightness": All(int, Range(min=0, max=100)), "brightness": All(int, Range(min=0, max=100)),
"color_temp": All(int, Range(min=2000, max=9000)), "color_temp": All(int, Range(min=0, max=9000)),
"hue": All(int, Range(min=0, max=255)), "hue": All(int, Range(min=0, max=255)),
"mode": str, "mode": str,
"saturation": All(int, Range(min=0, max=255)), "saturation": All(int, Range(min=0, max=255)),

View File

@ -66,6 +66,7 @@ async def test_hsv(dev, turn_on):
await dev.set_hsv(hue=1, saturation=1, value=1) await dev.set_hsv(hue=1, saturation=1, value=1)
await dev.update()
hue, saturation, brightness = dev.hsv hue, saturation, brightness = dev.hsv
assert hue == 1 assert hue == 1
assert saturation == 1 assert saturation == 1
@ -134,6 +135,7 @@ async def test_variable_temp_state_information(dev):
async def test_try_set_colortemp(dev, turn_on): async def test_try_set_colortemp(dev, turn_on):
await handle_turn_on(dev, turn_on) await handle_turn_on(dev, turn_on)
await dev.set_color_temp(2700) await dev.set_color_temp(2700)
await dev.update()
assert dev.color_temp == 2700 assert dev.color_temp == 2700
@ -179,9 +181,11 @@ async def test_dimmable_brightness(dev, turn_on):
assert dev.is_dimmable assert dev.is_dimmable
await dev.set_brightness(50) await dev.set_brightness(50)
await dev.update()
assert dev.brightness == 50 assert dev.brightness == 50
await dev.set_brightness(10) await dev.set_brightness(10)
await dev.update()
assert dev.brightness == 10 assert dev.brightness == 10
with pytest.raises(ValueError): with pytest.raises(ValueError):

View File

@ -18,7 +18,7 @@ async def test_state(dev, turn_on):
await handle_turn_on(dev, turn_on) await handle_turn_on(dev, turn_on)
runner = CliRunner() runner = CliRunner()
res = await runner.invoke(state, obj=dev) res = await runner.invoke(state, obj=dev)
print(res.output) await dev.update()
if dev.is_on: if dev.is_on:
assert "Device state: ON" in res.output assert "Device state: ON" in res.output
@ -32,6 +32,8 @@ async def test_alias(dev):
res = await runner.invoke(alias, obj=dev) res = await runner.invoke(alias, obj=dev)
assert f"Alias: {dev.alias}" in res.output assert f"Alias: {dev.alias}" in res.output
old_alias = dev.alias
new_alias = "new alias" new_alias = "new alias"
res = await runner.invoke(alias, [new_alias], obj=dev) res = await runner.invoke(alias, [new_alias], obj=dev)
assert f"Setting alias to {new_alias}" in res.output assert f"Setting alias to {new_alias}" in res.output
@ -39,6 +41,8 @@ async def test_alias(dev):
res = await runner.invoke(alias, obj=dev) res = await runner.invoke(alias, obj=dev)
assert f"Alias: {new_alias}" in res.output assert f"Alias: {new_alias}" in res.output
await dev.set_alias(old_alias)
async def test_raw_command(dev): async def test_raw_command(dev):
runner = CliRunner() runner = CliRunner()
@ -63,11 +67,13 @@ async def test_emeter(dev: SmartDevice, mocker):
assert "== Emeter ==" in res.output assert "== Emeter ==" in res.output
monthly = mocker.patch.object(dev, "get_emeter_monthly") monthly = mocker.patch.object(dev, "get_emeter_monthly")
monthly.return_value = []
res = await runner.invoke(emeter, ["--year", "1900"], obj=dev) res = await runner.invoke(emeter, ["--year", "1900"], obj=dev)
assert "For year" in res.output assert "For year" in res.output
monthly.assert_called() monthly.assert_called()
daily = mocker.patch.object(dev, "get_emeter_daily") daily = mocker.patch.object(dev, "get_emeter_daily")
daily.return_value = []
res = await runner.invoke(emeter, ["--month", "1900-12"], obj=dev) res = await runner.invoke(emeter, ["--month", "1900-12"], obj=dev)
assert "For month" in res.output assert "For month" in res.output
daily.assert_called() daily.assert_called()

View File

@ -8,14 +8,14 @@ from .conftest import bulb, dimmer, lightstrip, plug, pytestmark, strip
@plug @plug
async def test_type_detection_plug(dev: SmartDevice): async def test_type_detection_plug(dev: SmartDevice):
d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") d = Discover._get_device_class(dev._last_update)("localhost")
assert d.is_plug assert d.is_plug
assert d.device_type == DeviceType.Plug assert d.device_type == DeviceType.Plug
@bulb @bulb
async def test_type_detection_bulb(dev: SmartDevice): async def test_type_detection_bulb(dev: SmartDevice):
d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") d = Discover._get_device_class(dev._last_update)("localhost")
# TODO: light_strip is a special case for now to force bulb tests on it # TODO: light_strip is a special case for now to force bulb tests on it
if not d.is_light_strip: if not d.is_light_strip:
assert d.is_bulb assert d.is_bulb
@ -24,21 +24,21 @@ async def test_type_detection_bulb(dev: SmartDevice):
@strip @strip
async def test_type_detection_strip(dev: SmartDevice): async def test_type_detection_strip(dev: SmartDevice):
d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") d = Discover._get_device_class(dev._last_update)("localhost")
assert d.is_strip assert d.is_strip
assert d.device_type == DeviceType.Strip assert d.device_type == DeviceType.Strip
@dimmer @dimmer
async def test_type_detection_dimmer(dev: SmartDevice): async def test_type_detection_dimmer(dev: SmartDevice):
d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") d = Discover._get_device_class(dev._last_update)("localhost")
assert d.is_dimmer assert d.is_dimmer
assert d.device_type == DeviceType.Dimmer assert d.device_type == DeviceType.Dimmer
@lightstrip @lightstrip
async def test_type_detection_lightstrip(dev: SmartDevice): async def test_type_detection_lightstrip(dev: SmartDevice):
d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") d = Discover._get_device_class(dev._last_update)("localhost")
assert d.is_light_strip assert d.is_light_strip
assert d.device_type == DeviceType.LightStrip assert d.device_type == DeviceType.LightStrip

View File

@ -32,6 +32,7 @@ async def test_get_emeter_realtime(dev):
@has_emeter @has_emeter
@pytest.mark.requires_dummy
async def test_get_emeter_daily(dev): async def test_get_emeter_daily(dev):
if dev.is_strip: if dev.is_strip:
pytest.skip("Disabled for strips temporarily") pytest.skip("Disabled for strips temporarily")
@ -54,6 +55,7 @@ async def test_get_emeter_daily(dev):
@has_emeter @has_emeter
@pytest.mark.requires_dummy
async def test_get_emeter_monthly(dev): async def test_get_emeter_monthly(dev):
if dev.is_strip: if dev.is_strip:
pytest.skip("Disabled for strips temporarily") pytest.skip("Disabled for strips temporarily")

View File

@ -20,9 +20,11 @@ async def test_led(dev):
original = dev.led original = dev.led
await dev.set_led(False) await dev.set_led(False)
await dev.update()
assert not dev.led assert not dev.led
await dev.set_led(True) await dev.set_led(True)
await dev.update()
assert dev.led assert dev.led
await dev.set_led(original) await dev.set_led(original)

View File

@ -13,6 +13,7 @@ async def test_state_info(dev):
assert isinstance(dev.state_information, dict) assert isinstance(dev.state_information, dict)
@pytest.mark.requires_dummy
async def test_invalid_connection(dev): async def test_invalid_connection(dev):
with patch.object(FakeTransportProtocol, "query", side_effect=SmartDeviceException): with patch.object(FakeTransportProtocol, "query", side_effect=SmartDeviceException):
with pytest.raises(SmartDeviceException): with pytest.raises(SmartDeviceException):
@ -49,18 +50,22 @@ async def test_state(dev, turn_on):
orig_state = dev.is_on orig_state = dev.is_on
if orig_state: if orig_state:
await dev.turn_off() await dev.turn_off()
await dev.update()
assert not dev.is_on assert not dev.is_on
assert dev.is_off assert dev.is_off
await dev.turn_on() await dev.turn_on()
await dev.update()
assert dev.is_on assert dev.is_on
assert not dev.is_off assert not dev.is_off
else: else:
await dev.turn_on() await dev.turn_on()
await dev.update()
assert dev.is_on assert dev.is_on
assert not dev.is_off assert not dev.is_off
await dev.turn_off() await dev.turn_off()
await dev.update()
assert not dev.is_on assert not dev.is_on
assert dev.is_off assert dev.is_off
@ -71,9 +76,11 @@ async def test_alias(dev):
assert isinstance(original, str) assert isinstance(original, str)
await dev.set_alias(test_alias) await dev.set_alias(test_alias)
await dev.update()
assert dev.alias == test_alias assert dev.alias == test_alias
await dev.set_alias(original) await dev.set_alias(original)
await dev.update()
assert dev.alias == original assert dev.alias == original

View File

@ -15,20 +15,24 @@ async def test_children_change_state(dev, turn_on):
orig_state = plug.is_on orig_state = plug.is_on
if orig_state: if orig_state:
await plug.turn_off() await plug.turn_off()
assert not plug.is_on await dev.update()
assert plug.is_off assert plug.is_on is False
assert plug.is_off is True
await plug.turn_on() await plug.turn_on()
assert plug.is_on await dev.update()
assert not plug.is_off assert plug.is_on is True
assert plug.is_off is False
else: else:
await plug.turn_on() await plug.turn_on()
assert plug.is_on await dev.update()
assert not plug.is_off assert plug.is_on is True
assert plug.is_off is False
await plug.turn_off() await plug.turn_off()
assert not plug.is_on await dev.update()
assert plug.is_off assert plug.is_on is False
assert plug.is_off is True
@strip @strip

View File

@ -72,6 +72,11 @@ fail-under = 100
exclude = ['kasa/tests/*'] exclude = ['kasa/tests/*']
verbose = 2 verbose = 2
[tool.pytest.ini_options]
markers = [
"requires_dummy: test requires dummy data to pass, skipped on real devices",
]
[build-system] [build-system]
requires = ["poetry>=0.12"] requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api" build-backend = "poetry.masonry.api"